Опирается на правила:
R-MOD-1…R-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-2 | Stateless plain class; DI-регистрация в OrderModule если нужна |
Импорт core/customer/aggregate/customer.ts в core/order/aggregate/order.ts | R-AGG-5 + R-MOD-1 | Ссылка по ID: readonly customerId: CustomerId |
Репозиторный порт в adapters/ (не в core/) | R-REP-1 | core/<bc>/port/order-repository.ts + Symbol-токен |
| Отсутствие dependency-cruiser / eslint-boundaries в CI | R-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/соотносятся с портами и адаптерами.