Hexagonal Architecture (её ещё называют ports and adapters) — это способ организовать код так, чтобы бизнес-логика не зависела от фреймворка, базы данных и внешних сервисов. Но она не бесплатна: появляются дополнительные слои, mapper'ы и настройки. Разберём, когда это оправдано, а когда лучше обойтись проще.
Что такое Hexagonal Architecture
Идея простая: разделить код на три зоны.
- Core (ядро) — только бизнес-логика. Никаких импортов из NestJS, TypeORM или axios. Чистые TypeScript-классы.
- Adapters/in — как запросы попадают в систему: HTTP-контроллеры, Kafka consumer'ы, планировщики.
- Adapters/out — как система обращается наружу: запись в базу, вызов внешнего API, отправка событий.
Связь между ядром и адаптерами идёт только через порты — интерфейсы, которые объявляются в ядре. Адаптер реализует интерфейс, ядро его вызывает. Ядро никогда не знает, что именно находится за портом.
В Java границы между слоями держат отдельные gradle-модули — компилятор не даст импортировать из чужого модуля. В Node такого нет, поэтому в NestJS-проектах роль границ выполняет dependency-cruiser: инструмент проверяет графы импортов и не пропускает сборку в CI, если кто-то нарушил правила.
Почему NestJS-декораторы нельзя в ядре
Когда вы пишете @Injectable() или @Inject() на классе в core/, этот класс становится зависимым от NestJS. Теперь в тесте его нельзя создать просто через new Order() — нужно поднимать TestingModule.
Поэтому хэндлеры и доменные сервисы в core/ — plain TypeScript классы, без декораторов. Подключение к DI-контейнеру NestJS делается через useFactory-провайдеры в app/ — один раз, в точке сборки приложения.
Признаки «пора»
Это сигналы, при которых Hexagonal начинает давать реальный выигрыш.
1. Сервис работает с несколькими внешними системами. Например: PostgreSQL + платёжный шлюз + Kafka + SMS. У каждой системы своя схема данных, свои ошибки, свои таймауты. Без чётких границ всё это собирается в одном хэндлере и становится сложно тестируемым.
// core/order/port/out/payment-port.ts
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. В хэндлере накапливается бизнес-логика. Если видите if/else с бизнес-правилами прямо в хэндлере — это сигнал, что логику пора переместить в агрегат. Агрегат в core/ — plain class без NestJS:
// core/order/aggregate/order.aggregate.ts
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. Несколько типов входа. REST для пользователей + REST для администраторов + Kafka consumer. У каждого входа своя модель безопасности и свои Guard'ы. Без разделения они начинают смешиваться, и admin-Guard случайно накрывает user-эндпоинты.
4. Тесты вынуждены поднимать NestJS-контекст. Если unit-тест бизнес-логики создаёт TestingModule с провайдерами — это признак того, что доменные классы зависят от фреймворка. В Hexagonal тест агрегата выглядит так:
// ни createTestingModule, ни @nestjs/*
describe('Order.confirm', () => {
it('бросает EmptyOrderError для пустого заказа', () => {
const order = Order.create(orderId, customerId, []);
expect(() => order.confirm(new Date())).toThrow(EmptyOrderError);
});
});
Запускается за миллисекунды, не требует DI-контейнера.
5. Команда из трёх и более разработчиков. Когда несколько человек редактируют один сервис, архитектурные границы становятся социальной необходимостью. Dependency-cruiser в CI ловит нарушение автоматически — например, когда кто-то добавил @Injectable в core/ и code review это пропустил:
// .dependency-cruiser.cjs
{ name: 'core-pure', severity: 'error',
from: { path: '^src/core' },
to: { path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator))' } }
Если хотя бы три из пяти пунктов выполняются — переход на Hexagonal оправдан.
Признаки «рано»
Один сервис с одной базой. Если всё внешнее — только PostgreSQL через TypeORM, полная hex-раскладка не нужна. Достаточно интерфейса CustomerRepository в core/ и его реализации в adapters/out/persistence/ — это базовая граница, не требующая dependency-cruiser-конфига.
Один-два разработчика, небольшой сервис. Конвенции держатся устно. Добавлять useFactory-wiring для трёх хэндлеров вместо @Injectable() ради «а вдруг кто-то добавит TypeORM в core» — лишняя работа при маленькой команде.
Модель ещё меняется. Если бизнес меняет правила каждые две недели и модель ещё не устоялась, hex-структура тормозит: любое изменение Product требует правки mapper'а в persistence, mapper'а в http-адаптере и DTO в OpenAPI. Сначала — стабильная модель, потом — Hexagonal.
Нет реальной бизнес-логики. Если агрегат — это просто { id, name, email } с геттерами, ради него не стоит строить hex-обёртку. Пустая доменная модель в hexagonal-структуре — самый дорогой вариант CRUD.
Частичный Hexagonal — частая ошибка
Распространённый сценарий: core/ выделен, но адаптеры входа смешивают NestJS-контроллеры с бизнес-логикой. Или adapters/out/persistence/ есть, но вызовы к внешнему API лежат прямо в хэндлере через инжектированный axios.
Проблемы с таким подходом:
- Dependency-cruiser не работает вполсилы. Инструмент ловит нарушения только там, где есть правила. Если часть сервиса вне контракта — границы частично открыты и уверенности нет.
- Сложнее читать. Разработчик постоянно проверяет: «здесь уже Hexagonal или ещё нет?» Такая смесь сложнее для понимания, чем чистый монолит.
- Рефакторинг откладывается. «Доделаем позже» обычно не случается — работа занимает недели и не помещается в спринт.
Правило: либо полный Hexagonal — раскладка core/ + adapters/in/ + adapters/out/ + app/, dependency-cruiser в CI как обязательная проверка, Symbol-токены для всех портов, useFactory-wiring — либо ничего. Промежуточное состояние допустимо только как короткий период миграции с явным дедлайном.
Cargo-cult: одна шаблонная архитектура на всё
Ещё одна типичная ошибка — причесать все сервисы команды под один шаблон независимо от их сложности. Сервис из трёх эндпоинтов в полной hex-раскладке с Symbol-токенами, useFactory для каждого хэндлера и конфигом dependency-cruiser — архитектура ради архитектуры.
Решение принимается на каждый сервис отдельно. В одной команде вполне нормально иметь простой сервис каталога и сложный сервис заказов с биллингом — с разной архитектурой под их реальную сложность.
Коротко
- Hexagonal Architecture разделяет код на ядро (бизнес-логика), адаптеры входа (HTTP, Kafka) и адаптеры выхода (база, внешние API). Связь — только через порты-интерфейсы.
- В NestJS границы держит dependency-cruiser в CI — он заменяет compile-time изоляцию, которой в Node нет.
- Хэндлеры и доменные классы в
core/— plain TypeScript, без@Injectable. Wiring в DI — черезuseFactoryвapp/. - Стоит переходить, если: несколько внешних систем, сложная бизнес-логика с инвариантами, несколько типов входа, тесты требуют Nest-контекст, команда 3+ человек.
- Не стоит переходить, если: одна база, маленькая команда, модель ещё меняется, нет реальной бизнес-логики.
- Частичный Hexagonal опаснее монолита — создаёт иллюзию контроля без реальных гарантий.
- Архитектуру выбирают под сложность конкретного сервиса, а не применяют один шаблон на всю команду.
Что почитать дальше
- Структура модулей — что именно строим, когда решили переходить.
- Core слой — что попадает в
core/и почему@Injectableтам запрещён. - Порты — Symbol-токены, интерфейсы, domain-типы.
- Адаптеры входа — контроллер, маппер, изоляция admin.
- Адаптеры выхода — per-system папки, реализация порта, маппинг.
- Composition root —
AppModule,main.ts,useFactory. - Архитектурные тесты — dependency-cruiser в CI.