Опирается на правила:
R-HEX-CORE-1…R-HEX-CORE-4иR-HEX-CORE-X1…R-HEX-CORE-X5из Hexagonal Style Guide → раздел 3. Core слой.
Важно знать
core/зависит только от TypeScript/stdlib + чистых утилит (Big.js, uuid, dayjs) — без@nestjs/*,typeorm,class-validator,axios,kafkajs.@Injectable/@Injectна классахcore/запрещены (R-HEX-CORE-3): в Node нет авто-пикающего стартера; handlers и domain-сервисы — plain classes, wiring черезuseFactoryвapp/или feature-модулях.- Outbound port — TypeScript-интерфейс + Symbol-токен в
core/<bc>/port/out/(интерфейсы стираются в runtime — токен обязателен для DI).- Rich domain: бизнес-логика внутри агрегата (
order.confirm()), не в*Service-классах. Anemic domain — антипаттерн (R-HEX-CORE-X3).- TypeORM Entity (
@Entity()) вcore/как доменный тип — запрещена. TypeORM — деталь persistence-адаптера.- Request/response-DTO (
class-validator-декораторы) вcore/— запрещены. REST-DTO — деталь in-adapter.- Граница
core/enforce'ится черезdependency-cruiser(правилоcore-pure) в CI — не дисциплиной.
core/ — это сердце сервиса. Здесь живут агрегаты, бизнес-правила, инварианты, domain-события и контракты к внешнему миру (port-интерфейсы). Это то, что не знает ни о NestJS, ни о TypeORM, ни о HTTP. Сервис запускается на NestJS, но сам core/ этого не видит — он получает зависимости через port-интерфейсы, которые реализуют адаптеры.
Ниже — раскрытие правил R-HEX-CORE-* в идиомах Node/NestJS.
Что в core/ можно
R-HEX-CORE-1: точный список разрешённых зависимостей — только TypeScript/stdlib и доменные утилиты без фреймворка.
// Разрешено в core/
import { v4 as uuidv4 } from 'uuid'; // генерация идентификаторов
import Big from 'big.js'; // точная арифметика
import { parseISO } from 'date-fns'; // date-only утилиты без side-effects
Что запрещено в core/:
@nestjs/*— фреймворк, декораторы, DI-контейнер.typeorm,@typeorm/*— ORM, Entity-декораторы, Column, Repository.class-validator,class-transformer— декораторы валидации HTTP-запросов.axios,undici,node-fetch— HTTP-клиенты.kafkajs,bullmq,ioredis— очереди, кэши.- Любая другая инфраструктурная библиотека.
Если в core/ появился такой импорт — это либо файл лежит не там (должен быть в адаптере), либо нарушение архитектуры. dependency-cruiser (R-HEX-TEST-1) ловит это автоматически в CI.
Структура core/
R-HEX-CORE-2: типичная раскладка.
src/core/
└── <bc>/ # Bounded Context (order, customer, payment)
├── aggregate/
│ └── order.ts # Aggregate Root
├── entity/
│ └── order-item.ts # Entity
├── value-object/
│ ├── money.ts # Value Object
│ └── order-id.ts
├── event/
│ └── order-confirmed.event.ts # Domain Event
├── port/out/
│ ├── order-repository.ts # interface + Symbol-токен
│ ├── payment-port.ts
│ └── notification-port.ts
├── usecases/
│ ├── confirm-order.command.ts # Command/Query + Handler
│ ├── confirm-order.handler.ts
│ ├── get-order.query.ts
│ └── get-order.handler.ts
└── service/ # shared domain-логика (редко)
└── pricing.service.ts
Обрати внимание:
<bc>/— Bounded Context. Один сервис может иметь 1–3 BC (редко больше). В каждом — свои агрегаты, VO, события.port/out/— outbound port-интерфейсы + Symbol-токены. «Что core нужно от внешнего мира»: репозитории, клиенты внешних систем, event publishers.usecases/— Command/Query + Handler пары. Команды меняют состояние агрегата, query возвращают read-проекции.service/— shared domain-логика, не умещающаяся в один агрегат. Используется редко; чаще — метод на агрегате или domain-event.
Port-интерфейс + Symbol-токен
R-HEX-PORT-1: TypeScript стирает интерфейсы в runtime — для NestJS DI нужен Symbol.
// 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/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>;
}
Токен ORDER_REPOSITORY — это имя слота в DI-контейнере. В app/ адаптер биндится на этот токен, в core никто не знает, что за реализация придёт (R-HEX-PORT-X4 — порт как класс убивает подмену).
Исключение к | null: если отсутствие результата — нормальный flow (поиск по необязательному параметру) — null допустим. Если отсутствие = ошибка — бросай OrderNotFoundError (R-HEX-PORT-X3).
Rich domain — логика внутри агрегата
R-HEX-CORE-4: бизнес-правила и инварианты живут в агрегате, не в *Service-классах.
// 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);
}
}
Handler остаётся тонким — он оркеструет, не решает:
// 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-интерфейсом
}
});
}
}
Handler — plain class: нет @Injectable, нет декораторов, только конструктор с зависимостями. Wiring — в app/.
Что не так с анемичной моделью:
- Инварианты разъезжаются.
confirm()-логика дублируется в контроллере, в Kafka-consumer'е, в admin-CLI — одна из копий рано или поздно отстаёт. - Unit-тесты на логику невозможны. Вся проверка через
@nestjs/testing-контекст с поднятой БД. - Domain — свалка геттеров/сеттеров. Понять lifecycle заказа = найти все места, где меняется
status.
@Injectable в core/ — почему запрещён
R-HEX-CORE-3: в Java есть usecase-pattern-starter, который авто-пикает классы с кастомным маркером без Spring в classpath core/. В Node такого механизма нет — если добавить @Injectable в core/, придётся импортировать @nestjs/common, что нарушает R-HEX-CORE-X1.
Вместо этого — useFactory в app/ или feature-модуле:
// 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 {}
Да, это boilerplate — но осознанный. Каждый useFactory явно называет зависимости; незабинженный токен в NestJS падает на старте (R-HEX-BOOT-3), не на первом запросе.
На сервисе с 20–30 handler'ами — useFactory выглядит многословно, но даёт полную прозрачность: где какой токен, что куда биндится. Альтернатива через custom metadata (reflect-metadata без NestJS) — возможна, но усложняет дебаг DI.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
import { Injectable } from '@nestjs/common' в core/ | R-HEX-CORE-X1 | Plain class, wiring через useFactory в app/ |
@Entity(), @Column() из typeorm в core/ | R-HEX-CORE-X2 | TypeORM Entity — в adapters/out/persistence/; в core/ — domain-класс |
Агрегат без методов, вся логика в OrderService | R-HEX-CORE-X3 | Rich domain: order.confirm(), product.reserve(qty) |
| TypeORM-Entity как параметр или возврат порта | R-HEX-CORE-X4 | Порт оперирует domain-типами; маппинг — в persistence-адаптере |
CreateOrderDto (class-validator) в core/ | R-HEX-CORE-X5 | REST-DTO — в adapters/in/http/; в core/ — CreateOrderCommand с domain-типами |
| Порт как класс с реализацией | R-HEX-PORT-X4 | Только interface + Symbol-токен; реализация — адаптер |
Куда дальше
- Ports — про port-интерфейсы и Symbol-токены в
core/<bc>/port/out/. - Adapters in — контроллер, маппер request-DTO в Command,
Dispatcher. - Adapters out — реализация порта, биндинг по токену, маппинг domain ↔ DTO.
- Bootstrap / composition root —
AppModule,useFactory, wiring всех портов. - Module structure — полная раскладка папок и правила dependency-cruiser.
- Architecture tests —
depcruise --validateв CI как required check. - Когда переходить на Hexagonal — признаки «пора» и «рано».