Паттерн проектирования — именованное типовое решение повторяющейся задачи: Strategy подменяет алгоритм, Adapter переводит чужой API в ваш интерфейс, Factory создаёт объект по конфигурации, Observer рассылает события подписчикам, Decorator добавляет поведение вокруг вызова, Repository прячет хранилище, Builder собирает сложный объект по шагам. Ценность паттерна — в общем словаре команды и в задаче, которую он решает. Если задачи нет — например, у правила всего одна реализация, — паттерн только добавляет кода.
После материала вы сможете
- применить Strategy, Adapter, Factory, Observer, Decorator, Repository и Builder к задачам оплаты, уведомлений и интеграций на TypeScript
- подключить SDK стороннего провайдера через адаптер так, чтобы его типы, единицы и ошибки не выходили за пределы одного файла
- собрать цепочку декораторов (повтор, кэш, middleware) и объяснить, почему повторять можно только идемпотентные вызовы
- отличить паттерн, который убирает сложность, от паттерна ради паттерна, и заменить лишнюю иерархию функцией
Проверено на
TypeScript 7.0.2 (tsc --strict)Node.js 24 LTS (24.18), node:testJava 21 LTS (eclipse-temurin 21.0.12)
Сверено с документацией: 11 октября 2026 г.
Содержание
Паттерн — общий словарь, а не рецепт
Идея паттернов пришла из архитектуры зданий: Кристофер Александр в книге «A Pattern Language» описал типовые решения для городов и домов как «язык». В программировании её закрепила книга «Design Patterns: Elements of Reusable Object-Oriented Software» (1994) Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса — «банды четырёх», GoF — с каталогом из 23 паттернов. Позже Мартин Фаулер в «Patterns of Enterprise Application Architecture» описал паттерны корпоративных приложений, среди них Repository и Gateway.
Главная польза паттерна — словарь. Фраза «оберни SDK адаптером» заменяет абзац объяснений и сразу говорит, где будет перевод форматов. Паттерн — не библиотека и не код для копирования: в TypeScript многие решения из книги 1994 года выглядят проще, потому что функции — обычные значения. Ниже семь паттернов, которые постоянно встречаются в веб- и мобильной разработке, на задачах заказов, оплаты, уведомлений и интеграций.
Несколько способов посчитать или оплатить
- Паттерн
- Strategy
- Где встречается
- Доставка, скидки, тарифы, способы оплаты
Чужой API нужно привести к своему интерфейсу
- Паттерн
- Adapter
- Где встречается
- Платёжные провайдеры, учётные системы, CRM
Объект создаётся по конфигурации или окружению
- Паттерн
- Factory
- Где встречается
- Канал уведомлений, клиент хранилища
Несколько модулей реагируют на одно событие
- Паттерн
- Observer / Pub-Sub
- Где встречается
- «Заказ оплачен» → кухня, SMS, бонусы
Поведение вокруг вызова без правки самого вызова
- Паттерн
- Decorator / Middleware
- Где встречается
- Повторы, кэш, логи, request id
Данные нужны как коллекция, без SQL в бизнес-логике
- Паттерн
- Repository
- Где встречается
- Сервисы и их тесты
Сложный объект собирается по шагам с проверкой
- Паттерн
- Builder
- Где встречается
- Чеки, запросы к API, тестовые данные
Проект настроен так же, как в уроке о SOLID: TypeScript 7 в строгом режиме проверяет типы, Node.js 24 запускает .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"]
}Strategy: способы доставки и расчёта
Задача: стоимость доставки зависит от способа — самовывоз бесплатно, курьер по тарифу за километр, а от определённой суммы заказа курьер бесплатный. Правила меняет маркетинг, и часто. Если всё это живёт в switch внутри оформления заказа, каждая акция — правка кода оформления. Strategy выносит каждый алгоритм в отдельный взаимозаменяемый объект с общим интерфейсом.
В TypeScript стратегия чаще всего — просто тип функции. Справочник TypeScript описывает такие типы как function type expressions: (cart: Cart) => number — «функция, которая принимает корзину и возвращает число». Стратегии можно комбинировать: freeOver принимает другую стратегию и возвращает новую.
// Amounts are in tenge, integers. Fees are illustrative.
export type Cart = { subtotal: number; distanceKm: number };
// A strategy is just a function type: one way to price delivery.
export type DeliveryPricing = (cart: Cart) => number;
export const pickup: DeliveryPricing = () => 0;
export const courier =
(baseFee: number, perKm: number): DeliveryPricing =>
(cart) =>
baseFee + Math.ceil(cart.distanceKm) * perKm;
// Strategies compose: free delivery over a threshold, otherwise another rule.
export const freeOver =
(threshold: number, otherwise: DeliveryPricing): DeliveryPricing =>
(cart) =>
cart.subtotal >= threshold ? 0 : otherwise(cart);
export type DeliveryMethod = "pickup" | "courier";
// The choice is data, not a switch inside checkout.
export const pricingByMethod: Record<DeliveryMethod, DeliveryPricing> = {
pickup,
courier: freeOver(15_000, courier(700, 150)),
};
export function checkoutTotal(cart: Cart, method: DeliveryMethod): number {
return cart.subtotal + pricingByMethod[method](cart);
}import { test } from "node:test";
import assert from "node:assert/strict";
import { checkoutTotal, courier, freeOver, pickup } from "../src/strategy.ts";
test("each strategy prices delivery on its own", () => {
const cart = { subtotal: 8_000, distanceKm: 3.2 };
assert.equal(pickup(cart), 0);
assert.equal(courier(700, 150)(cart), 700 + 4 * 150);
assert.equal(freeOver(5_000, courier(700, 150))(cart), 0);
});
test("checkout picks the strategy by the delivery method", () => {
assert.equal(checkoutTotal({ subtotal: 8_000, distanceKm: 3.2 }, "courier"), 9_300);
assert.equal(checkoutTotal({ subtotal: 16_000, distanceKm: 3.2 }, "courier"), 16_000);
assert.equal(checkoutTotal({ subtotal: 8_000, distanceKm: 3.2 }, "pickup"), 8_000);
});Класс вместо функции нужен, когда у стратегии несколько методов или своё состояние — как у каналов уведомлений из урока о SOLID, где канал отвечает и на вопрос «можешь ли доставить?», и за саму отправку. В Java то же самое выражается функциональным интерфейсом — интерфейсом с одним абстрактным методом, который реализуется лямбдой:
import java.util.Map;
public class DeliveryPricingDemo {
record Cart(long subtotal, double distanceKm) {}
// A functional interface: any lambda with this shape is a strategy.
@FunctionalInterface
interface DeliveryPricing {
long price(Cart cart);
}
enum DeliveryMethod { PICKUP, COURIER }
static DeliveryPricing courier(long baseFee, long perKm) {
return cart -> baseFee + (long) Math.ceil(cart.distanceKm()) * perKm;
}
static DeliveryPricing freeOver(long threshold, DeliveryPricing otherwise) {
return cart -> cart.subtotal() >= threshold ? 0 : otherwise.price(cart);
}
static final Map<DeliveryMethod, DeliveryPricing> PRICING = Map.of(
DeliveryMethod.PICKUP, cart -> 0,
DeliveryMethod.COURIER, freeOver(15_000, courier(700, 150))
);
static long checkoutTotal(Cart cart, DeliveryMethod method) {
return cart.subtotal() + PRICING.get(method).price(cart);
}
public static void main(String[] args) {
var cart = new Cart(8_000, 3.2);
long courierTotal = checkoutTotal(cart, DeliveryMethod.COURIER);
long pickupTotal = checkoutTotal(cart, DeliveryMethod.PICKUP);
if (courierTotal != 9_300 || pickupTotal != 8_000) throw new AssertionError("wrong totals");
System.out.println("courier: " + courierTotal + ", pickup: " + pickupTotal);
}
}$ javac -encoding UTF-8 -d out DeliveryPricingDemo.java && java -cp out DeliveryPricingDemo
courier: 9300, pickup: 8000Adapter: чужой API в нашем интерфейсе
У каждого платёжного провайдера свой SDK: свои имена полей, суммы в минимальных единицах (тиынах), свои статусы и ошибки. Если эти типы расползутся по оформлению заказа, смена провайдера превратится в переписывание половины кода. Adapter переводит интерфейс чужого класса в тот, который ожидает ваш код. Фаулер описывает близкий паттерн Gateway — объект, инкапсулирующий доступ к внешней системе; на практике шлюз к провайдеру обычно и реализуют адаптером.
// Our interface: what checkout needs, in our terms (amounts in tenge).
export type PaymentLink = { paymentId: string; payUrl: string };
export interface PaymentGateway {
createPayment(orderId: string, amount: number): Promise<PaymentLink>;
}
export class PaymentDeclinedError extends Error {}// The shape of a third-party SDK (a made-up "AcmePay"): its names, its units.
export type AcmeInvoice = {
invoice: { id: string; pay_url: string; state: "created" | "rejected" };
reject_reason?: string;
};
export interface AcmePayClient {
createInvoice(request: {
merchant_order_id: string;
amount_minor: number; // tiyn: 1 tenge = 100 tiyn
currency: "KZT";
}): Promise<AcmeInvoice>;
}import type { AcmePayClient } from "./acme-pay-client.ts";
import { PaymentDeclinedError, type PaymentGateway, type PaymentLink } from "./payments.ts";
// Adapter: our PaymentGateway on top of the AcmePay SDK.
// Units, field names and errors are translated here and nowhere else.
export class AcmePayAdapter implements PaymentGateway {
readonly #client: AcmePayClient;
constructor(client: AcmePayClient) {
this.#client = client;
}
async createPayment(orderId: string, amount: number): Promise<PaymentLink> {
const response = await this.#client.createInvoice({
merchant_order_id: orderId,
amount_minor: Math.round(amount * 100),
currency: "KZT",
});
if (response.invoice.state === "rejected") {
throw new PaymentDeclinedError(response.reject_reason ?? "Payment rejected");
}
return { paymentId: response.invoice.id, payUrl: response.invoice.pay_url };
}
}import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcmePayClient } from "../src/acme-pay-client.ts";
import { AcmePayAdapter } from "../src/acme-pay-adapter.ts";
import { PaymentDeclinedError } from "../src/payments.ts";
test("translates our call into the SDK request and back", async () => {
const requests: unknown[] = [];
const client: AcmePayClient = {
createInvoice: async (request) => {
requests.push(request);
return { invoice: { id: "inv_1", pay_url: "https://pay.example.com/inv_1", state: "created" } };
},
};
const link = await new AcmePayAdapter(client).createPayment("order-17", 9_300);
assert.deepEqual(requests, [{ merchant_order_id: "order-17", amount_minor: 930_000, currency: "KZT" }]);
assert.deepEqual(link, { paymentId: "inv_1", payUrl: "https://pay.example.com/inv_1" });
});
test("a rejected invoice becomes our domain error", async () => {
const client: AcmePayClient = {
createInvoice: async () => ({
invoice: { id: "inv_2", pay_url: "", state: "rejected" },
reject_reason: "limit exceeded",
}),
};
await assert.rejects(new AcmePayAdapter(client).createPayment("order-18", 100), PaymentDeclinedError);
});Второй провайдер — второй адаптер с тем же интерфейсом, а оформление заказа не меняется. Тесты оформления работают с подделкой PaymentGateway и вообще не знают о провайдерах.
Factory: создание объекта по конфигурации
Локально уведомления должны печататься в консоль, на тестовом стенде — уходить в Telegram, в продакшене — по SMS. Если new TelegramNotifier(...) и проверки переменных окружения разбросаны по коду, переключение каналов затрагивает много файлов. Factory собирает в одном месте решение, какой класс создать. В книге GoF это Factory Method — метод подкласса; в TypeScript обычно хватает функции-фабрики.
export interface Notifier {
notify(to: string, text: string): Promise<void>;
}
// Configuration usually comes from the environment and differs per stage.
export type NotifierConfig =
| { channel: "telegram"; token: string }
| { channel: "sms"; endpoint: string; apiKey: string }
| { channel: "console" };
class TelegramNotifier implements Notifier {
readonly #token: string;
constructor(token: string) {
this.#token = token;
}
async notify(chatId: string, text: string): Promise<void> {
const response = await 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}`);
}
}
// The request format depends on the SMS provider; this one is illustrative.
class SmsNotifier implements Notifier {
readonly #endpoint: string;
readonly #apiKey: string;
constructor(endpoint: string, apiKey: string) {
this.#endpoint = endpoint;
this.#apiKey = apiKey;
}
async notify(phone: string, text: string): Promise<void> {
const response = await fetch(this.#endpoint, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${this.#apiKey}` },
body: JSON.stringify({ to: phone, text }),
});
if (!response.ok) throw new Error(`SMS provider responded ${response.status}`);
}
}
class ConsoleNotifier implements Notifier {
async notify(to: string, text: string): Promise<void> {
console.log(`[notify] ${to}: ${text}`);
}
}
// Factory: the only place that knows which class matches which config.
export function createNotifier(config: NotifierConfig): Notifier {
switch (config.channel) {
case "telegram":
return new TelegramNotifier(config.token);
case "sms":
return new SmsNotifier(config.endpoint, config.apiKey);
case "console":
return new ConsoleNotifier();
default: {
const unknown: never = config;
throw new Error(`Unknown notifier config: ${JSON.stringify(unknown)}`);
}
}
}
// Reads process.env-like input and fails fast on missing settings.
export function notifierConfigFromEnv(env: Record<string, string | undefined>): NotifierConfig {
const channel = env.NOTIFY_CHANNEL ?? "console";
if (channel === "console") return { channel };
if (channel === "telegram" && env.TELEGRAM_TOKEN) return { channel, token: env.TELEGRAM_TOKEN };
if (channel === "sms" && env.SMS_ENDPOINT && env.SMS_API_KEY) {
return { channel, endpoint: env.SMS_ENDPOINT, apiKey: env.SMS_API_KEY };
}
throw new Error(`Notifier "${channel}" is not configured`);
}import { test } from "node:test";
import assert from "node:assert/strict";
import { createNotifier, notifierConfigFromEnv } from "../src/notifier-factory.ts";
test("without settings the factory gives a console notifier for local runs", async (t) => {
const log = t.mock.method(console, "log", () => {});
const notifier = createNotifier(notifierConfigFromEnv({}));
await notifier.notify("+77010000000", "Order 17 is ready");
assert.equal(log.mock.calls[0]?.arguments[0], "[notify] +77010000000: Order 17 is ready");
});
test("the telegram config produces a notifier that calls the Bot API", async (t) => {
const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}"));
const notifier = createNotifier(notifierConfigFromEnv({ NOTIFY_CHANNEL: "telegram", TELEGRAM_TOKEN: "TEST_TOKEN" }));
await notifier.notify("42", "Order 17 is ready");
assert.equal(String(fetchMock.mock.calls[0]?.arguments[0]), "https://api.telegram.org/botTEST_TOKEN/sendMessage");
});
test("a missing secret fails at startup, not on the first message", () => {
assert.throws(() => notifierConfigFromEnv({ NOTIFY_CHANNEL: "sms", SMS_API_KEY: "YOUR_API_KEY" }), /"sms" is not configured/);
});
test("the sms config posts to the provider with the key in a header", async (t) => {
const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}"));
const notifier = createNotifier(
notifierConfigFromEnv({ NOTIFY_CHANNEL: "sms", SMS_ENDPOINT: "https://sms.example.com/send", SMS_API_KEY: "YOUR_API_KEY" }),
);
await notifier.notify("+77010000000", "Order 17 is ready");
const [url, init] = fetchMock.mock.calls[0]?.arguments ?? [];
assert.equal(String(url), "https://sms.example.com/send");
assert.deepEqual(init?.headers, { "content-type": "application/json", authorization: "Bearer YOUR_API_KEY" });
});Обратите внимание на notifierConfigFromEnv: фабрика падает при запуске приложения, если секрет не задан. Ошибка конфигурации видна сразу после деплоя, а не через час, когда первый клиент не получит SMS.
Observer и Pub-Sub: события заказа
После оплаты заказа нужно передать его на кухню, отправить клиенту SMS и начислить бонусы. Если оформление вызывает все три модуля напрямую, оно знает обо всех, и аналитика или чек добавляются правкой оформления. Observer даёт механизм подписки: издатель (publisher) объявляет событие, подписчики (subscribers) реагируют, а издатель не знает, кто они. Часто различают Observer, где подписчики подписываются на конкретный объект, и Pub-Sub, где издатель и подписчики общаются через посредника — шину или брокер сообщений. Шина ниже — Pub-Sub внутри одного процесса.
// Event name → payload. One place to see what the order module announces.
export type OrderEvents = {
"order.paid": { orderId: string; amount: number; phone: string };
"order.cancelled": { orderId: string; reason: string };
};type Handler<T> = (payload: T) => void | Promise<void>;
// A typed in-process event bus. Events maps an event name to its payload.
export class EventBus<Events extends Record<string, unknown>> {
readonly #handlers = new Map<keyof Events, Set<Handler<never>>>();
on<K extends keyof Events>(type: K, handler: Handler<Events[K]>): () => void {
const set = this.#handlers.get(type) ?? new Set();
set.add(handler);
this.#handlers.set(type, set);
return () => set.delete(handler); // unsubscribe
}
// Runs every handler, even if some fail, and reports the failures.
async emit<K extends keyof Events>(type: K, payload: Events[K]): Promise<unknown[]> {
const handlers = [...(this.#handlers.get(type) ?? [])] as Handler<Events[K]>[];
const results = await Promise.allSettled(handlers.map(async (handler) => handler(payload)));
return results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
}
}import { test } from "node:test";
import assert from "node:assert/strict";
import { EventBus } from "../src/event-bus.ts";
import type { OrderEvents } from "../src/order-events.ts";
test("checkout publishes one event, subscribers react independently", async () => {
const bus = new EventBus<OrderEvents>();
const log: string[] = [];
bus.on("order.paid", ({ orderId }) => void log.push(`kitchen: start ${orderId}`));
bus.on("order.paid", async ({ phone, amount }) => void log.push(`sms ${phone}: paid ${amount}`));
bus.on("order.paid", ({ amount }) => void log.push(`loyalty: +${Math.floor(amount / 100)} points`));
const failures = await bus.emit("order.paid", { orderId: "17", amount: 9_300, phone: "+77010000000" });
assert.deepEqual(failures, []);
assert.deepEqual(log, ["kitchen: start 17", "sms +77010000000: paid 9300", "loyalty: +93 points"]);
});
test("a failing subscriber does not stop the others", async () => {
const bus = new EventBus<OrderEvents>();
const log: string[] = [];
bus.on("order.paid", async () => {
throw new Error("SMS provider is down");
});
bus.on("order.paid", ({ orderId }) => void log.push(`kitchen: start ${orderId}`));
const failures = await bus.emit("order.paid", { orderId: "18", amount: 500, phone: "+77010000000" });
assert.deepEqual(log, ["kitchen: start 18"]);
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /SMS provider is down/);
});
test("unsubscribe removes the handler", async () => {
const bus = new EventBus<OrderEvents>();
let calls = 0;
const off = bus.on("order.cancelled", () => void calls++);
off();
await bus.emit("order.cancelled", { orderId: "19", reason: "client" });
assert.equal(calls, 0);
});
test("payload types are checked by the compiler", async () => {
const bus = new EventBus<OrderEvents>();
// @ts-expect-error amount must be a number
await bus.emit("order.paid", { orderId: "20", amount: "9300", phone: "+77010000000" });
// @ts-expect-error there is no such event
bus.on("order.shipped", () => {});
});Почему не встроенный EventEmitter? Он есть в Node.js и вызывает слушателей синхронно, в порядке регистрации. Документация Node.js предупреждает, что async-обработчики с ним проблемны: исключение внутри превращается в необработанный отказ промиса, если не включить captureRejections. Наша шина ждёт всех обработчиков через Promise.allSettled и возвращает ошибки вызывающему коду. В браузере ту же роль играет EventTarget.
Decorator и Middleware: поведение вокруг вызова
Складской API учётной системы иногда отвечает по таймауту, а остатки запрашиваются на каждой странице товара. Нужно добавить повторы и короткий кэш, не трогая клиент склада и код страниц. Decorator оборачивает объект другим объектом с тем же интерфейсом и добавляет поведение до или после вызова. Декораторы складываются в цепочку, и вызывающий код не видит разницы.
// Warehouse integration (an accounting system, an ERP): read-only stock lookup.
export interface StockApi {
getStock(sku: string): Promise<number>;
}
// Decorator: same interface, plus retries. Only for idempotent reads.
export function withRetry(api: StockApi, attempts: number): StockApi {
return {
async getStock(sku) {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await api.getStock(sku);
} catch (error) {
lastError = error;
}
}
throw lastError;
},
};
}
// Decorator: same interface, plus a short-lived cache. `now` is injectable for tests.
export function withCache(api: StockApi, ttlMs: number, now: () => number = Date.now): StockApi {
const cache = new Map<string, { value: number; expiresAt: number }>();
return {
async getStock(sku) {
const hit = cache.get(sku);
if (hit && hit.expiresAt > now()) return hit.value;
const value = await api.getStock(sku);
cache.set(sku, { value, expiresAt: now() + ttlMs });
return value;
},
};
}Middleware в Express, Koa и других фреймворках — тот же приём для HTTP-обработчиков. По документации Express, middleware получает запрос, ответ и next, может выполнить код, изменить запрос и ответ, завершить цикл или передать управление дальше — и если не завершает цикл, обязан вызвать next(), иначе запрос «повиснет». Ниже — функциональная версия на стандартных веб-объектах Request и Response, на которых построены, например, route handlers Next.js.
// The same idea for HTTP handlers: a middleware wraps the next handler.
export type Handler = (request: Request) => Promise<Response>;
export type Middleware = (next: Handler) => Handler;
export const withRequestId: Middleware = (next) => async (request) => {
const id = request.headers.get("x-request-id") ?? crypto.randomUUID();
const response = await next(request);
response.headers.set("x-request-id", id);
return response;
};
export const withErrorBoundary: Middleware = (next) => async (request) => {
try {
return await next(request);
} catch {
return Response.json({ error: "internal_error" }, { status: 500 });
}
};
// compose(a, b)(handler) = a(b(handler)): the first middleware is the outermost.
export const compose =
(...middlewares: Middleware[]): Middleware =>
(handler) =>
middlewares.reduceRight((next, middleware) => middleware(next), handler);import { test } from "node:test";
import assert from "node:assert/strict";
import { withCache, withRetry, type StockApi } from "../src/stock.ts";
import { compose, withErrorBoundary, withRequestId, type Handler } from "../src/middleware.ts";
// A flaky warehouse: fails the first `failures` calls, counts every call.
function flakyWarehouse(failures: number) {
let calls = 0;
const api: StockApi = {
async getStock() {
calls++;
if (calls <= failures) throw new Error("timeout");
return 12;
},
};
return { api, calls: () => calls };
}
test("retry hides two timeouts", async () => {
const warehouse = flakyWarehouse(2);
assert.equal(await withRetry(warehouse.api, 3).getStock("latte-cup"), 12);
assert.equal(warehouse.calls(), 3);
});
test("retry gives up after the last attempt", async () => {
const warehouse = flakyWarehouse(5);
await assert.rejects(withRetry(warehouse.api, 3).getStock("latte-cup"), /timeout/);
});
test("decorators stack: cache over retry over the real client", async () => {
let time = 0;
const warehouse = flakyWarehouse(1);
const stock = withCache(withRetry(warehouse.api, 3), 60_000, () => time);
assert.equal(await stock.getStock("latte-cup"), 12); // 1 failure + 1 success
assert.equal(await stock.getStock("latte-cup"), 12); // from cache
assert.equal(warehouse.calls(), 2);
time = 61_000; // cache expired
await stock.getStock("latte-cup");
assert.equal(warehouse.calls(), 3);
});
test("middleware: request id and error boundary around a handler", async () => {
const failing: Handler = async () => {
throw new Error("db is down");
};
const handler = compose(withRequestId, withErrorBoundary)(failing);
const response = await handler(new Request("https://shop.example.com/api/orders", { headers: { "x-request-id": "req-1" } }));
assert.equal(response.status, 500);
assert.equal(response.headers.get("x-request-id"), "req-1");
assert.deepEqual(await response.json(), { error: "internal_error" });
});Repository и Builder
Repository, по определению Фаулера, служит посредником между предметной областью и слоем отображения данных и даёт интерфейс, похожий на коллекцию. Сервис говорит «найди заказ» и «сохрани заказ» и не знает, лежат данные в PostgreSQL, во внешнем API или в памяти. Подробно — с транзакциями, PostgreSQL и тестами — паттерн разобран в уроке о слоях backend-приложения; здесь только суть.
export type Order = { id: string; status: "new" | "paid" | "cancelled"; total: number };
// Repository: a collection-like interface in domain terms. The service does
// not know whether orders live in PostgreSQL, an API or memory.
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export class InMemoryOrderRepository implements OrderRepository {
readonly #orders = new Map<string, Order>();
async findById(id: string): Promise<Order | null> {
const order = this.#orders.get(id);
return order ? { ...order } : null; // a copy: callers cannot mutate storage
}
async save(order: Order): Promise<void> {
this.#orders.set(order.id, { ...order });
}
}Builder собирает сложный объект по шагам и проверяет его один раз, в конце. Пример — чек: позиции добавляются по одной, оплата и email — отдельными шагами, а правило «оплата равна сумме позиций» нельзя проверить, пока не известны все части. Методы возвращают this, поэтому вызовы складываются в цепочку.
export type ReceiptItem = { name: string; quantity: number; price: number };
export type Receipt = {
orderId: string;
items: ReceiptItem[];
total: number;
payment: { method: "card" | "cash"; amount: number };
email?: string;
};
// Builder: assemble a receipt step by step, validate once in build().
export class ReceiptBuilder {
readonly #orderId: string;
readonly #items: ReceiptItem[] = [];
#payment?: Receipt["payment"];
#email?: string;
constructor(orderId: string) {
this.#orderId = orderId;
}
item(name: string, quantity: number, price: number): this {
if (quantity <= 0 || price < 0) throw new Error(`Bad item: ${name}`);
this.#items.push({ name, quantity, price });
return this;
}
paidBy(method: "card" | "cash", amount: number): this {
this.#payment = { method, amount };
return this;
}
sendTo(email: string): this {
this.#email = email;
return this;
}
build(): Receipt {
if (this.#items.length === 0) throw new Error("Receipt has no items");
if (!this.#payment) throw new Error("Receipt has no payment");
const total = this.#items.reduce((sum, i) => sum + i.quantity * i.price, 0);
if (this.#payment.amount !== total) {
throw new Error(`Payment ${this.#payment.amount} does not match total ${total}`);
}
return {
orderId: this.#orderId,
items: [...this.#items],
total,
payment: this.#payment,
...(this.#email ? { email: this.#email } : {}),
};
}
}import { test } from "node:test";
import assert from "node:assert/strict";
import { InMemoryOrderRepository } from "../src/order-repository.ts";
import { ReceiptBuilder } from "../src/receipt-builder.ts";
test("repository returns copies, not live objects", async () => {
const orders = new InMemoryOrderRepository();
await orders.save({ id: "17", status: "new", total: 9_300 });
const order = await orders.findById("17");
assert.ok(order);
order.status = "paid"; // not saved yet
assert.equal((await orders.findById("17"))?.status, "new");
await orders.save(order);
assert.equal((await orders.findById("17"))?.status, "paid");
assert.equal(await orders.findById("404"), null);
});
test("builder assembles a valid receipt", () => {
const receipt = new ReceiptBuilder("17")
.item("Latte 0.4", 2, 1_800)
.item("Croissant", 1, 1_200)
.paidBy("card", 4_800)
.sendTo("client@example.com")
.build();
assert.equal(receipt.total, 4_800);
assert.equal(receipt.items.length, 2);
assert.equal(receipt.email, "client@example.com");
});
test("builder refuses an inconsistent receipt", () => {
const builder = new ReceiptBuilder("18").item("Latte 0.4", 1, 1_800).paidBy("cash", 2_000);
assert.throws(() => builder.build(), /Payment 2000 does not match total 1800/);
assert.throws(() => new ReceiptBuilder("19").paidBy("card", 0).build(), /no items/);
});Антипаттерн: паттерн ради паттерна
Refactoring.Guru, справочник, на который ссылаются многие курсы, приводит три типичные претензии к паттернам. Первая: паттерны иногда — костыли для недостаточно выразительного языка; в языках с функциями первого класса Strategy реализуется обычной лямбдой. Вторая: паттерны внедряют «по учебнику», не адаптируя к проекту. Третья: новичок, только что выучивший паттерны, применяет их везде — «если у тебя есть только молоток, всё выглядит как гвоздь». Вот как это выглядит в коде.
// Before: an interface, a class, a factory interface, a factory and a service
// for one rule that has exactly one implementation.
interface DiscountStrategy {
apply(total: number): number;
}
class TenPercentDiscountStrategy implements DiscountStrategy {
apply(total: number): number {
return Math.round(total * 0.9);
}
}
interface DiscountStrategyFactory {
create(): DiscountStrategy;
}
export class DefaultDiscountStrategyFactory implements DiscountStrategyFactory {
create(): DiscountStrategy {
return new TenPercentDiscountStrategy();
}
}
export class DiscountService {
readonly #factory: DiscountStrategyFactory;
constructor(factory: DiscountStrategyFactory) {
this.#factory = factory;
}
apply(total: number): number {
return this.#factory.create().apply(total);
}
}// After: a function. When a second rule appears, its type becomes the strategy.
export const applyDiscount = (total: number): number => Math.round(total * 0.9);import { test } from "node:test";
import assert from "node:assert/strict";
import { DefaultDiscountStrategyFactory, DiscountService } from "../src/overengineered.ts";
import { applyDiscount } from "../src/simple.ts";
test("five constructs and one function give the same result", () => {
const service = new DiscountService(new DefaultDiscountStrategyFactory());
for (const total of [0, 1_800, 4_805, 9_300]) {
assert.equal(service.apply(total), applyDiscount(total));
}
});- Второй вариант уже есть или изменение по этой оси уже случалось
- Паттерн убирает дублирование или растущий
switch, а не добавляет файлы - Тест после внедрения стал проще, а не сложнее
- Новый разработчик понимает код по названию паттерна без пересказа
- Язык или фреймворк ещё не решает эту задачу встроенным средством
Последний пункт важен: часть паттернов стала частью языка и платформ. Iterator встроен в JavaScript через протоколы итерации, for...of и генераторы. Observer — это EventTarget в браузере и EventEmitter в Node.js. Strategy и Command — обычные функции. Decorator для HTTP — middleware в любом серверном фреймворке. Прежде чем писать паттерн вручную, проверьте, нет ли его уже под рукой. Проверка всего проекта урока:
$ npx tsc --noEmit && node --test
✔ translates our call into the SDK request and back
✔ a rejected invoice becomes our domain error
✔ retry hides two timeouts
✔ retry gives up after the last attempt
✔ decorators stack: cache over retry over the real client
✔ middleware: request id and error boundary around a handler
✔ exercise 1: express has no free threshold
✔ exercise 3: a hanging warehouse call is cut off and retried
✔ without settings the factory gives a console notifier for local runs
✔ the telegram config produces a notifier that calls the Bot API
✔ a missing secret fails at startup, not on the first message
✔ the sms config posts to the provider with the key in a header
✔ checkout publishes one event, subscribers react independently
✔ a failing subscriber does not stop the others
✔ unsubscribe removes the handler
✔ payload types are checked by the compiler
✔ five constructs and one function give the same result
✔ repository returns copies, not live objects
✔ builder assembles a valid receipt
✔ builder refuses an inconsistent receipt
✔ each strategy prices delivery on its own
✔ checkout picks the strategy by the delivery method
ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0Как проверено2 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
Все TS-файлы примеров (strategy, payments + acme-pay-client + acme-pay-adapter, notifier-factory, event-bus + order-events, stock, middleware, order-repository, receipt-builder, overengineered/simple, exercise/express, exercise/with-timeout) и тесты test/*.test.ts
временный проект: typescript 7.0.2 + @types/node 24.19.2; npx tsc --noEmit (strict) — 0 ошибок; node --test на Node.js 24.18.0 — 22 теста, 22 pass. @ts-expect-error в observer.test.ts проверены: без директив tsc выдаёт TS2322 и TS2345. Пропуск строки express в Record даёт TS2741 (подсказка упражнения 1). Код в статье вставлен из этих файлов скриптом сборки
- запущено, работает
DeliveryPricingDemo.java (Strategy на Java 21)
docker run --rm eclipse-temurin:21 (OpenJDK 21.0.12): javac -encoding UTF-8 && java — вывод «courier: 9300, pickup: 8000», встроенная проверка AssertionError не сработала
- не запускалось
Решение упражнения 2 (адаптер BetaPay)
решение дано текстом, без кода
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.