Опирается на правила: R-HEX-WHEN-1R-HEX-WHEN-3 и R-HEX-WHEN-X1R-HEX-WHEN-X2 из Hexagonal Style Guide → раздел 1. Когда переходить на Hexagonal.

Важно знать

  • Hexagonal — часть Уровня 3 в UCP. На Уровне 1–2 — overkill, ceremony без выгоды.
  • В Node нет compile-time изоляции модулей, как в Java (Gradle multi-module). Роль ArchUnit выполняет dependency-cruiser в CI — единственный реальный enforcement границ.
  • NestJS-декораторы (@Injectable, @Inject) запрещены в core/ — handlers и domain services — plain classes, wiring через useFactory-провайдеры в app/.
  • Признаки «пора»: 2+ внешних систем, сложные агрегаты с инвариантами, 3+ типа входа (HTTP, Kafka, scheduler), тесты требуют половину Nest-контекста.
  • Признаки «рано»: один сервис с одной PG, 1–2 разработчика, форма домена ещё не устаканилась.
  • Cargo-cult запрещён (R-HEX-WHEN-X1) — решение принимается на сервис, не на команду.
  • Частичный Hexagonal — антипаттерн (R-HEX-WHEN-X2): либо полная раскладка (core/ + adapters/in/* + adapters/out/* + app/) с dependency-cruiser в CI, либо ничего.
  • Symbol-токены для портов обязательны — TypeScript стирает интерфейсы в runtime; без токена Nest не сможет разрешить зависимость.

Hexagonal Architecture в NestJS даёт те же гарантии, что в Java: core/ без фреймворка, изоляция портов от инфраструктуры, автоматическая проверка границ импортов. Механизм другой — вместо gradle-модулей структура папок плюс правила dependency-cruiser, которые выполняются в CI. Эту цену (mapper'ы между слоями, useFactory-wiring вместо авто-сканирования, дополнительный конфиг cruiser'а) стоит платить только когда выгода реально проявляется.

Hexagonal — часть Уровня 3

R-HEX-WHEN-1: Hexagonal в UCP — часть Уровня 3 (DDD + ports/adapters + import-контроль). На Уровнях 1–2 — нет.

Шкала по уровням зрелости (кратко):

  • Уровень 1 — UseCase + Handler + Controller в одном модуле, без DDD-агрегатов. CRUD-сервисы, один NestJS-модуль.
  • Уровень 2 — UseCase + DDD-агрегаты (rich domain), но в одном-двух модулях; domain выделена, hex-структуры core/adapters/app ещё нет.
  • Уровень 3 — DDD + Hexagonal: папочная раскладка с core/, adapters/in/, adapters/out/, app/; Symbol-токены для портов; dependency-cruiser в CI как required check.

На Уровне 1 применять Hexagonal — это добавить конфиг dependency-cruiser, Symbol-токены и useFactory для трёх handler'ов, которые прекрасно работали бы с @Injectable(). Ceremony без выгоды.

Признаки «пора»

R-HEX-WHEN-2: сигналы, при которых Hexagonal начинает давать измеримый выигрыш.

1. Сервис интегрируется с 2+ внешними системами. Типичный Уровень 3 для заказа: PG + платёжный шлюз (Sber) + Kafka + SMS. У каждой внешней системы свой axios-клиент, своя схема DTO, свои таймауты. Без явных adapters/out/sber/, adapters/out/kafka/ всё это собирается в одном handler'е и становится нетестируемым.

// core/order/port/out/payment-port.ts — контракт, который видит только core
export const PAYMENT_PORT = Symbol('PaymentPort');
export interface PaymentPort {
  register(cmd: RegisterPayment): Promise<RegisterResult>;
  cancel(paymentId: PaymentId): Promise<void>;
}
// adapters/out/sber/sber-payment.adapter.ts — знает SberRegisterRequest, axios;
// core не знает ни того, ни другого

2. Доменная логика становится сложной. Агрегат Order проверяет статусный инвариант и эмитирует доменное событие — логика живёт в order.confirm(), не в handler'е. Когда в handler'е начинает накапливаться if/else с бизнес-правилами — это сигнал, что domain пора изолировать.

// core/order/aggregate/order.aggregate.ts — plain class, без @nestjs/*
export class Order {
  confirm(now: Date): OrderConfirmed {
    if (this.status !== OrderStatus.PENDING) throw new OrderAlreadyConfirmedError(this.id);
    if (this.items.length === 0) throw new EmptyOrderError(this.id);
    this.status = OrderStatus.CONFIRMED;
    this.confirmedAt = now;
    return new OrderConfirmed(this.id, now);
  }
}

3. 3+ типа входа. REST для пользователя + REST для администратора + Kafka consumer. Каждый вход — своя security-модель и свой Guard. Без adapters/in/http/, adapters/in/http-admin/, adapters/in/kafka/ они начинают смешиваться в одном модуле, и admin-Guard случайно накрывает user-эндпоинты или наоборот. Dependency-cruiser-правило adapters-independent не даст in-adapter'у импортировать out-adapter.

4. Тесты требуют половину Nest-контекста. Если unit-тест бизнес-логики поднимает TestingModule с провайдерами — это признак того, что domain зависит от NestJS-классов. В Hexagonal core/ — plain classes, тест выглядит так:

// __tests__/order.aggregate.spec.ts — ни createTestingModule, ни @nestjs/*
describe('Order.confirm', () => {
  it('выбрасывает EmptyOrderError для пустого заказа', () => {
    const order = Order.create(orderId, customerId, []);
    expect(() => order.confirm(new Date())).toThrow(EmptyOrderError);
  });
});

Такой тест запускается за миллисекунды и не требует поднятия DI-контейнера.

5. Команда 3+ разработчиков. Когда несколько человек редактируют сервис, архитектурные границы становятся социальной потребностью. Dependency-cruiser ловит «коллега добавил import { Injectable } from '@nestjs/common' в core/», когда code review пропустит:

// .dependency-cruiser.cjs — enforce в CI, не в головах
{ name: 'core-pure', severity: 'error',
  from: { path: '^src/core' },
  to: { path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator))' } }

Если хотя бы 3 из 5 пунктов выполняются — переходим на Hexagonal.

Признаки «рано»

R-HEX-WHEN-3: когда переход даст больше ceremony, чем пользы.

Один сервис с одной PG. Если внешний мир — только PG (через TypeORM), Hexagonal не нужен. Repository-pattern в adapters/out/persistence/ без полной hex-раскладки достаточен. Интерфейс CustomerRepository в core/, реализация в adapters/out/persistence/ — это базовая граница domain ↔ persistence, не требующая dependency-cruiser-конфига.

1–2 разработчика, < 10K LOC. Конвенции держатся устно. Добавлять useFactory-wiring для трёх handler'ов вместо @Injectable() ради «как бы кто-нибудь не добавил typeorm в core» — overhead, когда в команде один человек.

Форма домена ещё не устаканилась. Первые итерации сервиса Products — бизнес меняет правила ценообразования каждые две недели. В этот момент hex-структура с её mapper'ами тормозит: каждое изменение модели Product = правка ProductMapper в persistence, ProductRequestMapper в http-adapter и DTO в OpenAPI. Сначала — устойчивая модель, потом — Hexagonal.

Нет агрегатов с инвариантами. Если Customer — это { id, name, email } и методов кроме геттеров нет, hexagonal ничего не защищает. Anemic domain в hex-обёртке — самый дорогой вариант anemic-domain (plain class с Symbol-токеном, useFactory, маппером — ради CRUD).

Cargo-cult и частичный Hexagonal — запрет

R-HEX-WHEN-X1: Hexagonal как cargo-cult — все сервисы команды причёсаны под один шаблон независимо от сложности. Сервис Customer из трёх эндпоинтов в полной раскладке: core/<bc>/port/out/, adapters/in/http/, adapters/out/persistence/, app/, конфиг dependency-cruiser, useFactory для каждого handler'а — ради чего? Это форма «архитектура ради архитектуры».

Решение принимается на сервис. В одной команде может жить сервис Уровня 1 (Product-каталог) и сервис Уровня 3 (Order с биллингом и платёжным шлюзом).

R-HEX-WHEN-X2: Частичный Hexagonalcore/ есть, но adapters/in/http/ смешивает NestJS-контроллеры с бизнес-логикой. Или adapters/out/persistence/ выделена, но Sber-вызовы лежат прямо в handler'е через инжектированный axios.

Что в этом плохого:

  • Import-контроль не работает. Dependency-cruiser ловит нарушения только там, где есть правила. Если часть сервиса вне контракта — границы частично открыты, уверенности нет.
  • Mental overhead. Читая сервис, разработчик постоянно проверяет: «здесь уже Hexagonal или ещё нет?». Такая мешанина сложнее для понимания, чем чистый монолит.
  • Задолженность накапливается. «Доделать hex позже» обычно не случается — рефакторинг занимает недели и не помещается в спринт.

Правило: либо полный Hexagonal (раскладка + dependency-cruiser в CI + Symbol-токены для всех портов + useFactory-wiring), либо никакого. Промежуточное состояние допустимо только как короткий миграционный период с явным дедлайном.

Куда дальше

  • Структура модулей — что именно строим, когда решили переходить; папки, dependency-cruiser, npm workspaces.
  • Core слой — что попадает в core/, почему @Injectable запрещён и как wiring через useFactory.
  • Порты — Symbol-токены, interface в core/port/out/, domain-типы vs DTO.
  • Адаптеры входа — контроллер через Dispatcher, маппер order-request.mapper.ts, изоляция admin.
  • Адаптеры выхода — per-system папки, реализация порта по токену, маппинг domain ↔ TypeORM-Entity.
  • Composition root — AppModule, main.ts, useFactory для всех портов.
  • Архитектурные тесты — dependency-cruiser в CI как required check, конфиг и примеры правил.