Опирается на правила: R-HEX-CORE-1R-HEX-CORE-4 и R-HEX-CORE-X1R-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-X1Plain class, wiring через useFactory в app/
@Entity(), @Column() из typeorm в core/R-HEX-CORE-X2TypeORM Entity — в adapters/out/persistence/; в core/ — domain-класс
Агрегат без методов, вся логика в OrderServiceR-HEX-CORE-X3Rich domain: order.confirm(), product.reserve(qty)
TypeORM-Entity как параметр или возврат портаR-HEX-CORE-X4Порт оперирует domain-типами; маппинг — в persistence-адаптере
CreateOrderDto (class-validator) в core/R-HEX-CORE-X5REST-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 — признаки «пора» и «рано».