Как делать11 октября 2026 г.8 мин чтения
JavaШаг 3 из 6

SOLID на понятных примерах

Коротко

SOLID — пять принципов объектно-ориентированного проектирования, собранных Робертом Мартином: единственная ответственность (S), открытость/закрытость (O), подстановка Лисков (L), разделение интерфейсов (I) и инверсия зависимостей (D). Все пять отвечают на один вопрос: как устроить код, чтобы изменение в одном месте не ломало другие и чтобы части можно было тестировать по отдельности. Это эвристики, а не законы: у каждой абстракции есть цена, и применять принцип стоит там, где изменения действительно происходят.

ОсновыУровень: СреднийSOLID

После материала вы сможете

  • распознать нарушение каждого из пяти принципов SOLID по симптому в коде: класс меняют разные люди, switch растёт с каждой фичей, подкласс падает на чужих данных, подделка для теста реализует пять методов, сервис сам создаёт HTTP-клиент
  • исправить нарушение на TypeScript в режиме strict и подтвердить, что поведение сохранилось, тестом на node:test
  • объяснить, почему tsc не ловит сужение параметра в методе и как контрактный тест проверяет принцип Лисков
  • решить, когда принцип применять не нужно, и назвать цену лишней абстракции

Проверено на

TypeScript 7.0.2 (tsc --strict)Node.js 24 LTS (24.18), node:testJava 21 LTS (eclipse-temurin 21.0.12)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Зачем SOLID и где его перегибают
  2. 02S — единственная ответственность
  3. 03O — открытость для расширения, закрытость для изменения
  4. 04L — подстановка Лисков
  5. 05I — разделение интерфейсов
  6. 06D — инверсия зависимостей
  7. 07Практика: сервис уведомлений после рефакторинга

Зачем SOLID и где его перегибают

Код, который работает, не обязательно легко менять. Типичная история: сервис уведомлений начинался с одной функции «отправить в Telegram», потом добавились SMS, email, тексты от маркетинга, логи для поддержки — и каждая правка стала задевать соседние. SOLID — пять принципов, которые описывают, как раскладывать обязанности и зависимости, чтобы этого не происходило. Роберт Мартин в заметке «Solid Relevance» (2020) формулирует их коротко:

S — Single Responsibility

Формулировка Мартина (2020)
Собирайте вместе то, что меняется по одним причинам, и разделяйте то, что меняется по разным
Симптом нарушения
Один класс правят маркетинг, разработчики и поддержка

O — Open/Closed

Формулировка Мартина (2020)
Модуль открыт для расширения и закрыт для изменения
Симптом нарушения
Каждый новый вариант — правка того же switch

L — Liskov Substitution

Формулировка Мартина (2020)
Программа, работающая через интерфейс, не должна путаться из-за его реализации
Симптом нарушения
Подкласс падает или ведёт себя иначе на тех же данных

I — Interface Segregation

Формулировка Мартина (2020)
Делайте интерфейсы маленькими, чтобы клиенты не зависели от ненужного
Симптом нарушения
Подделка для теста реализует пять методов ради одного

D — Dependency Inversion

Формулировка Мартина (2020)
Зависьте в сторону абстракций; высокоуровневые модули не должны зависеть от низкоуровневых деталей
Симптом нарушения
Бизнес-логика сама создаёт HTTP-клиент и читает process.env

Все примеры — один домен: сервис записи в салон и его уведомления. Код написан на TypeScript, проверен tsc в строгом режиме и запускается самим Node.js 24 без сборки: начиная с версии 24.12 Node.js стабильно вырезает типы из .ts-файлов. Тесты — на встроенном node:test, без сторонних библиотек. Для каждого принципа есть версия «до» и «после», и тесты проверяют обе.

bash
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node --test
Подготовка проекта: TypeScript только для проверки типов, запуск и тесты — Node.js
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noImplicitOverride": true,
    "types": ["node"]
  },
  "include": ["src", "test"],
  "exclude": ["src/lsp/strict-channel.ts"]
}
erasableSyntaxOnly запрещает синтаксис, который Node.js не умеет вырезать (enum, parameter properties), поэтому поля в примерах объявлены явно. Файл с намеренной ошибкой из раздела о Лисков исключён из общей проверки
src/booking.ts
export type Booking = {
  id: string;
  clientName: string;
  service: string;
  startsAt: Date;
  chatId: string; // Telegram chat of the client
};
Общая модель записи для всех примеров

S — единственная ответственность

Формулировку «у класса должна быть одна причина для изменения» часто понимают как «класс делает одну вещь». Мартин в статье 2014 года уточняет: причины изменений — это люди. В его примере класс Employee считает зарплату (это интересует финансового директора), формирует отчёт по часам (операционного) и сохраняет себя в базу (технического). Три разных заказчика изменений — три ответственности. Посмотрим на сервис напоминаний.

src/srp/reminder-before.ts
import type { Booking } from "../booking.ts";

// One class, three reasons to change: the wording (marketing),
// the delivery channel (developers) and the audit log (operations).
export class ReminderService {
  async remind(booking: Booking): Promise<void> {
    const time = new Intl.DateTimeFormat("en-GB", {
      timeZone: "Asia/Almaty",
      day: "numeric",
      month: "short",
      hour: "2-digit",
      minute: "2-digit",
    }).format(booking.startsAt);
    const text = `${booking.clientName}, see you on ${time}: ${booking.service}`;

    const response = await fetch(
      `https://api.telegram.org/bot${process.env.TELEGRAM_TOKEN}/sendMessage`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ chat_id: booking.chatId, text }),
      },
    );
    if (!response.ok) throw new Error(`Telegram responded ${response.status}`);

    console.log(`${new Date().toISOString()} reminder sent: ${booking.id}`);
  }
}
«До»: текст, доставка и журнал в одном методе
  • Маркетинг просит поменять текст — правка в классе, который ходит в сеть
  • Переход с Telegram на другой канал — правка в классе, где лежит текст
  • Поддержка просит писать журнал в другой формат — снова тот же класс
  • Чтобы проверить текст, тест вынужден подменять глобальный fetch и console.log

Разделим по причинам изменений: текст — чистая функция, доставка — клиент Telegram, сценарий — сервис, который их вызывает. Журнал передаётся снаружи функцией, и сервис больше не знает, куда пишутся строки.

src/srp/reminder-after.ts
import type { Booking } from "../booking.ts";

// Wording: changes when marketing rewrites the text.
export function reminderText(booking: Booking): string {
  const time = new Intl.DateTimeFormat("en-GB", {
    timeZone: "Asia/Almaty",
    day: "numeric",
    month: "short",
    hour: "2-digit",
    minute: "2-digit",
  }).format(booking.startsAt);
  return `${booking.clientName}, see you on ${time}: ${booking.service}`;
}

// Delivery: changes when the Telegram API or the channel changes.
export class TelegramClient {
  readonly #token: string;
  readonly #fetch: typeof fetch;

  constructor(token: string, fetchFn: typeof fetch = (input, init) => fetch(input, init)) {
    this.#token = token;
    this.#fetch = fetchFn;
  }

  async sendMessage(chatId: string, text: string): Promise<void> {
    const response = await this.#fetch(
      `https://api.telegram.org/bot${this.#token}/sendMessage`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ chat_id: chatId, text }),
      },
    );
    if (!response.ok) throw new Error(`Telegram responded ${response.status}`);
  }
}

// Scenario: what happens and in which order. The log is passed in.
export class ReminderService {
  readonly #telegram: TelegramClient;
  readonly #log: (line: string) => void;

  constructor(telegram: TelegramClient, log: (line: string) => void) {
    this.#telegram = telegram;
    this.#log = log;
  }

  async remind(booking: Booking): Promise<void> {
    await this.#telegram.sendMessage(booking.chatId, reminderText(booking));
    this.#log(`reminder sent: ${booking.id}`);
  }
}
«После»: три части, у каждой свой заказчик изменений. fetch по умолчанию обёрнут в стрелочную функцию: в браузере вызов fetch с чужим this может бросить TypeError «Illegal invocation»
test/srp.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Booking } from "../src/booking.ts";
import { ReminderService as ReminderBefore } from "../src/srp/reminder-before.ts";
import { ReminderService, TelegramClient, reminderText } from "../src/srp/reminder-after.ts";

const booking: Booking = {
  id: "b-1",
  clientName: "Aida",
  service: "Haircut",
  startsAt: new Date("2026-10-12T13:00:00Z"), // 18:00 in Almaty (UTC+5)
  chatId: "42",
};

test("before: checking the text means faking global fetch and console", async (t) => {
  const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}"));
  t.mock.method(console, "log", () => {});
  await new ReminderBefore().remind(booking);
  const body = JSON.parse(String(fetchMock.mock.calls[0]?.arguments[1]?.body));
  assert.equal(body.text, "Aida, see you on 12 Oct, 18:00: Haircut");
});

test("after: the wording is a pure function", () => {
  assert.equal(reminderText(booking), "Aida, see you on 12 Oct, 18:00: Haircut");
});

test("after: the scenario runs with a fake fetch and a fake log", async () => {
  const urls: string[] = [];
  const fakeFetch: typeof fetch = async (input) => {
    urls.push(String(input));
    return new Response("{}");
  };
  const lines: string[] = [];
  const service = new ReminderService(new TelegramClient("TEST_TOKEN", fakeFetch), (line) => lines.push(line));

  await service.remind(booking);

  assert.deepEqual(urls, ["https://api.telegram.org/botTEST_TOKEN/sendMessage"]);
  assert.deepEqual(lines, ["reminder sent: b-1"]);
});

test("after: a Telegram error stops the scenario before logging", async () => {
  const failing: typeof fetch = async () => new Response("{}", { status: 502 });
  const lines: string[] = [];
  const service = new ReminderService(new TelegramClient("TEST_TOKEN", failing), (line) => lines.push(line));

  await assert.rejects(service.remind(booking), /Telegram responded 502/);
  assert.deepEqual(lines, []);
});
Тесты: текст проверяется без сети, сценарий — с подделанным fetch, ошибка Telegram не пишет «отправлено» в журнал

O — открытость для расширения, закрытость для изменения

Принцип сформулировал Бертран Мейер в 1988 году. Мартин пересказывает его так: поведение системы должно расширяться без изменения существующего кода — как редактор кода получает новые возможности через плагины, а сам о плагинах не знает. Пример нарушения — функция, которая выбирает канал уведомления через switch.

src/contact.ts
export type Contact = { phone: string; chatId?: string; email?: string };

// Stand-in for an HTTP client of a messaging provider.
export interface Gateway {
  post(endpoint: string, payload: Record<string, string>): Promise<void>;
}
Контакт клиента и заглушка HTTP-шлюза провайдера — общие для «до» и «после»
src/ocp/notify-before.ts
import type { Contact, Gateway } from "../contact.ts";

export type ChannelName = "sms" | "telegram" | "email";

// Every new channel means editing this union and this switch.
export async function notify(
  channel: ChannelName,
  contact: Contact,
  text: string,
  gateway: Gateway,
): Promise<void> {
  switch (channel) {
    case "sms":
      return gateway.post("sms", { phone: contact.phone, text: text.slice(0, 160) });
    case "telegram":
      if (!contact.chatId) throw new Error("No Telegram chat");
      return gateway.post("telegram", { chat_id: contact.chatId, text });
    case "email":
      if (!contact.email) throw new Error("No email");
      return gateway.post("email", { to: contact.email, subject: "Reminder", text });
  }
}
«До»: WhatsApp или push — это правка типа, функции и всех мест, где перечисляются каналы

Правило выбора канала тоже размазано: вызывающий код сам решает, куда отправлять. Вынесем канал в интерфейс с двумя вопросами — «можешь ли ты достучаться до этого клиента?» и «отправь». Notifier перебирает каналы в порядке приоритета и не знает, сколько их и какие они.

src/ocp/notify-after.ts
import type { Contact, Gateway } from "../contact.ts";

export interface Channel {
  readonly name: string;
  canReach(contact: Contact): boolean;
  send(contact: Contact, text: string): Promise<void>;
}

// Closed for modification: adding a channel does not touch this class.
export class Notifier {
  readonly #channels: readonly Channel[];

  constructor(channels: readonly Channel[]) {
    this.#channels = channels;
  }

  // Sends through the first channel (in priority order) that can reach the contact.
  async notify(contact: Contact, text: string): Promise<string> {
    const channel = this.#channels.find((c) => c.canReach(contact));
    if (!channel) throw new Error("No channel can reach this contact");
    await channel.send(contact, text);
    return channel.name;
  }
}

// Open for extension: each channel is a separate object.
export const telegramChannel = (gateway: Gateway): Channel => ({
  name: "telegram",
  canReach: (contact) => Boolean(contact.chatId),
  send: (contact, text) => gateway.post("telegram", { chat_id: contact.chatId ?? "", text }),
});

export const smsChannel = (gateway: Gateway): Channel => ({
  name: "sms",
  canReach: (contact) => contact.phone.length > 0,
  send: (contact, text) => gateway.post("sms", { phone: contact.phone, text: text.slice(0, 160) }),
});
«После»: новый канал — новый объект в списке, Notifier не меняется
test/ocp.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Contact, Gateway } from "../src/contact.ts";
import { notify } from "../src/ocp/notify-before.ts";
import { Notifier, smsChannel, telegramChannel, type Channel } from "../src/ocp/notify-after.ts";

function recordingGateway() {
  const calls: string[] = [];
  const gateway: Gateway = {
    post: async (endpoint, payload) => {
      calls.push(`${endpoint} ${JSON.stringify(payload)}`);
    },
  };
  return { gateway, calls };
}

const withChat: Contact = { phone: "+77010000000", chatId: "42" };
const phoneOnly: Contact = { phone: "+77010000000" };

test("before: the caller picks the channel and the switch knows every channel", async () => {
  const { gateway, calls } = recordingGateway();
  await notify("sms", phoneOnly, "x".repeat(200), gateway);
  assert.equal(calls.length, 1);
  assert.match(calls[0] ?? "", /^sms .*"text":"x{160}"/);
});

test("after: Telegram first, SMS as a fallback", async () => {
  const { gateway, calls } = recordingGateway();
  const notifier = new Notifier([telegramChannel(gateway), smsChannel(gateway)]);

  assert.equal(await notifier.notify(withChat, "Hi"), "telegram");
  assert.equal(await notifier.notify(phoneOnly, "Hi"), "sms");
  assert.deepEqual(calls, [
    'telegram {"chat_id":"42","text":"Hi"}',
    'sms {"phone":"+77010000000","text":"Hi"}',
  ]);
});

test("after: a new channel is added without editing Notifier", async () => {
  const { gateway, calls } = recordingGateway();
  const whatsapp: Channel = {
    name: "whatsapp",
    canReach: (contact) => contact.phone.startsWith("+7"),
    send: (contact, text) => gateway.post("whatsapp", { to: contact.phone, text }),
  };
  const notifier = new Notifier([telegramChannel(gateway), whatsapp, smsChannel(gateway)]);

  assert.equal(await notifier.notify(phoneOnly, "Hi"), "whatsapp");
  assert.deepEqual(calls, ['whatsapp {"to":"+77010000000","text":"Hi"}']);
});

test("after: no suitable channel is an explicit error", async () => {
  const notifier = new Notifier([]);
  await assert.rejects(notifier.notify(phoneOnly, "Hi"), /No channel can reach this contact/);
});
Тест добавляет WhatsApp прямо в тестовом файле — без единой правки notify-after.ts

L — подстановка Лисков

Принцип назван по имени Барбары Лисков. Формулировка Мартина: программа, которая работает через интерфейс, не должна «путаться» из-за конкретной реализации. Любая реализация обязана принимать всё, что принимает контракт, и возвращать то, что он обещает. Мартин подчёркивает, что речь о подтипах, а не о наследовании: принцип действует и для утиной типизации, и для структурных типов TypeScript.

src/lsp/message.ts
export type Message =
  | { kind: "text"; text: string }
  | { kind: "image"; url: string; caption: string };

export type TextMessage = Extract<Message, { kind: "text" }>;

// Contract: accepts any Message and resolves with a delivery id.
export interface Channel {
  send(to: string, message: Message): Promise<string>;
}
Контракт канала: любое сообщение — текст или картинка. В учебном примере каналы возвращают описание отправки вместо реального запроса
src/lsp/channels-before.ts
import type { Channel, Message, TextMessage } from "./message.ts";

export class TelegramChannel implements Channel {
  async send(to: string, message: Message): Promise<string> {
    return message.kind === "text" ? `tg:${to}:${message.text}` : `tg-photo:${to}:${message.url}`;
  }
}

// Compiles: parameters of methods are not checked strictly, so narrowing
// Message to TextMessage goes unnoticed. An image will crash at runtime.
export class SmsChannel implements Channel {
  async send(to: string, message: TextMessage): Promise<string> {
    return `sms:${to}:${message.text.slice(0, 160)}`;
  }
}

export function broadcast(channels: Channel[], to: string, message: Message): Promise<string[]> {
  return Promise.all(channels.map((channel) => channel.send(to, message)));
}
«До»: SmsChannel сузил параметр до текста. tsc --strict ошибки не показывает

Почему компилятор молчит, хотя включён strict? Флаг strictFunctionTypes проверяет параметры строго только у функций, записанных синтаксисом функции. Для методов проверка ослаблена — документация TypeScript объясняет это тем, что иначе пришлось бы ломать множество существующих иерархий классов, в том числе в DOM. Если записать тот же контракт свойством-функцией, компилятор нарушение поймает:

src/lsp/strict-channel.ts
import type { Message, TextMessage } from "./message.ts";

// Property syntax: with strict (strictFunctionTypes) the compiler
// checks the parameter and rejects the narrowed implementation.
interface StrictChannel {
  send: (to: string, message: Message) => Promise<string>;
}

export const sms: StrictChannel = {
  send: async (to: string, message: TextMessage) => `sms:${to}:${message.text}`,
};
Учебный файл с намеренной ошибкой. Проверяется отдельным tsconfig.lsp-demo.json: extends основного, include только этот файл
terminal
$ npx tsc -p tsconfig.lsp-demo.json
src/lsp/strict-channel.ts(10,3): error TS2322: Type '(to: string, message: TextMessage) => Promise<string>' is not assignable to type '(to: string, message: Message) => Promise<string>'.
  Types of parameters 'message' and 'message' are incompatible.
    Type 'Message' is not assignable to type '{ kind: "text"; text: string; }'.
      Property 'text' is missing in type '{ kind: "image"; url: string; caption: string; }' but required in type '{ kind: "text"; text: string; }'.
Реальный вывод tsc 7.0.2

Но даже свойство-функция защищает только типы. Принцип Лисков шире: реализация не должна усиливать предусловия («только короткий текст»), ослаблять постусловия («иногда молча не отправляю») или бросать исключения, которых контракт не обещает. Этого компилятор не видит, поэтому контракт проверяют тестом, который прогоняется на каждой реализации. Исправленный SMS-канал принимает любое сообщение: картинку он отправить не может, поэтому отправляет подпись и ссылку.

src/lsp/channels-after.ts
import type { Channel, Message } from "./message.ts";

// Keeps the contract: accepts every kind of Message.
// SMS cannot carry a picture, so it sends the caption and a link.
export class SmsChannel implements Channel {
  async send(to: string, message: Message): Promise<string> {
    const text = message.kind === "text" ? message.text : `${message.caption} ${message.url}`;
    return `sms:${to}:${text.slice(0, 160)}`;
  }
}
«После»: контракт соблюдён для всех видов сообщений
test/lsp.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Channel, Message } from "../src/lsp/message.ts";
import { SmsChannel as SmsBefore, TelegramChannel, broadcast } from "../src/lsp/channels-before.ts";
import { SmsChannel } from "../src/lsp/channels-after.ts";

const image: Message = { kind: "image", url: "https://example.com/promo.jpg", caption: "-20% today" };
const text: Message = { kind: "text", text: "See you at 18:00" };

test("before: the narrowed SmsChannel breaks broadcast at runtime", async () => {
  await assert.rejects(broadcast([new TelegramChannel(), new SmsBefore()], "42", image), TypeError);
});

// One contract test for every implementation: this is how LSP is checked.
const channels: [string, Channel][] = [
  ["telegram", new TelegramChannel()],
  ["sms", new SmsChannel()],
];
for (const [name, channel] of channels) {
  for (const message of [text, image]) {
    test(`contract: ${name} accepts ${message.kind} messages`, async () => {
      const id = await channel.send("42", message);
      assert.equal(typeof id, "string");
      assert.ok(id.length > 0);
    });
  }
}

test("after: SMS sends the caption and a link instead of a picture", async () => {
  assert.equal(await new SmsChannel().send("42", image), "sms:42:-20% today https://example.com/promo.jpg");
});
Контрактный тест: одни и те же проверки для каждой реализации. Новый канал добавляется в массив и сразу проверяется

I — разделение интерфейсов

SDK провайдера рассылок обычно умеет всё: SMS, email, push, статусы доставки, баланс. Если сервис кодов подтверждения зависит от всего SDK, он «знает» о пяти методах, хотя вызывает один. Мартин формулирует принцип так: держите интерфейсы маленькими, чтобы их пользователи не зависели от того, что им не нужно.

src/isp/provider.ts
// The full surface of a messaging provider, the way its SDK exposes it.
export interface MessagingProvider {
  sendSms(phone: string, text: string): Promise<void>;
  sendEmail(to: string, subject: string, html: string): Promise<void>;
  sendPush(deviceToken: string, title: string, body: string): Promise<void>;
  deliveryStatus(messageId: string): Promise<"pending" | "delivered" | "failed">;
  balance(): Promise<number>;
}
Полная поверхность провайдера — как её отдаёт SDK
src/isp/otp-before.ts
import type { MessagingProvider } from "./provider.ts";

// Needs one method, depends on all five.
export class OtpService {
  readonly #provider: MessagingProvider;

  constructor(provider: MessagingProvider) {
    this.#provider = provider;
  }

  async sendCode(phone: string, code: string): Promise<void> {
    await this.#provider.sendSms(phone, `Your code: ${code}`);
  }
}
«До»: OtpService зависит от пяти методов, использует один

Цена видна в тесте: подделка обязана реализовать все пять методов. Хуже того, любое изменение в неиспользуемых методах — новый параметр у sendPush — заставляет перепроверять и OtpService. Мартин Фаулер называет маленький интерфейс, описанный со стороны потребителя, ролевым (role interface) и отмечает, что именно такие интерфейсы следуют этому принципу.

src/isp/otp-after.ts
// A role interface: exactly what this client needs, owned by the client.
export interface SmsSender {
  sendSms(phone: string, text: string): Promise<void>;
}

export class OtpService {
  readonly #sms: SmsSender;

  constructor(sms: SmsSender) {
    this.#sms = sms;
  }

  async sendCode(phone: string, code: string): Promise<void> {
    await this.#sms.sendSms(phone, `Your code: ${code}`);
  }
}
«После»: интерфейс объявлен рядом с клиентом и содержит ровно то, что ему нужно
test/isp.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { MessagingProvider } from "../src/isp/provider.ts";
import { OtpService as OtpBefore } from "../src/isp/otp-before.ts";
import { OtpService, type SmsSender } from "../src/isp/otp-after.ts";

test("before: the fake has to implement all five methods", async () => {
  const sent: string[] = [];
  const fake: MessagingProvider = {
    sendSms: async (phone, text) => {
      sent.push(`${phone} ${text}`);
    },
    sendEmail: async () => assert.fail("not used"),
    sendPush: async () => assert.fail("not used"),
    deliveryStatus: async () => assert.fail("not used"),
    balance: async () => assert.fail("not used"),
  };
  await new OtpBefore(fake).sendCode("+77010000000", "4821");
  assert.deepEqual(sent, ["+77010000000 Your code: 4821"]);
});

test("after: the fake is one method", async () => {
  const sent: string[] = [];
  const fake: SmsSender = {
    sendSms: async (phone, text) => {
      sent.push(`${phone} ${text}`);
    },
  };
  await new OtpService(fake).sendCode("+77010000000", "4821");
  assert.deepEqual(sent, ["+77010000000 Your code: 4821"]);
});

test("after: the full provider still fits SmsSender, no implements needed", async () => {
  const sent: string[] = [];
  const provider: MessagingProvider = {
    sendSms: async (phone) => {
      sent.push(phone);
    },
    sendEmail: async () => {},
    sendPush: async () => {},
    deliveryStatus: async () => "delivered",
    balance: async () => 100,
  };
  await new OtpService(provider).sendCode("+77010000000", "4821");
  assert.deepEqual(sent, ["+77010000000"]);
});
Подделка — один метод. Полный провайдер подходит без implements: TypeScript сравнивает типы структурно

D — инверсия зависимостей

Высокоуровневая политика — правило «нельзя записаться дважды на один слот» — не должна зависеть от низкоуровневой детали вроде HTTP-запроса в Telegram. В версии «до» сервис записи сам создаёт клиент Telegram и сам читает токен из окружения.

src/dip/booking-before.ts
import type { Booking } from "../booking.ts";
import { TelegramClient } from "../srp/reminder-after.ts";

// High-level policy creates and depends on a low-level detail.
export class BookingService {
  readonly #bookings = new Map<string, Booking>();
  readonly #telegram = new TelegramClient(process.env.TELEGRAM_TOKEN ?? "");

  async book(booking: Booking): Promise<void> {
    if (this.#bookings.has(booking.id)) throw new Error(`Already booked: ${booking.id}`);
    this.#bookings.set(booking.id, booking);
    await this.#telegram.sendMessage(booking.chatId, `Booked: ${booking.service}`);
  }
}
«До»: правило записи нельзя проверить, не подменяя сеть

Инверсия состоит из двух шагов. Первый: модуль записи объявляет абстракцию в своих терминах — «запись подтверждена», а не «отправь сообщение в Telegram». Шухерт формулирует это как требование выражать низкоуровневые зависимости в абстракциях предметной области. Второй: деталь (Telegram) реализует эту абстракцию, то есть теперь она зависит от модуля записи, а не наоборот. Связывание происходит в одном месте — корне композиции (composition root).

src/dip/booking-service.ts
import type { Booking } from "../booking.ts";

// The abstraction belongs to the booking module and speaks its language:
// "a booking was confirmed", not "send a Telegram message".
export interface BookingNotifier {
  bookingConfirmed(booking: Booking): Promise<void>;
}

export class BookingService {
  readonly #bookings = new Map<string, Booking>();
  readonly #notifier: BookingNotifier;

  constructor(notifier: BookingNotifier) {
    this.#notifier = notifier;
  }

  async book(booking: Booking): Promise<void> {
    if (this.#bookings.has(booking.id)) throw new Error(`Already booked: ${booking.id}`);
    this.#bookings.set(booking.id, booking);
    await this.#notifier.bookingConfirmed(booking);
  }
}
Абстракция принадлежит модулю записи
src/dip/telegram-notifier.ts
import type { Booking } from "../booking.ts";
import type { BookingNotifier } from "./booking-service.ts";
import type { TelegramClient } from "../srp/reminder-after.ts";

// The detail depends on the abstraction, not the other way round.
export class TelegramBookingNotifier implements BookingNotifier {
  readonly #telegram: TelegramClient;

  constructor(telegram: TelegramClient) {
    this.#telegram = telegram;
  }

  bookingConfirmed(booking: Booking): Promise<void> {
    return this.#telegram.sendMessage(booking.chatId, `Booked: ${booking.service}`);
  }
}
Деталь зависит от абстракции
src/dip/composition-root.ts
import { BookingService } from "./booking-service.ts";
import { TelegramBookingNotifier } from "./telegram-notifier.ts";
import { TelegramClient } from "../srp/reminder-after.ts";

// The only place that knows which implementations are used.
export function createBookingService(token: string, fetchFn?: typeof fetch): BookingService {
  return new BookingService(new TelegramBookingNotifier(new TelegramClient(token, fetchFn)));
}
Единственное место, где выбирается реализация
test/dip.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Booking } from "../src/booking.ts";
import { BookingService as BookingBefore } from "../src/dip/booking-before.ts";
import { BookingService, type BookingNotifier } from "../src/dip/booking-service.ts";
import { createBookingService } from "../src/dip/composition-root.ts";

const booking: Booking = {
  id: "b-1",
  clientName: "Aida",
  service: "Haircut",
  startsAt: new Date("2026-10-12T13:00:00Z"),
  chatId: "42",
};

test("before: without a network fake the rule cannot be tested", async (t) => {
  t.mock.method(globalThis, "fetch", async () => new Response("{}", { status: 401 }));
  await assert.rejects(new BookingBefore().book(booking), /Telegram responded 401/);
});

test("after: the rule is tested with an in-memory notifier", async () => {
  const confirmed: string[] = [];
  const notifier: BookingNotifier = {
    bookingConfirmed: async (b) => {
      confirmed.push(b.id);
    },
  };
  const service = new BookingService(notifier);

  await service.book(booking);
  await assert.rejects(service.book(booking), /Already booked: b-1/);
  assert.deepEqual(confirmed, ["b-1"]);
});

test("composition root wires Telegram in", async () => {
  const urls: string[] = [];
  const fakeFetch: typeof fetch = async (input) => {
    urls.push(String(input));
    return new Response("{}");
  };
  await createBookingService("TEST_TOKEN", fakeFetch).book(booking);
  assert.deepEqual(urls, ["https://api.telegram.org/botTEST_TOKEN/sendMessage"]);
});
Правило «дважды нельзя» проверяется без сети; отдельный тест проверяет, что корень композиции подключил Telegram

Внедрение зависимостей (dependency injection, DI) и инверсия зависимостей — не одно и то же. Шухерт разводит их так: DI — о том, как связать объекты, а DIP — о форме абстракции. Если внедрить в сервис «сырой» HTTP-клиент, это DI, но не DIP: сервис по-прежнему зависит от детали, только полученной через конструктор. В Spring и NestJS связыванием занимается контейнер, а принцип выглядит так же. Вот та же схема на Java 21:

BookingDemo.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class BookingDemo {
    record Booking(String id, String chatId, String service) {}

    // Owned by the booking module, named in its terms.
    interface BookingNotifier {
        void bookingConfirmed(Booking booking);
    }

    static final class BookingService {
        private final Map<String, Booking> bookings = new HashMap<>();
        private final BookingNotifier notifier;

        BookingService(BookingNotifier notifier) {
            this.notifier = notifier;
        }

        void book(Booking booking) {
            if (bookings.putIfAbsent(booking.id(), booking) != null) {
                throw new IllegalStateException("Already booked: " + booking.id());
            }
            notifier.bookingConfirmed(booking);
        }
    }

    public static void main(String[] args) {
        List<Booking> confirmed = new ArrayList<>();
        // A one-method interface is satisfied by a lambda or a method reference.
        var service = new BookingService(confirmed::add);

        service.book(new Booking("b-1", "42", "Haircut"));
        try {
            service.book(new Booking("b-1", "42", "Haircut"));
            throw new AssertionError("duplicate must be rejected");
        } catch (IllegalStateException expected) {
            System.out.println(expected.getMessage());
        }
        if (confirmed.size() != 1) throw new AssertionError("expected one confirmation");
        System.out.println("confirmations: " + confirmed.size());
    }
}
Интерфейс с одним методом реализуется лямбдой или ссылкой на метод — подделка для теста в одну строку
terminal
$ javac -encoding UTF-8 -d out BookingDemo.java && java -cp out BookingDemo
Already booked: b-1
confirmations: 1
Реальный вывод в Docker-образе eclipse-temurin:21 (OpenJDK 21.0.12)

Практика: сервис уведомлений после рефакторинга

Сложим всё вместе. После рефакторинга у сервиса уведомлений текст отделён от доставки (S), каналы добавляются без правки Notifier (O), каждый канал проходит общий контрактный тест (L), сервисы зависят от маленьких ролевых интерфейсов (I), а правила записи — от абстракции, которую реализует Telegram (D). Проверка всего проекта — две команды:

terminal
$ npx tsc --noEmit && node --test
✔ before: without a network fake the rule cannot be tested
✔ after: the rule is tested with an in-memory notifier
✔ composition root wires Telegram in
✔ exercise: email goes between Telegram and SMS
✔ before: the fake has to implement all five methods
✔ after: the fake is one method
✔ after: the full provider still fits SmsSender, no implements needed
✔ before: the narrowed SmsChannel breaks broadcast at runtime
✔ contract: telegram accepts text messages
✔ contract: telegram accepts image messages
✔ contract: sms accepts text messages
✔ contract: sms accepts image messages
✔ after: SMS sends the caption and a link instead of a picture
✔ before: the caller picks the channel and the switch knows every channel
✔ after: Telegram first, SMS as a fallback
✔ after: a new channel is added without editing Notifier
✔ after: no suitable channel is an explicit error
✔ before: checking the text means faking global fetch and console
✔ after: the wording is a pure function
✔ after: the scenario runs with a fake fetch and a fake log
✔ after: a Telegram error stops the scenario before logging
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Реальный вывод: 21 тест, обе версии каждого примера и решение первого упражнения. Время выполнения тестов убрано

S

Что изменилось в коде
reminderText, TelegramClient, ReminderService вместо одного метода
Чем подтверждено
Текст проверяется без подмены сети

O

Что изменилось в коде
Интерфейс Channel и список каналов вместо switch
Чем подтверждено
WhatsApp добавлен в тесте без правки Notifier

L

Что изменилось в коде
SMS-канал принимает любое сообщение
Чем подтверждено
Контрактный тест на всех реализациях

I

Что изменилось в коде
SmsSender вместо полного MessagingProvider
Чем подтверждено
Подделка из одного метода

D

Что изменилось в коде
BookingNotifier в модуле записи, Telegram — реализация
Чем подтверждено
Правило записи проверяется без сети

Следующий шаг — паттерны проектирования: Strategy, Adapter, Observer и другие готовые решения, которые реализуют эти принципы. Каналы из раздела об открытости — это уже паттерн «Стратегия».

Как проверено3 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    Все TS-файлы примеров (src/booking.ts, srp/*, ocp/*, lsp/*, isp/*, dip/*, exercise/email-channel.ts) и тесты test/*.test.ts

    временный проект: typescript 7.0.2 + @types/node 24.19.2; npx tsc --noEmit (strict, erasableSyntaxOnly, verbatimModuleSyntax) — 0 ошибок; node --test на Node.js 24.18.0 — 21 тест, 21 pass (обе версии «до/после» каждого принципа и решение упражнения 1). Код в статье вставлен из этих файлов скриптом сборки

  • запущено, работает

    src/lsp/strict-channel.ts — намеренная ошибка (свойство-функция ловит сужение параметра)

    npx tsc -p tsconfig.lsp-demo.json → error TS2322 (вывод в статье — реальный); отдельно показано, что методная запись в channels-before.ts компилируется без ошибок и падает TypeError в тесте

  • запущено, работает

    BookingDemo.java (DIP на Java 21)

    docker run --rm eclipse-temurin:21 (OpenJDK 21.0.12): javac -encoding UTF-8 && java — вывод «Already booked: b-1 / confirmations: 1», встроенные проверки AssertionError не сработали

  • не запускалось

    Решение упражнения 2 (второй ролевой интерфейс)

    решение дано текстом, без кода

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.