Опирается на правила:
R-AGG-1…R-AGG-5иR-AGG-X1…R-AGG-X4из DDD Tactical Style Guide → раздел 3. Aggregate Root.
Важно знать
- Aggregate — кластер Entity и VO, согласованных по инварианту. Один корень (
AggregateRoot) — единственная точка внешнего доступа.- Транзакционная граница = граница агрегата. Один UseCase изменяет один агрегат. Между агрегатами — eventual consistency через события.
- Внутренние Entity скрыты. Наружу коллекции выдаются копией (
[...this.lines]) или черезReadonlyArray— это не защита в runtime, поэтому нужна явная копия.- Доменные события регистрируются только в корне через
this.registerEvent(...). Handler и репозиторий события не создают.- После
repository.save(order)Handler вызываетorder.pullEvents()и публикует события в Outbox.pullEvents()очищает внутренний список.- Ссылки на другие агрегаты — по ID (
CustomerId,ProductId), не объектами.- God aggregate — главный антипаттерн. Делим по локальному бизнес-инварианту, не по UI или структуре таблиц.
Aggregate Root — объект, который отвечает за согласованность кластера данных. Если правило «сумма позиций заказа равна total» — Order является корнем, OrderLine — внутренней Entity. Любая операция над OrderLine идёт через метод Order, который пересчитает total. Раскрытие раздела 3 гайда.
Базовый класс AggregateRoot
R-AGG-1: корень наследует AggregateRoot<ID> из core/shared/building-blocks.ts:
// core/shared/building-blocks.ts
export abstract class AggregateRoot<ID> extends Entity<ID> {
private readonly events: DomainEvent[] = [];
protected registerEvent(event: DomainEvent): void {
this.events.push(event);
}
pullEvents(): DomainEvent[] {
return this.events.splice(0, this.events.length);
}
}
// core/order/aggregate/order.ts
import { v7 as uuidv7 } from 'uuid';
import { AggregateRoot } from '../../shared/building-blocks';
import { OrderId, 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 { OrderCreated } from '../event/order-created';
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(readonly id: OrderId, readonly customerId: CustomerId) {
super(id);
}
get lines(): ReadonlyArray<OrderLine> {
return [...this.orderLines];
}
total(): Money {
return this.orderLines
.map(l => l.subtotal())
.reduce((acc, m) => acc.add(m), Money.zero('RUB'));
}
addLine(line: OrderLine): void {
if (this.status !== OrderStatus.NEW) {
throw new DomainError('cannot add line to confirmed order');
}
this.orderLines.push(line);
}
confirm(now: Date): void {
if (this.orderLines.length === 0) {
throw new DomainError('cannot confirm empty order');
}
if (this.status !== OrderStatus.NEW) {
throw new DomainError('order already confirmed');
}
this.status = OrderStatus.CONFIRMED;
this.registerEvent(
new OrderConfirmed(uuidv7(), now, this.id, this.customerId, this.total()),
);
}
cancel(reason: string, now: Date): void {
if (this.status === OrderStatus.SHIPPED) {
throw new DomainError('cannot cancel shipped order');
}
if (this.status === OrderStatus.CANCELLED) return;
this.status = OrderStatus.CANCELLED;
this.registerEvent(new OrderCancelled(uuidv7(), now, this.id, reason));
}
static create(customerId: CustomerId, now: Date): Order {
const id = OrderId(uuidv7());
const order = new Order(id, customerId);
order.registerEvent(new OrderCreated(uuidv7(), now, id, customerId));
return order;
}
}
Все внешние операции — через методы корня
R-AGG-2: внутренние Entity недоступны снаружи без обёртки. Геттер lines возвращает копию массива — ReadonlyArray в сигнатуре не мешает вызвавшему сделать приведение (order.lines as OrderLine[]).push(...).
get lines(): ReadonlyArray<OrderLine> {
return [...this.orderLines]; // копия, не this.orderLines
}
Зачем закрывать:
- Инвариант
total = sum(lines)ломается, еслиorderLinesменяется помимо методаaddLine. - События не регистрируются — прямая мутация не вызывает
registerEvent.
// ПЛОХО — клиент мутирует внутреннее состояние
(order.lines as OrderLine[]).push(newLine);
// ХОРОШО
order.addLine(newLine);
События регистрируются только в корне
R-AGG-3, R-AGG-X4: this.registerEvent(...) — внутри методов агрегата, в момент изменения состояния. Не в Handler, не в репозитории.
// core/order/aggregate/order.ts
confirm(now: Date): void {
// ...
this.status = OrderStatus.CONFIRMED;
this.registerEvent( // ← в корне, в методе изменения
new OrderConfirmed(uuidv7(), now, this.id, this.customerId, this.total()),
);
}
Почему в корне: состояние и событие меняются в одном месте. Невозможно «изменить состояние и забыть зарегистрировать событие».
После сохранения Handler вызывает pullEvents() — splice очищает внутренний список и возвращает накопленные события для публикации:
// core/order/usecase/command/confirm-order.handler.ts
async handle(command: ConfirmOrder): Promise<void> {
const order = await this.orderRepository.byId(command.orderId);
if (!order) throw new OrderNotFoundError(command.orderId);
order.confirm(new Date());
await this.orderRepository.save(order);
const events = order.pullEvents();
await this.outboxPublisher.publishAll(events);
}
Транзакционная граница — один агрегат
R-AGG-4: один UseCase изменяет один агрегат. Другие — через события, в отдельных транзакциях.
// ПЛОХО — Handler меняет два агрегата в одной транзакции
async handle(command: ConfirmOrder): Promise<void> {
const order = await this.orderRepository.byId(command.orderId);
const customer = await this.customerRepository.byId(order.customerId);
customer.incrementOrderCount(); // ← мутация чужого агрегата
order.confirm(new Date());
await this.customerRepository.save(customer);
await this.orderRepository.save(order);
}
Что не так (R-AGG-X3): двойная блокировка, deadlock-prone при встречном порядке в другом UseCase, невозможность разнести по сервисам.
Правильно: OrderConfirmed подписчик CustomerOrderCountIncreased обрабатывает в отдельной транзакции, увеличивая счётчик заказов.
Ссылки между агрегатами — по ID
R-AGG-5:
// ПЛОХО
class Order extends AggregateRoot<OrderId> {
customer: Customer;
}
// ХОРОШО
class Order extends AggregateRoot<OrderId> {
readonly customerId: CustomerId;
}
Read-сценарий «показать заказ + имя клиента» — это query на read-model, не загрузка Customer внутри Order.
God aggregate — антипаттерн
R-AGG-X1: если внутри одного агрегата десятки несвязанных Entity — это God aggregate.
// ПЛОХО — God aggregate
class Customer extends AggregateRoot<CustomerId> {
orders: Order[]; // ← каждый Order — свой агрегат
invoices: Invoice[]; // ← Invoice тоже
subscriptions: Subscription[];
}
Что не так: загрузка Customer поднимает мегабайты данных, любая операция блокирует всё. Выделяем по общему бизнес-инварианту, который нужно поддерживать атомарно.
Правильное деление:
Customer— id, имя, email, статус.Order— id, lines, total, status; хранитcustomerIdпо ID.Invoice— id, сумма, дата, статус оплаты.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| God aggregate (десятки несвязанных Entity) | R-AGG-X1 | Делим по локальному инварианту |
Возврат this.orderLines без копии | R-AGG-X2 | [...this.orderLines] или ReadonlyArray + копия |
| Изменение чужого агрегата напрямую | R-AGG-X3 | registerEvent + подписчик в отдельной транзакции |
| Регистрация события вне корня (в Handler / репозитории) | R-AGG-X4 | this.registerEvent(...) внутри метода агрегата |
Ссылка на объект другого агрегата (customer: Customer) | R-AGG-5 | customerId: CustomerId |
Куда дальше
- DDD Tactical → раздел 3. Aggregate Root — нормативные формулировки
R-AGG-*. - node/entity.md — внутренние Entity агрегата.
- node/domain-event.md — что такое
registerEventи как события публикуются после save. - node/repository.md —
save+pullEventsна границе транзакции. - node/factory.md — фабричный метод
Order.create(...).