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

В Java Hexagonal-архитектуру охраняет компилятор: если core/ случайно сослался на Spring, сборка упадёт. В Node.js такой защиты нет — TypeScript не запрещает импортировать что угодно откуда угодно. Структура папок без дополнительного инструмента — просто соглашение, которое легко нарушить.

Эта статья объясняет: как именно раскладывать файлы, что куда попадает, и как настроить автоматическую проверку границ между слоями через dependency-cruiser.

Четыре зоны проекта

В Hexagonal-проекте на NestJS весь код делится на четыре зоны внутри src/:

src/
  core/       # бизнес-логика — не знает о NestJS, TypeORM, axios
  adapters/   # вход (HTTP, Kafka) и выход (БД, внешние API)
  app/        # точка сборки — собирает всё вместе

Стрелка зависимостей строго односторонняя:

app → adapters → core

core/ не знает ни об adapters/, ни об app/. adapters/ не знают друг о друге. app/ знает обо всех — он и связывает части вместе.

core/ — бизнес-логика без инфраструктуры

Здесь живёт всё, что относится к предметной области: агрегаты, value-объекты, доменные события, порт-интерфейсы, команды и хендлеры.

core/
  order/
    aggregate/        # Order, OrderItem
    value-object/     # OrderId, Money, CustomerId
    event/            # OrderCreatedEvent, OrderConfirmedEvent
    port/
      out/            # OrderRepository, PaymentPort — интерфейсы + Symbol-токены
    usecases/         # CreateOrderCommand, CreateOrderHandler
  product/
    aggregate/
    port/out/
    usecases/
  shared/
    dispatcher.ts     # единственная точка входа в core
    value-object/     # Money, Clock, TransactionRunner

Главное ограничение core/: здесь не появляются @nestjs/*, typeorm, class-validator, axios. Класс агрегата — это просто TypeScript-класс без сторонних зависимостей:

// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { Money } from '../../shared/value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderCreatedEvent } from '../event/order-created.event';

export class Order {
  private constructor(
    readonly id: OrderId,
    private _totalAmount: Money,
    private _status: OrderStatus,
    private readonly _events: unknown[],
  ) {}

  static create(customerId: CustomerId, items: OrderItem[], clock: Clock): Order {
    const id = new OrderId(randomUUID());
    const total = items.reduce((acc, i) => acc.add(i.price), Money.zero());
    const order = new Order(id, total, OrderStatus.PENDING, []);
    order._events.push(new OrderCreatedEvent(id, customerId, total, clock.now()));
    return order;
  }

  confirm(): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new OrderAlreadyConfirmedError(this.id);
    }
    this._status = OrderStatus.CONFIRMED;
  }

  pullEvents(): unknown[] { return this._events.splice(0); }
}

Ни одного импорта из фреймворка. Такой код можно тестировать без поднятия NestJS.

Хендлеры — plain-классы без @Injectable

Типичная ошибка — поставить @Injectable() на хендлер в core/. Тогда хендлер становится зависимым от @nestjs/common, что нарушает изоляцию.

Хендлеры в core/ — обычные TypeScript-классы. NestJS о них не знает. Связывание происходит в app/ через useFactory-провайдеры:

// core/order/usecases/create-order.handler.ts
export class CreateOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly tx: TransactionRunner,
    private readonly clock: Clock,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    const order = Order.create(cmd.customerId, cmd.items, this.clock);
    await this.tx.run(async () => { await this.orders.save(order); });
    return order;
  }
}

Нет @Injectable(). Нет @Inject(). Нет зависимости на @nestjs/common.

adapters/ — вход и выход

Адаптеры — реализация того, что core/ описывает через интерфейсы. Делятся на входящие (in/) и исходящие (out/).

Входящие адаптеры — точки входа в приложение:

adapters/in/
  http/            # публичный REST, JWT с user-audience
  http-admin/      # REST для админ-панели, JWT с admin-audience
  kafka/           # Kafka consumers как точка входа
  cli/             # CLI / batch (если есть)

Публичный API и admin-API разделены намеренно. У каждого свой Guard, свой JwtStrategy, свои DTO. Смешивать их в одной папке — значит потерять эту изоляцию.

Исходящие адаптеры — обращения к внешним системам. Каждая система — отдельная папка:

adapters/out/
  persistence/     # TypeORM — реализует OrderRepository
  sber/            # axios-клиент Sber — реализует PaymentPort
  notifications/   # SMS-провайдер — реализует NotificationPort
  kafka/           # kafkajs — реализует DomainEventPublisher

Зачем одна папка на одну систему: таймаут, retry, circuit breaker для Sber и для SMS — разные настройки. Один общий HTTP-клиент для всех внешних вызовов — частая ошибка, которая делает невозможным раздельное управление поведением.

app/ — точка сборки

app/ — это место, где всё собирается вместе. Только здесь NestJS-модули биндят порты на реализации:

app/
  main.ts           # NestFactory.create, enableShutdownHooks
  app.module.ts     # AppModule: импортирует все feature-модули
  config/           # ConfigModule, типизированный конфиг
  Dockerfile

Никакой бизнес-логики в app/ нет — только сборка. И никто не импортирует из app/ — это закрывающий узел.

Пример: как хендлер из core/ получает свои зависимости через useFactory:

// app/order.module.ts
@Module({
  providers: [
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
    { provide: TX_RUNNER, useClass: PgTransactionRunner },
    { provide: CLOCK, useClass: SystemClock },
    {
      provide: CreateOrderHandler,
      useFactory: (repo, tx, clock) => new CreateOrderHandler(repo, tx, clock),
      inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
    },
  ],
  exports: [CreateOrderHandler],
})
export class OrderModule {}

CreateOrderHandler не знает, что живёт в NestJS-контейнере. Он получает зависимости через конструктор — как обычный объект.

dependency-cruiser — автоматическая проверка границ

Структура папок без инструмента — соглашение на доверии. Стоит одному разработчику добавить import { DataSource } from 'typeorm' в файл агрегата — и граница нарушена. Тесты пройдут, IDE не скажет ни слова, code review может пропустить.

dependency-cruiser решает это: описываете запрещённые переходы в .dependency-cruiser.cjs, запускаете проверку в CI как обязательный шаг.

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: 'core-pure',
      severity: 'error',
      from: { path: '^src/core' },
      to: {
        path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
      },
    },
    {
      name: 'adapters-independent',
      severity: 'error',
      from: { path: '^src/adapters/in' },
      to: { path: '^src/adapters/out' },
    },
    {
      name: 'nobody-depends-on-app',
      severity: 'error',
      from: { path: '^src/(core|adapters)' },
      to: { path: '^src/app' },
    },
  ],
};

Добавьте скрипт в package.json:

{
  "scripts": {
    "arch:check": "depcruise --validate .dependency-cruiser.cjs src"
  }
}

Запускайте npm run arch:check в CI как required check — без зелёного статуса мерж заблокирован. Нарушение выглядит так:

error core-pure: src/core/order/aggregate/order.ts
  → node_modules/@nestjs/common/index.js

Нельзя «не заметить».

Частые ошибки

TypeORM в core/: import { DataSource } from 'typeorm' в агрегате или хендлере — нарушение изоляции. TypeORM относится только к adapters/out/persistence/, маппинг между доменными и ORM-объектами — в *.mapper.ts.

User и admin в одной папке: если публичный и административный REST живут в одном adapters/in/http/, они неизбежно начнут делить Guard-ы и DTO. Разделение на http/ и http-admin/ делает это явным.

@Injectable() на хендлере в core/: хендлер становится зависимым от @nestjs/common. Используйте plain-класс и useFactory-провайдер в app/.

In-адаптер импортирует out-адаптер: контроллер не должен обращаться напрямую к репозиторию. Координация — через DispatcherHandler в core/.

Коротко

  • src/ делится на три зоны: core/ (бизнес), adapters/ (вход/выход), app/ (сборка).
  • Стрелка зависимостей: app → adapters → core. core/ не знает ни о фреймворке, ни об адаптерах.
  • Хендлеры в core/ — plain-классы без @Injectable(). Связывание через useFactory в app/.
  • Каждая внешняя система — отдельная папка в adapters/out/. Каждый тип входа — отдельная папка в adapters/in/.
  • dependency-cruiser с тремя правилами (core-pure, adapters-independent, nobody-depends-on-app) и запуском в CI делает границы принудительными, а не добровольными.

Что почитать дальше

  • Ports — Symbol-токены, интерфейс порта, port-исключения в core/.
  • Adapters in — NestJS-контроллеры, маппинг DTO → command, Dispatcher.
  • Adapters out — реализация port-интерфейсов, TypeORM и axios-адаптеры.
  • Core слой — агрегаты, port-интерфейсы и plain-handlers.
  • Архитектурные тесты — полный конфиг dependency-cruiser и разбор ошибок.