Книге «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 | Не писать руками — это работа контейнера |
| Prototype | Scope.TRANSIENT, ModuleRef.resolve() | Редко; чаще достаточно локальной переменной |
| Factory Method | useFactory-провайдеры | Да — статические фабричные методы для создания объектов с валидацией |
| Abstract Factory | DI-контейнер NestJS, ModuleRef | Не нужен — конфигурацию собирает useFactory + ConfigService |
| Builder | DocumentBuilder в @nestjs/swagger, query builder'ы TypeORM/Knex | Да — но чаще хватает объектного литерала с именованными полями |
| Adapter | ExpressAdapter, FastifyAdapter | Да — адаптеры к внешним зависимостям |
| Bridge | Readable-потоки Node.js | Почти никогда — «интерфейс + DI» покрывает |
| Composite | CompositePropagator в OpenTelemetry | Да — когда нужно несколько получателей за одним интерфейсом |
| Decorator | Interceptors в NestJS, обёртки потоков | Да — обёртки над репозиториями; сначала проверьте встроенные механизмы |
| Facade | Repository в TypeORM, HttpService, ClientProxy | Да — адаптер-фасад над чужим SDK |
| Flyweight | Интернирование строк, hidden classes в V8 | Почти никогда — идею несут неизменяемые value objects |
| Proxy | Proxy в JS, lazy-relations TypeORM, @Transactional() | Не писать — использовать встроенные механизмы |
| Chain of Responsibility | Middleware в Express/NestJS, guards и filters | Редко — хватает готовых цепочек фреймворка |
| Command | Job'ы BullMQ, @Cron из @nestjs/schedule | Да — разделение «что» и «как» в обработчиках операций |
| Interpreter | Cron-выражения @nestjs/schedule, JSONPath | Не изобретать собственных языков выражений |
| Iterator | Symbol.iterator, for...of, генераторы | Растворился в языке |
| Mediator | EventEmitter2, роутер NestJS | Да — события вместо прямых вызовов между модулями |
| Memento | SAVEPOINT в транзакциях | Почти никогда — откат делает транзакция базы данных |
| Observer | @OnEvent в @nestjs/event-emitter | Да — доменные события, постоянно |
| State | XState для сложных случаев | Да, в облегчённой форме — union-тип статусов + проверки переходов |
| Strategy | PassportStrategy, транспорты winston | Да — вместо разрастающихся switch |
| Template Method | PassportStrategy(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 в работе.