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

Паттерны проектирования, которые реально нужны

Коротко

Паттерн проектирования — именованное типовое решение повторяющейся задачи: 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 г.

Содержание
  1. 01Паттерн — общий словарь, а не рецепт
  2. 02Strategy: способы доставки и расчёта
  3. 03Adapter: чужой API в нашем интерфейсе
  4. 04Factory: создание объекта по конфигурации
  5. 05Observer и Pub-Sub: события заказа
  6. 06Decorator и Middleware: поведение вокруг вызова
  7. 07Repository и Builder
  8. 08Антипаттерн: паттерн ради паттерна

Паттерн — общий словарь, а не рецепт

Идея паттернов пришла из архитектуры зданий: Кристофер Александр в книге «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 без сборки.

bash
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node --test
Подготовка проекта
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noImplicitOverride": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Тот же строгий tsconfig, что в уроке о SOLID

Strategy: способы доставки и расчёта

Задача: стоимость доставки зависит от способа — самовывоз бесплатно, курьер по тарифу за километр, а от определённой суммы заказа курьер бесплатный. Правила меняет маркетинг, и часто. Если всё это живёт в switch внутри оформления заказа, каждая акция — правка кода оформления. Strategy выносит каждый алгоритм в отдельный взаимозаменяемый объект с общим интерфейсом.

В TypeScript стратегия чаще всего — просто тип функции. Справочник TypeScript описывает такие типы как function type expressions: (cart: Cart) => number — «функция, которая принимает корзину и возвращает число». Стратегии можно комбинировать: freeOver принимает другую стратегию и возвращает новую.

src/strategy.ts
// 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);
}
Стратегии доставки. Суммы в тенге, тарифы условные. Выбор стратегии — данные (Record), а не ветвление
test/strategy.test.ts
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 то же самое выражается функциональным интерфейсом — интерфейсом с одним абстрактным методом, который реализуется лямбдой:

DeliveryPricingDemo.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);
    }
}
Strategy на Java 21: функциональный интерфейс, лямбды и таблица выбора через Map.of
terminal
$ javac -encoding UTF-8 -d out DeliveryPricingDemo.java && java -cp out DeliveryPricingDemo
courier: 9300, pickup: 8000
Реальный вывод в Docker-образе eclipse-temurin:21

Adapter: чужой API в нашем интерфейсе

У каждого платёжного провайдера свой SDK: свои имена полей, суммы в минимальных единицах (тиынах), свои статусы и ошибки. Если эти типы расползутся по оформлению заказа, смена провайдера превратится в переписывание половины кода. Adapter переводит интерфейс чужого класса в тот, который ожидает ваш код. Фаулер описывает близкий паттерн Gateway — объект, инкапсулирующий доступ к внешней системе; на практике шлюз к провайдеру обычно и реализуют адаптером.

src/payments.ts
// 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 {}
Наш интерфейс: что нужно оформлению заказа и в наших единицах
src/acme-pay-client.ts
// 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>;
}
Форма SDK вымышленного провайдера AcmePay — так выглядит любой сторонний клиент
src/acme-pay-adapter.ts
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 };
  }
}
Адаптер: перевод единиц, имён полей и ошибок собран в одном файле
test/adapter.test.ts
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);
});
Тест адаптера с подделкой SDK: проверяется перевод в обе стороны и превращение отказа в доменную ошибку

Второй провайдер — второй адаптер с тем же интерфейсом, а оформление заказа не меняется. Тесты оформления работают с подделкой PaymentGateway и вообще не знают о провайдерах.

Factory: создание объекта по конфигурации

Локально уведомления должны печататься в консоль, на тестовом стенде — уходить в Telegram, в продакшене — по SMS. Если new TelegramNotifier(...) и проверки переменных окружения разбросаны по коду, переключение каналов затрагивает много файлов. Factory собирает в одном месте решение, какой класс создать. В книге GoF это Factory Method — метод подкласса; в TypeScript обычно хватает функции-фабрики.

src/notifier-factory.ts
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`);
}
Фабрика уведомлений. Конфигурация — размеченное объединение, проверка полноты через never не даст забыть новый канал
test/factory.test.ts
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" });
});
Тесты: консоль по умолчанию, Telegram и SMS по конфигурации, отсутствующий ключ — ошибка при старте, а не на первом сообщении. Сеть подменена

Обратите внимание на notifierConfigFromEnv: фабрика падает при запуске приложения, если секрет не задан. Ошибка конфигурации видна сразу после деплоя, а не через час, когда первый клиент не получит SMS.

Observer и Pub-Sub: события заказа

После оплаты заказа нужно передать его на кухню, отправить клиенту SMS и начислить бонусы. Если оформление вызывает все три модуля напрямую, оно знает обо всех, и аналитика или чек добавляются правкой оформления. Observer даёт механизм подписки: издатель (publisher) объявляет событие, подписчики (subscribers) реагируют, а издатель не знает, кто они. Часто различают Observer, где подписчики подписываются на конкретный объект, и Pub-Sub, где издатель и подписчики общаются через посредника — шину или брокер сообщений. Шина ниже — Pub-Sub внутри одного процесса.

src/order-events.ts
// 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 };
};
Каталог событий модуля заказов: имя события → данные
src/event-bus.ts
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] : []));
  }
}
Типизированная шина событий: TypeScript не даст подписаться на несуществующее событие или передать данные не той формы
test/observer.test.ts
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", () => {});
});
Тесты: подписчики работают независимо, сбой одного не мешает остальным, отписка работает, ошибки типов ловит компилятор (@ts-expect-error)

Почему не встроенный EventEmitter? Он есть в Node.js и вызывает слушателей синхронно, в порядке регистрации. Документация Node.js предупреждает, что async-обработчики с ним проблемны: исключение внутри превращается в необработанный отказ промиса, если не включить captureRejections. Наша шина ждёт всех обработчиков через Promise.allSettled и возвращает ошибки вызывающему коду. В браузере ту же роль играет EventTarget.

Decorator и Middleware: поведение вокруг вызова

Складской API учётной системы иногда отвечает по таймауту, а остатки запрашиваются на каждой странице товара. Нужно добавить повторы и короткий кэш, не трогая клиент склада и код страниц. Decorator оборачивает объект другим объектом с тем же интерфейсом и добавляет поведение до или после вызова. Декораторы складываются в цепочку, и вызывающий код не видит разницы.

src/stock.ts
// 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;
    },
  };
}
Декораторы повтора и кэша для складского API. Время передаётся параметром, чтобы тест не ждал минуту

Middleware в Express, Koa и других фреймворках — тот же приём для HTTP-обработчиков. По документации Express, middleware получает запрос, ответ и next, может выполнить код, изменить запрос и ответ, завершить цикл или передать управление дальше — и если не завершает цикл, обязан вызвать next(), иначе запрос «повиснет». Ниже — функциональная версия на стандартных веб-объектах Request и Response, на которых построены, например, route handlers Next.js.

src/middleware.ts
// 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);
Middleware как декоратор функции-обработчика. Заголовки можно менять у ответа, созданного через Response или Response.json; у ответа из fetch() они неизменяемы (MDN, Headers)
test/decorator.test.ts
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" });
});
Тесты: повтор скрывает два таймаута, кэш поверх повтора экономит вызовы, middleware превращает исключение в ответ 500 с request id

Repository и Builder

Repository, по определению Фаулера, служит посредником между предметной областью и слоем отображения данных и даёт интерфейс, похожий на коллекцию. Сервис говорит «найди заказ» и «сохрани заказ» и не знает, лежат данные в PostgreSQL, во внешнем API или в памяти. Подробно — с транзакциями, PostgreSQL и тестами — паттерн разобран в уроке о слоях backend-приложения; здесь только суть.

src/order-repository.ts
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, поэтому вызовы складываются в цепочку.

src/receipt-builder.ts
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 } : {}),
    };
  }
}
Builder чека: шаги по одному, проверка целостности в build()
test/repository-builder.test.ts
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 реализуется обычной лямбдой. Вторая: паттерны внедряют «по учебнику», не адаптируя к проекту. Третья: новичок, только что выучивший паттерны, применяет их везде — «если у тебя есть только молоток, всё выглядит как гвоздь». Вот как это выглядит в коде.

src/overengineered.ts
// 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);
  }
}
«До»: интерфейс, класс, интерфейс фабрики, фабрика и сервис ради одного правила с одной реализацией
src/simple.ts
// After: a function. When a second rule appears, its type becomes the strategy.
export const applyDiscount = (total: number): number => Math.round(total * 0.9);
«После»: функция. Когда появится второе правило, её тип станет стратегией
test/overengineered.test.ts
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 в любом серверном фреймворке. Прежде чем писать паттерн вручную, проверьте, нет ли его уже под рукой. Проверка всего проекта урока:

terminal
$ 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
Реальный вывод: 22 теста, включая решения упражнений 1 и 3. Время выполнения тестов убрано
Как проверено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.

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

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

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

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

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

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