Опирается на правила: R-MOD-1R-MOD-2 из DDD Tactical Style Guide → раздел 9. Module (структура папок).

Важно знать

  • Верхний уровень core/Bounded Context: core/order/, core/customer/, не entity/, не service/, не repository/.
  • Внутри BC — подпапки по роли: aggregate/, entity/, value-object/, event/, port/, usecases/ (опционально: service/, specification/).
  • Адаптеры — отдельная иерархия: adapters/in/http/, adapters/out/persistence/. В монорепо с NestJS-модулями — отдельный AppModule в app/.
  • Группировка по типу — антипаттерн. Верхнеуровневые entity/, repository/, service/ — признак отсутствия DDD.
  • core/ не импортирует @nestjs/*, typeorm, class-validator, class-transformer. Только чистый TypeScript.
  • Нарушение границ core/ проверяется в CI через dependency-cruiser (depcruise --validate) или eslint-boundaries.
  • Порт репозитория (интерфейс + Symbol-токен) объявляется в core/<bc>/port/ — домен остаётся независимым от реализации.
  • Ссылки между Bounded Context — только по ID (CustomerId, ProductId), импорт класса соседнего BC — запрещён.

Структура папок выглядит формальностью, но именно по ней через год читается, был ли в проекте DDD или только его обозначение. Когда entity/, service/, repository/ живут на верхнем уровне, разработчик ищет «всё про заказ» в шести местах одновременно. Когда верхний уровень — Bounded Context, открыл core/order/ — увидел всё. Раскрытие раздела 9 гайда.

Канонический layout

R-MOD-1: верхний уровень core/ — Bounded Context. Внутри — подпапки по роли DDD-строительных блоков.

src/
  core/
    shared/
      building-blocks.ts          # Entity, ValueObject, AggregateRoot, DomainEvent
    order/                        # Bounded Context: Order
      aggregate/
        order.ts                  # class Order extends AggregateRoot<OrderId>
      entity/
        order-line.ts             # class OrderLine extends Entity<string>
      value-object/
        money.ts                  # class Money extends ValueObject
        order-id.ts               # type OrderId = string & { __brand: 'OrderId' }
        order-status.ts           # enum OrderStatus
      event/
        order-created.ts          # class OrderCreated extends DomainEvent
        order-confirmed.ts
        order-cancelled.ts
      port/
        order-repository.ts       # interface OrderRepository + Symbol-токен
      service/                    # опционально
        pricing.service.ts        # class PricingService (stateless, без @Injectable)
      specification/              # опционально
        eligible-for-discount.ts  # class EligibleForDiscount
      usecases/
        command/
          create-order.ts         # class CreateOrderCommand
          create-order.handler.ts # class CreateOrderHandler
          confirm-order.ts
          confirm-order.handler.ts
          cancel-order.ts
          cancel-order.handler.ts
        query/
          get-order.ts
          get-order.handler.ts
          find-active-orders.ts
          find-active-orders.handler.ts
    customer/                     # Bounded Context: Customer
      aggregate/...
      port/...
      usecases/...
    product/                      # Bounded Context: Product
      aggregate/...
      port/...
      usecases/...

  adapters/
    in/
      http/
        order.controller.ts       # @Controller, NestJS
        order.mapper.ts           # DTO ↔ Command/Query
    out/
      persistence/
        order.typeorm-entity.ts   # @Entity TypeORM
        order.typeorm-repository.ts # implements OrderRepository
        order.mapper.ts           # TypeORM-Entity ↔ Domain

  app/
    app.module.ts                 # AppModule, DI-регистрация
    order.module.ts               # OrderModule — связывает порт и реализацию

Что даёт такая структура:

  • Перенос BC в отдельный сервис — папка становится пакетом. Когда customer/ вырастает, папка core/customer/ копируется целиком. Ссылки на другие BC уже идут по ID (CustomerId), импорты не размазаны.
  • Читаемость «всё про заказ» — одна папка. core/order/ содержит агрегат, события, репозиторный порт и use-case-ы. Искать OrderService в services/ и OrderEntity в entities/ — не нужно.
  • Проверяемая граница. dependency-cruiser знает, что core/ не должен импортировать adapters/ — правило пишется одной строкой в .dependency-cruiser.js.

Группировка по типу — антипаттерн

R-MOD-1 запрещает это:

// ПЛОХО — по типу
src/
  entity/
    order.ts
    customer.ts
    product.ts
  repository/
    order.repository.ts
    customer.repository.ts
  service/
    order.service.ts
    customer.service.ts
    pricing.service.ts
  controller/
    order.controller.ts
    customer.controller.ts

Что не так:

  • Зависимости размазаны. Чтобы понять, как работает Order, нужно открыть четыре разные папки.
  • Нарушение R-AGG-5 не видно. Импорт entity/customer.ts в service/order.service.ts — формально ок, но это ссылка между агрегатами объектом. Структура по типу не помогает это заметить.
  • Выделение BC — операция на весь проект. Перенос Customer в отдельный сервис требует собирать файлы из entity/, repository/, service/, controller/ по имени.

Группировка по типу — наследие MVC-мышления (controllers/, models/, services/). В DDD от неё отказываются: всё, что связано одним доменом, должно быть рядом.

core/ без NestJS и TypeORM

R-MOD-2: core/<bc>/ не импортирует ничего из фреймворка и persistence-стека.

Что разрешено внутри core/:

  • ./building-blocks — базовые классы Entity, ValueObject, AggregateRoot, DomainEvent.
  • Стандартная библиотека TypeScript/Node (crypto, uuid, big.js).
  • Другие domain-классы того же или соседнего BC — только по ID.

Что не допускается:

  • @nestjs/common и любые @nestjs/*@Injectable, @Module, @Controller не в core/.
  • typeorm, @typeorm/* — ORM-аннотации и сущности TypeORM остаются в adapters/out/persistence/.
  • class-validator, class-transformer — валидация DTO не в домене.
  • express, fastify — HTTP-зависимости не в core/.
// ПЛОХО — TypeORM в domain
import { Entity, Column, PrimaryColumn } from 'typeorm';

@Entity('orders')
export class Order {
  @PrimaryColumn()
  id: string;

  @Column()
  status: string;

  // нет методов — анемия + R-MOD-2
}
// ХОРОШО — чистый домен
// core/order/aggregate/order.ts
import { AggregateRoot } from '../../shared/building-blocks';
import { OrderId } from '../value-object/order-id';
import { CustomerId } from '../value-object/ids';
import { OrderLine } from '../entity/order-line';
import { Money } from '../value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderConfirmed } from '../event/order-confirmed';
import { DomainError } from '../../shared/domain-error';

export class Order extends AggregateRoot<OrderId> {
  private status: OrderStatus = OrderStatus.NEW;
  private readonly orderLines: OrderLine[] = [];

  constructor(id: OrderId, readonly customerId: CustomerId) {
    super(id);
  }

  get lines(): ReadonlyArray<OrderLine> {
    return [...this.orderLines];
  }

  confirm(now: Date): void {
    if (this.orderLines.length === 0) throw new DomainError('cannot confirm empty order');
    this.status = OrderStatus.CONFIRMED;
    this.registerEvent(new OrderConfirmed(crypto.randomUUID(), now, this.id, this.customerId, this.total()));
  }

  total(): Money {
    return this.orderLines.reduce((acc, line) => acc.add(line.subtotal()), Money.zero('RUB'));
  }
}

Маппинг Order ↔ TypeORM-Entity делается в отдельном маппере в adapters/out/persistence/. Домен не знает, как выглядит orders-таблица.

Порт репозитория в core/

R-REP-1: порт — интерфейс + Symbol-токен — объявляется в core/<bc>/port/, не в адаптере.

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

export interface OrderRepository {
  byId(orderId: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  activeByCustomer(customerId: CustomerId): Promise<Order[]>;
}

Реализация в adapters/out/persistence/order.typeorm-repository.ts регистрируется в OrderModule:

// app/order.module.ts
import { Module } from '@nestjs/common';
import { ORDER_REPOSITORY } from '../core/order/port/order-repository';
import { OrderTypeOrmRepository } from '../adapters/out/persistence/order.typeorm-repository';
import { CreateOrderHandler } from '../core/order/usecases/command/create-order.handler';

@Module({
  providers: [
    { provide: ORDER_REPOSITORY, useClass: OrderTypeOrmRepository },
    CreateOrderHandler,
  ],
  exports: [CreateOrderHandler],
})
export class OrderModule {}

Handler получает репозиторий через @Inject(ORDER_REPOSITORY) — NestJS-декоратор допустим в usecases/, потому что это граница приложения, не домена. Альтернатива — вынести регистрацию в app/ полностью и держать Handler без декораторов.

usecases/ — команды и запросы

usecases/ живёт внутри того же BC рядом с domain-папками. Здесь — команды и запросы с хендлерами:

// core/order/usecases/command/confirm-order.handler.ts
import { Inject } from '@nestjs/common';
import { ORDER_REPOSITORY, OrderRepository } from '../../port/order-repository';

export class ConfirmOrderCommand {
  constructor(readonly orderId: OrderId) {}
}

export class ConfirmOrderHandler {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orderRepository: OrderRepository,
  ) {}

  async handle(command: ConfirmOrderCommand): Promise<void> {
    const order = await this.orderRepository.byId(command.orderId);
    if (!order) throw new DomainError('Order not found');

    order.confirm(new Date());

    await this.orderRepository.save(order);
    // публикация событий — через Outbox в реализации save, или явно здесь через EventPublisher
  }
}

Бизнес-правила живут в core/order/aggregate/order.ts. Хендлер оркеструет: загрузил агрегат, вызвал метод, сохранил. Это разделение — и есть смысл слоя usecases/.

Подробно про команды и хендлеры — в Use Case Pattern Style Guide.

Enforce границ в CI

Одного соглашения недостаточно — нужна проверка. Два варианта:

dependency-cruiser — декларативные правила в .dependency-cruiser.js:

module.exports = {
  forbidden: [
    {
      name: 'core-no-adapters',
      severity: 'error',
      from: { path: '^src/core/' },
      to: { path: '^src/adapters/' },
    },
    {
      name: 'core-no-nestjs',
      severity: 'error',
      from: { path: '^src/core/' },
      to: { dependencyTypes: ['npm'], path: '^@nestjs/' },
    },
  ],
};

eslint-boundaries — через import/no-restricted-paths или плагин eslint-plugin-boundaries с зонами core, adapters, app.

Оба подхода запускаются в CI до сборки. Нарушение границы — ошибка сборки, не замечание на ревью.

Что запрещено

АнтипаттернПравилоЧто взамен
Верхнеуровневые entity/, service/, repository/ в core/R-MOD-1Группировка по Bounded Context: core/order/, core/customer/
@Entity, @Column, TypeORM-аннотации в core/<bc>/aggregate/R-MOD-2Чистый класс в core/, TypeORM-Entity в adapters/out/persistence/
@Injectable() на Domain Service в core/<bc>/service/R-MOD-2Stateless plain class; DI-регистрация в OrderModule если нужна
Импорт core/customer/aggregate/customer.ts в core/order/aggregate/order.tsR-AGG-5 + R-MOD-1Ссылка по ID: readonly customerId: CustomerId
Репозиторный порт в adapters/ (не в core/)R-REP-1core/<bc>/port/order-repository.ts + Symbol-токен
Отсутствие dependency-cruiser / eslint-boundaries в CIR-MOD-2Проверка границ core/ в CI — обязательна

Куда дальше

  • node/aggregate-root.md — AggregateRoot<ID>, registerEvent, pullEvents.
  • node/value-object.md — иммутабельные VO, branded types, Object.freeze.
  • node/entity.md — Entity<ID>, identity-equality, equals().
  • node/repository.md — порт OrderRepository, Symbol-токен, реализация в TypeORM.
  • node/domain-event.md — DomainEvent, Outbox, pullEvents().
  • node/domain-service.md — когда вводить, stateless plain class.
  • node/factory.md — static create(), когда конструктора недостаточно.
  • node/specification.md — isSatisfiedBy, когда вводить.
  • Смежный раздел: Hexagonal Architecture — как core/ и adapters/ соотносятся с портами и адаптерами.