Опирается на правила:
R-HEX-WHEN-1…R-HEX-WHEN-3иR-HEX-WHEN-X1…R-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: Частичный Hexagonal — core/ есть, но 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, конфиг и примеры правил.