SOLID is five object-oriented design principles collected by Robert C. Martin: Single Responsibility (S), Open/Closed (O), Liskov Substitution (L), Interface Segregation (I) and Dependency Inversion (D). All five answer one question: how to structure code so that a change in one place does not break others and parts can be tested in isolation. They are heuristics, not laws: every abstraction has a cost, so apply a principle where changes actually happen.
After this you will be able to
- recognise a violation of each SOLID principle by its symptom: different people edit one class, a
switchgrows with every feature, a subclass crashes on valid input, a test fake implements five methods, a service creates its own HTTP client - fix the violation in TypeScript with
stricton and prove the behaviour is preserved with anode:testtest - explain why
tscdoes not catch a narrowed method parameter and how a contract test checks Liskov substitution - decide when a principle is not worth applying and name the cost of an unnecessary abstraction
Know beforehand
Verified with
TypeScript 7.0.2 (tsc --strict)Node.js 24 LTS (24.18), node:testJava 21 LTS (eclipse-temurin 21.0.12)
Checked against docs: October 11, 2026
Contents
Why SOLID, and where people overdo it
Code that works is not necessarily easy to change. A typical story: a notification service started as one "send to Telegram" function, then SMS, email, marketing copy and support logs were added — and every edit started touching its neighbours. SOLID is five principles describing how to distribute responsibilities and dependencies so this does not happen. In his 2020 note "Solid Relevance", Robert C. Martin states them briefly:
S — Single Responsibility
- Martin's wording (2020)
- Gather together the things that change for the same reasons; separate things that change for different reasons
- Symptom of a violation
- Marketing, developers and support all edit one class
O — Open/Closed
- Martin's wording (2020)
- A module should be open for extension but closed for modification
- Symptom of a violation
- Every new variant means editing the same
switch
L — Liskov Substitution
- Martin's wording (2020)
- A program that uses an interface must not be confused by an implementation of that interface
- Symptom of a violation
- A subclass crashes or behaves differently on the same input
I — Interface Segregation
- Martin's wording (2020)
- Keep interfaces small so that users don't depend on things they don't need
- Symptom of a violation
- A test fake implements five methods for the sake of one
D — Dependency Inversion
- Martin's wording (2020)
- Depend in the direction of abstraction; high-level modules should not depend on low-level details
- Symptom of a violation
- Business logic creates its own HTTP client and reads
process.env
All examples share one domain: a salon booking service and its notifications. The code is TypeScript, checked by tsc in strict mode and run by Node.js 24 directly, without a build: since 24.12, Node.js strips types from .ts files as a stable feature. Tests use the built-in node:test, no third-party libraries. Every principle has a "before" and an "after" version, and the tests cover both.
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 — Single Responsibility
"A class should have one reason to change" is often read as "a class does one thing". In his 2014 article Martin clarifies: the reasons for change are people. In his example an Employee class calculates pay (the CFO's concern), reports hours (the COO's) and saves itself to the database (the CTO's). Three different sources of change, three responsibilities. Let us look at the reminder service.
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}`);
}
}- Marketing asks to change the wording — an edit in a class that talks to the network
- Moving from Telegram to another channel — an edit in the class that holds the wording
- Support asks for a different log format — the same class again
- To check the wording, a test has to fake the global
fetchandconsole.log
Split by reasons for change: the wording becomes a pure function, delivery becomes a Telegram client, and the scenario is a service that calls both. The log is passed in as a function, so the service no longer knows where lines go.
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 — Open for extension, closed for modification
Bertrand Meyer formulated the principle in 1988. Martin restates it: you should be able to extend the behaviour of a system without modifying it — the way a code editor gains features through plugins while knowing nothing about them. A typical violation is a function that picks the notification channel with a 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 });
}
}The channel-selection rule is scattered too: the caller decides where to send. Move the channel into an interface with two questions — "can you reach this client?" and "send". Notifier walks the channels in priority order and does not know how many there are or what they are.
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 — Liskov Substitution
The principle is named after Barbara Liskov. Martin's wording: a program that uses an interface must not be confused by an implementation of that interface. Every implementation must accept everything the contract accepts and return what it promises. Martin stresses that this is about subtyping, not inheritance: the principle applies to duck typing and to TypeScript's structural types alike.
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)));
}Why is the compiler silent with strict on? The strictFunctionTypes flag checks parameters strictly only for functions written in function syntax. For methods the check is relaxed — the TypeScript docs explain that otherwise a large number of existing class hierarchies, including some in the DOM, would break. Write the same contract as a function-typed property and the compiler catches the violation:
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; }'.Even a function-typed property only protects types. Liskov substitution is broader: an implementation must not strengthen preconditions ("short text only"), weaken postconditions ("sometimes I silently don't send") or throw exceptions the contract does not promise. The compiler cannot see that, so the contract is checked by a test that runs against every implementation. The fixed SMS channel accepts any message: it cannot send a picture, so it sends the caption and a link.
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 — Interface Segregation
A messaging provider's SDK usually does everything: SMS, email, push, delivery status, balance. If the one-time-code service depends on the whole SDK, it "knows" about five methods while calling one. Martin puts it this way: keep interfaces small so that users don't end up depending on things they don't need.
// 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}`);
}
}The cost shows in the test: the fake must implement all five methods. Worse, any change to the unused methods — a new parameter on sendPush — forces you to recheck OtpService as well. Martin Fowler calls a small interface defined from the consumer's side a role interface and notes that role interfaces follow this principle.
// 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 — Dependency Inversion
High-level policy — the rule "you cannot book the same slot twice" — should not depend on a low-level detail such as an HTTP request to Telegram. In the "before" version, the booking service creates the Telegram client itself and reads the token from the environment.
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}`);
}
}Inversion takes two steps. First, the booking module declares an abstraction in its own terms — "a booking was confirmed", not "send a Telegram message". Schuchert phrases this as capturing low-level dependencies in domain-relevant abstractions. Second, the detail (Telegram) implements that abstraction, so it now depends on the booking module rather than the other way round. Wiring happens in one place — the 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) and dependency inversion are not the same. Schuchert separates them: DI is about wiring, DIP is about the shape of the abstraction. Injecting a raw HTTP client into a service is DI but not DIP: the service still depends on a detail, it just receives it through the constructor. In Spring and NestJS a container does the wiring, and the principle looks the same. Here is the same design in 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: 1Practice: the notification service after refactoring
Let us put it together. After refactoring, the notification service keeps wording apart from delivery (S), adds channels without editing Notifier (O), runs every channel through a shared contract test (L), makes services depend on small role interfaces (I), and makes booking rules depend on an abstraction that Telegram implements (D). Checking the whole project takes two commands:
$ 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
- What changed in the code
reminderText,TelegramClient,ReminderServiceinstead of one method- What proves it
- The wording is tested without faking the network
O
- What changed in the code
- A
Channelinterface and a list of channels instead of aswitch - What proves it
- WhatsApp added in a test without editing
Notifier
L
- What changed in the code
- The SMS channel accepts any message
- What proves it
- A contract test over all implementations
I
- What changed in the code
SmsSenderinstead of the fullMessagingProvider- What proves it
- A one-method fake
D
- What changed in the code
BookingNotifierin the booking module, Telegram as an implementation- What proves it
- The booking rule is tested without the network
Next step: design patterns — Strategy, Adapter, Observer and other ready-made solutions that implement these principles. The channels from the Open/Closed section are already the Strategy pattern.
How this was verified3 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.