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

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.