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

Когда NestJS-приложение растёт, бизнес-логика начинает расползаться — немного в контроллер, немного в сервис, немного в TypeORM-репозиторий. Когда нужно написать тест, оказывается, что без поднятой базы ничего не работает. Когда нужно поменять HTTP на Kafka — переписывать приходится половину кода.

Hexagonal Architecture решает это одним принципом: бизнес-логика не знает, откуда пришли данные и куда они уйдут. Для этого в приложении выделяют core/ — слой, который не импортирует ни NestJS, ни TypeORM, ни axios. Здесь живут агрегаты, правила, инварианты и контракты к внешнему миру.

Что можно импортировать в core/

Правило простое: только TypeScript и небольшие утилиты без побочных эффектов.

import { v4 as uuidv4 } from 'uuid';     // генерация идентификаторов
import Big from 'big.js';                 // точная арифметика
import { parseISO } from 'date-fns';      // работа с датами

Всё остальное — за пределами core/:

  • @nestjs/* — фреймворк, декораторы, DI-контейнер;
  • typeorm, Entity-декораторы — детали хранения данных;
  • class-validator, class-transformer — декораторы для HTTP-запросов;
  • axios, undici, node-fetch — HTTP-клиенты;
  • kafkajs, bullmq, ioredis — очереди и кэши.

Если в core/ появился такой импорт — либо файл лежит не там, либо что-то сломалось в архитектуре. Граница проверяется автоматически через dependency-cruiser в CI, не вручную.

Как устроен core/

src/core/
└── order/                             # один Bounded Context
    ├── aggregate/
    │   └── order.ts                   # Aggregate Root
    ├── entity/
    │   └── order-item.ts
    ├── value-object/
    │   ├── money.ts
    │   └── order-id.ts
    ├── event/
    │   └── order-confirmed.event.ts   # Domain Event
    ├── port/out/
    │   ├── order-repository.ts        # интерфейс + Symbol-токен
    │   └── payment-port.ts
    └── usecases/
        ├── confirm-order.command.ts
        ├── confirm-order.handler.ts
        ├── get-order.query.ts
        └── get-order.handler.ts
  • aggregate/ — корень агрегата с бизнес-правилами;
  • port/out/ — контракты к внешнему миру: репозитории, клиенты внешних систем, издатели событий;
  • usecases/ — пары «команда/запрос + хендлер», которые оркестрируют операцию.

Один сервис обычно содержит один-три Bounded Context. Больше — сигнал, что пора разделять сервисы.

Port-интерфейс и Symbol-токен

Контракт между core/ и адаптером оформляется как TypeScript-интерфейс. Но есть нюанс: TypeScript стирает интерфейсы в рантайме, а NestJS DI работает именно в рантайме. Поэтому рядом с интерфейсом объявляют Symbol-токен — это «имя слота» в контейнере.

// core/order/port/out/order-repository.ts
export const ORDER_REPOSITORY = Symbol('OrderRepository');

export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByIdForUpdate(id: OrderId): Promise<Order>;   // бросает OrderNotFoundError если не нашёл
  save(order: Order): Promise<void>;
}
// core/payment/port/out/payment-port.ts
export const PAYMENT_PORT = Symbol('PaymentPort');

export interface PaymentPort {
  register(cmd: RegisterPayment): Promise<RegisterResult>;
  cancel(paymentId: PaymentId): Promise<void>;
}

core/ знает только интерфейс — что можно попросить у внешнего мира. Какая реализация придёт (настоящая БД, Kafka, заглушка в тесте) — решает app/. Именно это позволяет тестировать хендлеры без базы.

findByIdForUpdate возвращает агрегат или бросает ошибку — потому что отсутствие заказа при подтверждении это ошибка, а не нормальный сценарий. findById возвращает null только там, где отсутствие — допустимый результат (например, поиск по необязательному параметру).

Rich domain — логика внутри агрегата

Распространённая ошибка в NestJS-проектах: агрегат — просто набор полей с геттерами и сеттерами, а вся логика живёт в OrderService. Это называется анемичной моделью, и у неё есть конкретные последствия:

  • правило «заказ нельзя подтвердить без товаров» копируется в контроллер, в Kafka-consumer, в скрипт миграции — рано или поздно копии расходятся;
  • написать unit-тест на логику нельзя без поднятого NestJS-контекста с базой;
  • понять жизненный цикл заказа = найти все места, где меняется поле status.

Альтернатива: бизнес-правила и инварианты живут внутри агрегата.

// core/order/aggregate/order.ts
export class Order {
  private status: OrderStatus;
  private items: OrderItem[];
  private total: Money;
  private readonly events: DomainEvent[] = [];

  confirm(): void {
    if (this.items.length === 0) {
      throw new EmptyOrderError(this.id);
    }
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStatusError(this.status, OrderStatus.DRAFT);
    }
    if (this.total.isZeroOrNegative()) {
      throw new InvalidOrderTotalError(this.total);
    }
    this.status = OrderStatus.CONFIRMED;
    this.events.push(new OrderConfirmedEvent(this.id, this.total));
  }

  addItem(product: ProductId, qty: number, price: Money): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStatusError(this.status, OrderStatus.DRAFT);
    }
    this.items.push(new OrderItem(product, qty, price));
    this.total = this.total.add(price.multiply(qty));
  }

  pullEvents(): DomainEvent[] {
    return this.events.splice(0);
  }
}

Хендлер при этом остаётся тонким — он оркестрирует операцию, а не решает бизнес-вопросы:

// core/order/usecases/confirm-order.handler.ts
export class ConfirmOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentPort,
    private readonly tx: TransactionRunner,
  ) {}

  async handle(cmd: ConfirmOrderCommand): Promise<void> {
    await this.tx.run(async () => {
      const order = await this.orders.findByIdForUpdate(cmd.orderId);
      order.confirm();                         // вся логика — в агрегате
      await this.orders.save(order);

      const events = order.pullEvents();
      for (const e of events) {
        await this.payments.processEvent(e);   // реализация — за port-интерфейсом
      }
    });
  }
}

Хендлер — plain class: никаких декораторов, никакого @Injectable, только конструктор с зависимостями через интерфейсы. Его можно создать через new в тесте и передать заглушки.

Почему @Injectable запрещён в core/

В Java есть механизм, который позволяет пикать классы без Spring в classpath. В Node такого нет. Если добавить @Injectable в core/, придётся импортировать @nestjs/common — а это уже нарушение границы слоя.

Вместо этого хендлеры подключаются через useFactory в модуле приложения:

// app/order.module.ts
import { Module } from '@nestjs/common';
import { ConfirmOrderHandler } from '../core/order/usecases/confirm-order.handler';
import { ORDER_REPOSITORY, TX_RUNNER, PAYMENT_PORT } from '../core/order/port/out';

@Module({
  providers: [
    {
      provide: ConfirmOrderHandler,
      useFactory: (
        orders: OrderRepository,
        tx: TransactionRunner,
        payments: PaymentPort,
      ) => new ConfirmOrderHandler(orders, tx, payments),
      inject: [ORDER_REPOSITORY, TX_RUNNER, PAYMENT_PORT],
    },
  ],
  exports: [ConfirmOrderHandler],
})
export class OrderModule {}

Это многословнее, чем @Injectable, но даёт прозрачность: каждый useFactory явно называет, что куда биндится. Незарегистрированный токен NestJS обнаружит на старте приложения, а не при первом запросе.

На сервисе с 20–30 хендлерами useFactory выглядит объёмно. Но это управляемый объём — каждая строка несёт смысл, и разобраться в нём проще, чем в «магическом» DI через рефлексию.

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

@Entity() из TypeORM в core/. TypeORM Entity — это деталь persistence-адаптера. В core/ — обычный TypeScript-класс без декораторов. Маппинг между ними — в адаптере.

REST-DTO с class-validator в core/. CreateOrderDto с декораторами @IsString() — это деталь HTTP-адаптера. В core/CreateOrderCommand с domain-типами.

Порт как класс с реализацией. Порт — только interface + Symbol-токен. Если порт — это класс, его нельзя подменить в тесте без изменения core/.

Вся логика в OrderService, агрегат — геттеры. Это анемичная модель. Логика переедет в агрегат — инварианты соберутся в одном месте, тесты упростятся.

Коротко

  • core/ — слой без фреймворков: только TypeScript, stdlib и небольшие утилиты. Граница проверяется dependency-cruiser в CI.
  • Port-интерфейс объявляется вместе с Symbol-токеном: TypeScript стирает интерфейсы в рантайме, а NestJS DI работает по токену.
  • Хендлеры — plain classes без @Injectable; подключаются через useFactory в модулях app/.
  • Бизнес-правила живут в агрегате (order.confirm()), а не в сервисных классах — так инварианты не копируются и тестируются без фреймворка.
  • TypeORM Entity и DTO с class-validator — детали адаптеров, не core/.

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

  • Ports в Hexagonal Architecture: Node/NestJS — port-интерфейсы и Symbol-токены подробнее.
  • Adapters in: Node/NestJS — контроллер, маппер request-DTO в команду.
  • Adapters out: Node/NestJS — реализация порта, биндинг по токену, маппинг domain ↔ persistence.
  • Bootstrap / composition root: Node/NestJS — AppModule, wiring всех портов.