SOLID — пять принципов объектно-ориентированного проектирования, собранных Робертом Мартином: единственная ответственность (S), открытость/закрытость (O), подстановка Лисков (L), разделение интерфейсов (I) и инверсия зависимостей (D). Все пять отвечают на один вопрос: как устроить код, чтобы изменение в одном месте не ломало другие и чтобы части можно было тестировать по отдельности. Это эвристики, а не законы: у каждой абстракции есть цена, и применять принцип стоит там, где изменения действительно происходят.
После материала вы сможете
- распознать нарушение каждого из пяти принципов 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 г.
Содержание
Зачем 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, без сторонних библиотек. Для каждого принципа есть версия «до» и «после», и тесты проверяют обе.
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node --test{
"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"]
}export type Booking = {
id: string;
clientName: string;
service: string;
startsAt: Date;
chatId: string; // Telegram chat of the client
};S — единственная ответственность
Формулировку «у класса должна быть одна причина для изменения» часто понимают как «класс делает одну вещь». Мартин в статье 2014 года уточняет: причины изменений — это люди. В его примере класс Employee считает зарплату (это интересует финансового директора), формирует отчёт по часам (операционного) и сохраняет себя в базу (технического). Три разных заказчика изменений — три ответственности. Посмотрим на сервис напоминаний.
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, сценарий — сервис, который их вызывает. Журнал передаётся снаружи функцией, и сервис больше не знает, куда пишутся строки.
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}`);
}
}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, []);
});O — открытость для расширения, закрытость для изменения
Принцип сформулировал Бертран Мейер в 1988 году. Мартин пересказывает его так: поведение системы должно расширяться без изменения существующего кода — как редактор кода получает новые возможности через плагины, а сам о плагинах не знает. Пример нарушения — функция, которая выбирает канал уведомления через switch.
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>;
}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 });
}
}Правило выбора канала тоже размазано: вызывающий код сам решает, куда отправлять. Вынесем канал в интерфейс с двумя вопросами — «можешь ли ты достучаться до этого клиента?» и «отправь». Notifier перебирает каналы в порядке приоритета и не знает, сколько их и какие они.
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) }),
});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/);
});L — подстановка Лисков
Принцип назван по имени Барбары Лисков. Формулировка Мартина: программа, которая работает через интерфейс, не должна «путаться» из-за конкретной реализации. Любая реализация обязана принимать всё, что принимает контракт, и возвращать то, что он обещает. Мартин подчёркивает, что речь о подтипах, а не о наследовании: принцип действует и для утиной типизации, и для структурных типов TypeScript.
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>;
}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)));
}Почему компилятор молчит, хотя включён strict? Флаг strictFunctionTypes проверяет параметры строго только у функций, записанных синтаксисом функции. Для методов проверка ослаблена — документация TypeScript объясняет это тем, что иначе пришлось бы ломать множество существующих иерархий классов, в том числе в DOM. Если записать тот же контракт свойством-функцией, компилятор нарушение поймает:
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}`,
};$ 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; }'.Но даже свойство-функция защищает только типы. Принцип Лисков шире: реализация не должна усиливать предусловия («только короткий текст»), ослаблять постусловия («иногда молча не отправляю») или бросать исключения, которых контракт не обещает. Этого компилятор не видит, поэтому контракт проверяют тестом, который прогоняется на каждой реализации. Исправленный SMS-канал принимает любое сообщение: картинку он отправить не может, поэтому отправляет подпись и ссылку.
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)}`;
}
}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, он «знает» о пяти методах, хотя вызывает один. Мартин формулирует принцип так: держите интерфейсы маленькими, чтобы их пользователи не зависели от того, что им не нужно.
// 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>;
}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}`);
}
}Цена видна в тесте: подделка обязана реализовать все пять методов. Хуже того, любое изменение в неиспользуемых методах — новый параметр у sendPush — заставляет перепроверять и OtpService. Мартин Фаулер называет маленький интерфейс, описанный со стороны потребителя, ролевым (role interface) и отмечает, что именно такие интерфейсы следуют этому принципу.
// 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}`);
}
}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"]);
});D — инверсия зависимостей
Высокоуровневая политика — правило «нельзя записаться дважды на один слот» — не должна зависеть от низкоуровневой детали вроде HTTP-запроса в Telegram. В версии «до» сервис записи сам создаёт клиент Telegram и сам читает токен из окружения.
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).
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);
}
}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}`);
}
}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)));
}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"]);
});Внедрение зависимостей (dependency injection, DI) и инверсия зависимостей — не одно и то же. Шухерт разводит их так: DI — о том, как связать объекты, а DIP — о форме абстракции. Если внедрить в сервис «сырой» HTTP-клиент, это DI, но не DIP: сервис по-прежнему зависит от детали, только полученной через конструктор. В Spring и NestJS связыванием занимается контейнер, а принцип выглядит так же. Вот та же схема на Java 21:
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());
}
}$ javac -encoding UTF-8 -d out BookingDemo.java && java -cp out BookingDemo
Already booked: b-1
confirmations: 1Практика: сервис уведомлений после рефакторинга
Сложим всё вместе. После рефакторинга у сервиса уведомлений текст отделён от доставки (S), каналы добавляются без правки Notifier (O), каждый канал проходит общий контрактный тест (L), сервисы зависят от маленьких ролевых интерфейсов (I), а правила записи — от абстракции, которую реализует Telegram (D). Проверка всего проекта — две команды:
$ 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 0S
- Что изменилось в коде
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.