← назад к разделу

Книге «Design Patterns» банды четырёх уже больше тридцати лет, но читать её как готовый каталог рецептов сегодня не нужно — половина паттернов давно растворилась в языке и фреймворках. Зачем тогда учить? Потому что NestJS, TypeORM, любой HTTP-фреймворк разговаривают именно этим словарём. ExpressAdapter, CompositePropagator, PassportStrategy — это не случайные имена, это паттерны GoF в названиях классов. Не зная паттерн, трудно понять, почему класс устроен так, а не иначе.

Ниже — все 23 паттерна по классическим группам. Для каждого: суть в одном предложении, где он уже живёт в готовых инструментах и нужно ли его писать самому.

Порождающие паттерны

Эта группа отвечает на один вопрос: как правильно создавать объекты?

Singleton

Суть: один экземпляр на всё приложение.

Если создавать объект в каждом месте, где он нужен, получается несколько несвязанных копий с разным состоянием. Singleton решает это: объект существует в одном экземпляре, и все обращаются к одному и тому же.

В NestJS синглтон реализуется через контейнер — каждый @Injectable()-провайдер по умолчанию создаётся в одном экземпляре. Это лучше классического GoF-варианта со static getInstance(): контейнерный синглтон внедряется через конструктор и легко подменяется в тестах.

Главное следствие: один экземпляр обслуживает все запросы параллельно, поэтому в singleton-провайдерах не должно быть изменяемого состояния — иначе состояние одного запроса протечёт в другой.

@Injectable()
export class PricingService {
  // один экземпляр на весь контекст приложения
}

it('calculates price', () => {
  const service = new PricingService(); // в тесте создаём через new — просто
});

Prototype

Суть: новый экземпляр при каждом запросе.

Противоположность Singleton: иногда нужен свежий объект для каждой операции, а не общий на всех. В NestJS это Scope.TRANSIENT — каждое разрешение зависимости даёт новый объект.

Ловушка: если внедрить transient-провайдер в singleton-провайдер обычным образом, внедрение произойдёт один раз при создании singleton'а — и «новизна» потеряется. Решение — ModuleRef.resolve():

@Injectable()
export class ReportController {

  constructor(private readonly moduleRef: ModuleRef) {}

  async create(request: ReportRequest): Promise<ReportDto> {
    const builder = await this.moduleRef.resolve(ReportBuilder); // каждый раз новый экземпляр
    return builder.with(request).build();
  }
}

Factory Method

Суть: создание объекта делегируется методу, скрывающему конкретный класс.

Вместо того чтобы везде писать new ConcreteClass(...), вызывающий код работает с интерфейсом, а фабричный метод решает, какую реализацию создать.

В NestJS каждый useFactory-провайдер — это фабричный метод: вызывающий код знает токен и интерфейс, фабрика решает, какую реализацию сконфигурировать:

export const CLOCK = Symbol('Clock');

@Module({
  providers: [
    {
      provide: CLOCK,
      useFactory: (config: ConfigService): Clock =>
        config.get('CLOCK_FIXED')
          ? Clock.fixed(new Date('2026-01-15T10:00:00Z'))
          : Clock.system(),
      inject: [ConfigService],
    },
  ],
  exports: [CLOCK],
})
export class ClockModule {}

В прикладном коде статические фабричные методы — хороший способ создавать объекты с валидацией:

export class Order {

  static create(customerId: CustomerId, lines: OrderLine[]): Order {
    if (lines.length === 0) {
      throw new Error('Заказ должен содержать хотя бы одну позицию');
    }
    return new Order(OrderId.generate(), customerId, lines, 'CREATED');
  }
}

Abstract Factory

Суть: фабрика, которая создаёт семейство связанных объектов.

Если Factory Method создаёт один объект, Abstract Factory создаёт целую группу объектов, которые должны «сочетаться» друг с другом.

В NestJS роль абстрактной фабрики играет DI-контейнер и ModuleRef: по токену выдают готовые объекты, скрывая конкретную реализацию. Какая реализация окажется за токеном NOTIFICATION_PORT — SMTP-адаптер или push — решает конфигурация модуля, не вызывающий код.

В прикладном коде Abstract Factory пишут редко: конфигурацию по окружениям (useFactory + ConfigService) покрывает задачу проще.

Builder

Суть: пошаговая сборка сложного объекта с читаемым кодом.

Конструктор с восемью параметрами — источник ошибок: легко перепутать порядок, непонятно, что за что отвечает. Builder даёт именованные методы для каждого поля.

В экосистеме TypeScript Builder используется в DocumentBuilder из @nestjs/swagger и в query builder'ах TypeORM и Knex. Впрочем, большую часть работы Builder в TypeScript делает объектный литерал с именованными полями:

interface OrderSearchQuery {
  customerId?: CustomerId;
  status?: OrderStatus;
  page: number;
  size: number;
}

const query: OrderSearchQuery = {
  status: 'PAID',
  page: 0,
  size: 20,
};

// классический builder остаётся там, где сборка многошаговая
const openApiConfig = new DocumentBuilder()
  .setTitle('Orders API')
  .setVersion('1.0')
  .build();

Структурные паттерны

Эта группа отвечает на вопрос: как правильно строить связи между объектами?

Adapter

Суть: преобразует один интерфейс в другой, которого ожидает клиент.

Представьте две заглушки разной формы: код ожидает один интерфейс, а внешняя библиотека предлагает другой. Adapter — переходник между ними.

NestJS не знает, какой HTTP-сервер под ним — Express или Fastify. Он работает с единым HttpAdapter (ExpressAdapter, FastifyAdapter), который приводит любой сервер к общему контракту.

В прикладном коде Adapter — основа работы с внешними зависимостями: адаптер переводит доменный интерфейс в язык конкретного SDK:

@Injectable()
export class S3DocumentStorageAdapter implements DocumentStoragePort {

  constructor(private readonly s3: S3Client) {}

  async store(document: Document): Promise<DocumentRef> {
    const key = document.id.value;
    await this.s3.send(new PutObjectCommand({
      Bucket: 'documents',
      Key: key,
      Body: document.content,
    }));
    return new DocumentRef(key);
  }
}

Bridge

Суть: абстракция и реализация развиваются независимо.

Классический пример: Readable-поток в Node.js — одна абстракция «поток данных», независимые реализации под файл, сокет, HTTP-ответ. Код, читающий поток, не меняется при смене источника.

В прикладном коде Bridge в чистом виде почти не встречается — его заменяет связка «интерфейс + внедрение зависимости».

Composite

Суть: группа объектов используется так же, как один объект.

Нужно отправить уведомление одновременно по email и SMS, но вызывающий код не должен знать о деталях. Composite позволяет «завернуть» несколько объектов в один, реализующий тот же интерфейс.

Узнаётся по префиксу Composite: например, CompositePropagator в OpenTelemetry для Node — несколько propagator'ов выглядят как один.

@Injectable()
export class CompositeNotificationAdapter implements NotificationPort {

  constructor(private readonly channels: NotificationPort[]) {}

  async orderCancelled(order: Order): Promise<void> {
    await Promise.all(this.channels.map(channel => channel.orderCancelled(order)));
  }
}

Decorator

Суть: объект оборачивается в обёртку с тем же интерфейсом, добавляющую поведение.

Нужно добавить кеширование к репозиторию, но менять его класс нельзя (или не хочется). Decorator создаёт обёртку с тем же интерфейсом, которая перехватывает вызовы и добавляет нужное поведение.

В NestJS эту роль играют interceptors — обёртка вокруг вызова обработчика с тем же контрактом. Не путать с декораторами TypeScript (@Injectable(), @Get()): синтаксически это метаданные-аннотации, а GoF Decorator — про объект-обёртку.

@Injectable()
export class CachingProductRepository implements ProductRepository {

  constructor(
    private readonly delegate: TypeOrmProductRepository,
    private readonly cache: Cache,
  ) {}

  async findById(id: ProductId): Promise<Product | null> {
    return this.cache.getOrLoad(id.value, () => this.delegate.findById(id));
  }
}

Facade

Суть: простой интерфейс над сложной подсистемой.

Работа с драйвером базы данных напрямую требует: взять соединение из пула, подготовить запрос, выполнить, разобрать строки, вернуть соединение в пул. Repository из TypeORM скрывает всю эту сложность за одним вызовом.

В NestJS фасады — это HttpService, Repository из TypeORM, ClientProxy для микросервисов. В прикладном коде фасад над внешним SDK — нормальная форма адаптера: один метод может скрывать три вызова стороннего API, повторные попытки и преобразование ошибок.

@Injectable()
export class PaymentGatewayAdapter implements PaymentPort {

  constructor(private readonly sdkClient: PaymentSdkClient) {}

  async charge(order: Order, method: PaymentMethod): Promise<PaymentResult> {
    const response = await this.sdkClient.submit({
      amount: order.total.amount,
      currency: order.total.currency.code,
      method: method.token,
    });
    return PaymentResult.of(response.transactionId, response.status);
  }
}

Flyweight

Суть: разделяемые неизменяемые объекты вместо тысяч одинаковых копий.

Если создавать по объекту на каждое слово в тексте, память закончится быстро. Flyweight разделяет объекты с одинаковым содержимым — один экземпляр на значение.

В JavaScript это интернирование строк и разделяемые hidden classes в V8. Во фреймворках — внутренние кеши метаданных декораторов (reflect-metadata).

В прикладном коде Flyweight почти никогда не пишут руками. Его идею несут неизменяемые value objects и константы: Currency.RUB один на всё приложение именно потому, что неизменяем.

Proxy

Суть: объект-заместитель контролирует доступ к реальному объекту.

Прокси перехватывает вызовы и делает что-то до или после: открывает транзакцию, проверяет права, кеширует результат.

В JavaScript заместитель встроен в язык — объект Proxy перехватывает обращения к свойствам и методам. На нём работают lazy-relations в TypeORM и автомоки в тестовых библиотеках. В NestJS ту же роль «сделать что-то до и после вызова» играют guards и interceptors, а декоратор @Transactional() из typeorm-transactional оборачивает метод в транзакцию.

Отсюда и классическая ловушка: interceptors срабатывают только на вызовах через контроллер — прямой вызов метода сервиса из другого сервиса их обходит (в Spring та же механика прокси — подробно в статье про AOP).

@Injectable()
export class TransferService {

  @Transactional() // декоратор оборачивает метод в транзакцию
  async transfer(from: AccountId, to: AccountId, amount: Money): Promise<void> {
    // вызывающий код получает обёртку, а не исходный метод напрямую
  }
}

Поведенческие паттерны

Эта группа отвечает на вопрос: как организовать взаимодействие объектов?

Chain of Responsibility

Суть: запрос идёт по цепочке обработчиков, пока кто-то его не обработает.

HTTP-запрос нужно сначала проверить на аутентификацию, потом на CSRF, потом на авторизацию, и каждый шаг может остановить обработку. Вместо одного огромного метода — цепочка независимых обработчиков.

В Express и NestJS это цепочка middleware — эталон паттерна. Та же механика у guards, interceptors и exception filters NestJS.

@Injectable()
export class TraceIdMiddleware implements NestMiddleware {

  use(req: Request, res: Response, next: NextFunction): void {
    traceContext.run({ traceId: resolveTraceId(req) }, () => {
      next(); // передаём дальше по цепочке
    });
  }
}

Command

Суть: операция упакована в объект — её можно передать, отложить, поставить в очередь.

Обычно вызов метода мгновенный и безымянный. Command превращает операцию в объект с данными — его можно передать в другой поток, отложить, залогировать, отменить.

В Node это job-объекты, уходящие в очередь BullMQ. В NestJS — @Cron-задачи из @nestjs/schedule и процессоры очередей.

В прикладном коде Command — структурная основа разделения «что делать» и «как делать»: один объект несёт данные операции, другой её исполняет.

export interface CancelOrderCommand {
  readonly orderId: OrderId;
  readonly reason: CancelReason;
}

@Injectable()
export class CancelOrderHandler {

  constructor(private readonly orderRepository: OrderRepository) {}

  @Transactional()
  async handle(command: CancelOrderCommand): Promise<void> {
    const order = await this.orderRepository.findByIdOrFail(command.orderId);
    order.cancel(command.reason);
    await this.orderRepository.save(order);
  }
}

Interpreter

Суть: язык с грамматикой и интерпретатор выражений на нём.

В Node это cron-выражения в @Cron('0 0 * * *') из @nestjs/schedule и query-языки вроде JSONPath или JMESPath.

В собственном коде создавать мини-языки не стоит: строковые выражения не проверяются компилятором, ломаются при рефакторинге и усложняют отладку.

Iterator

Суть: последовательный доступ к элементам без раскрытия внутренней структуры.

Паттерн давно растворился в языке: протокол Symbol.iterator, for...of, генераторы, async-итераторы для потоков. TypeORM добавляет skip/take для постраничных выборок.

Реализовывать Iterator руками не приходится. Единственное близкое решение — отдавать из коллекций агрегата неизменяемые представления.

Mediator

Суть: объекты общаются через посредника, не зная друг о друге.

Если один сервис напрямую вызывает другой, они тесно связаны: изменение одного ломает другой. Mediator убирает прямую зависимость — объекты публикуют события, и кто хочет — подписывается.

В NestJS это EventEmitter2 из @nestjs/event-emitter: провайдер публикует событие, слушатели реагируют, и никто ни с кем не связан напрямую. Роутер NestJS — тоже медиатор между транспортом и обработчиками.

@Injectable()
export class CancelOrderHandler {

  constructor(private readonly events: EventEmitter2) {}

  async handle(command: CancelOrderCommand): Promise<void> {
    // ... логика отмены ...
    this.events.emit('order.cancelled', new OrderCancelled(command.orderId));
  }
}

@Injectable()
export class RefundListener {

  @OnEvent('order.cancelled')
  async on(event: OrderCancelled): Promise<void> {
    // другой модуль, не знает об обработчике отмены
  }
}

Memento

Суть: снимок состояния объекта для последующего отката.

Savepoint в транзакциях — чистый Memento: SAVEPOINT фиксирует точку, ROLLBACK TO SAVEPOINT откатывает к ней.

В прикладном коде почти никогда не нужен: откат состояния — работа транзакции базы данных, история изменений — отдельная таблица аудита или event sourcing.

Observer

Суть: подписчики получают уведомления об изменении состояния издателя.

Нужно отправить письмо при отмене заказа. Можно вызвать emailService.send(...) прямо в бизнес-логике — но тогда бизнес-логика знает об email-сервисе. Observer разделяет их: бизнес-логика публикует событие, email-сервис подписывается.

В NestJS это @OnEvent из @nestjs/event-emitter. Важный нюанс: если слушатель с внешними эффектами (письмо, SMS) срабатывает до фиксации транзакции, уведомление может уйти по откаченной транзакции. Правильно — публиковать событие после commit (или через outbox).

@Injectable()
export class OrderNotificationListener {

  @OnEvent('order.cancelled')
  async on(event: OrderCancelled): Promise<void> {
    await this.notificationPort.orderCancelled(event.orderId);
  }
}

State

Суть: поведение объекта меняется при смене его внутреннего состояния.

Заказ в статусе CREATED можно отменить. Заказ в статусе DELIVERED — нет. Логику переходов можно разложить в отдельные классы-состояния, но для большинства задач достаточно проверок в методах самого объекта:

export class Order {

  cancel(reason: CancelReason): void {
    if (this.status !== 'PAID' && this.status !== 'CREATED') {
      throw new IllegalOrderStateError(this.id, this.status, 'cancel');
    }
    this.status = 'CANCELLED';
    this.registerEvent(new OrderCancelled(this.id, reason));
  }
}

Классический State с отдельным классом на каждое состояние оправдан при очень большой машине состояний. Для обычного объекта с несколькими статусами хватает union-типа статусов и проверок переходов.

Strategy

Суть: семейство алгоритмов за общим интерфейсом, выбираемых в зависимости от ситуации.

Скидки для разных категорий покупателей: можно написать switch с условиями, а можно объявить интерфейс DiscountPolicy и создать по реализации на каждую категорию. Добавление новой категории — новый класс, а не правка switch.

В NestJS Strategy повсюду: PassportStrategy (выбирает способ аутентификации), хранилища cache-manager, транспорты логгера winston.

export interface DiscountPolicy {
  supports(order: Order): boolean;
  apply(order: Order): Money;
}

@Injectable()
export class DiscountService {

  constructor(private readonly policies: DiscountPolicy[]) {}

  calculateDiscount(order: Order): Money {
    return this.policies
      .filter(p => p.supports(order))
      .map(p => p.apply(order))
      .reduce((acc, discount) => acc.add(discount), Money.ZERO);
  }
}

Для стратегии с единственным методом в TypeScript естественна и функциональная форма: type DiscountFn = (order: Order) => Money — реализации передаются как обычные функции, без классов.

Template Method

Суть: скелет алгоритма в базовом классе, изменяемые шаги — в наследниках.

Стратегия аутентификации всегда должна одинаково извлечь и проверить учётные данные. Базовый класс PassportStrategy(Strategy) берёт это на себя, оставляя наследнику только содержательную часть:

@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy) {

  async validate(payload: JwtPayload): Promise<AuthUser> {
    // пишем только содержательную часть, скелет берёт на себя базовый класс
    return { userId: payload.sub, roles: payload.roles };
  }
}

Современный вариант — передавать шаг как функцию, а не создавать наследника. dataSource.transaction(...) в TypeORM так и делает: скелет (открыть транзакцию → выполнить → commit/rollback) постоянный, переменный шаг передаётся колбэком.

Visitor

Суть: новая операция над структурой объектов без изменения их классов.

Есть иерархия типов PaymentMethod: Card, Sbp, Cash. Нужно считать комиссию по-разному для каждого типа, не добавляя метод fee() в каждый класс. Visitor добавляет операцию снаружи.

В современных языках Visitor вытеснил pattern matching:

// TypeScript: discriminated union + exhaustive switch вместо Visitor
type PaymentMethod = Card | Sbp | Cash;

function fee(method: PaymentMethod): number {
  switch (method.kind) {
    case 'card': return method.amount * 0.02;
    case 'sbp':  return 0;
    case 'cash': return 50;
  }
}

Все 23 паттерна: быстрая сводка

ПаттернГде встречается в готовых инструментахНужен ли в своём коде
SingletonДефолтный scope провайдера в NestJSНе писать руками — это работа контейнера
PrototypeScope.TRANSIENT, ModuleRef.resolve()Редко; чаще достаточно локальной переменной
Factory MethoduseFactory-провайдерыДа — статические фабричные методы для создания объектов с валидацией
Abstract FactoryDI-контейнер NestJS, ModuleRefНе нужен — конфигурацию собирает useFactory + ConfigService
BuilderDocumentBuilder в @nestjs/swagger, query builder'ы TypeORM/KnexДа — но чаще хватает объектного литерала с именованными полями
AdapterExpressAdapter, FastifyAdapterДа — адаптеры к внешним зависимостям
BridgeReadable-потоки Node.jsПочти никогда — «интерфейс + DI» покрывает
CompositeCompositePropagator в OpenTelemetryДа — когда нужно несколько получателей за одним интерфейсом
DecoratorInterceptors в NestJS, обёртки потоковДа — обёртки над репозиториями; сначала проверьте встроенные механизмы
FacadeRepository в TypeORM, HttpService, ClientProxyДа — адаптер-фасад над чужим SDK
FlyweightИнтернирование строк, hidden classes в V8Почти никогда — идею несут неизменяемые value objects
ProxyProxy в JS, lazy-relations TypeORM, @Transactional()Не писать — использовать встроенные механизмы
Chain of ResponsibilityMiddleware в Express/NestJS, guards и filtersРедко — хватает готовых цепочек фреймворка
CommandJob'ы BullMQ, @Cron из @nestjs/scheduleДа — разделение «что» и «как» в обработчиках операций
InterpreterCron-выражения @nestjs/schedule, JSONPathНе изобретать собственных языков выражений
IteratorSymbol.iterator, for...of, генераторыРастворился в языке
MediatorEventEmitter2, роутер NestJSДа — события вместо прямых вызовов между модулями
MementoSAVEPOINT в транзакцияхПочти никогда — откат делает транзакция базы данных
Observer@OnEvent в @nestjs/event-emitterДа — доменные события, постоянно
StateXState для сложных случаевДа, в облегчённой форме — union-тип статусов + проверки переходов
StrategyPassportStrategy, транспорты winstonДа — вместо разрастающихся switch
Template MethodPassportStrategy(Strategy) + validate()Колбэк-вариант предпочтительнее наследования
VisitorОбход AST в компиляторе TypeScriptВытеснен discriminated unions + exhaustive switch

Из 23 паттернов в прикладном коде регулярно пишут семь–восемь: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method и State. Ещё столько же используют каждый день в готовом виде, не замечая: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. Остальные — словарь для чтения чужого кода.

Коротко

  • Паттерны GoF — не рецепты для копирования, а словарь: именно им разговаривают фреймворки в названиях классов.
  • Proxy в JavaScript встроен в язык: Proxy-объект, lazy-relations TypeORM и @Transactional() работают через него.
  • Strategy — главный инструмент против разрастающихся switch.
  • Observer — стандартный способ отделить побочные действия (письмо, метрика) от бизнес-логики.
  • Adapter — основа работы с внешними зависимостями: домен знает интерфейс, адаптер знает конкретный SDK.
  • Singleton не пишут вручную со static getInstance() — это работа DI-контейнера.
  • Decorator добавляет поведение без наследования — но сначала проверьте, нет ли готового interceptor'а во фреймворке.
  • Из 23 паттернов регулярно пишут руками ~7; остальные живут в готовых инструментах.

Что почитать дальше

  • SOLID на примерах — принципы, ради которых эти паттерны существуют.
  • GRASP на примерах — какому классу отдать ответственность, прежде чем выбирать паттерн.
  • Spring AOP — как устроен Proxy, паттерн номер один Spring.
  • DI/IoC, bean scopes — Singleton и Prototype как scope контейнера.
  • Spring Events — Observer и Mediator в работе.