Когда 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 всех портов.