Опирается на правила: R-AGG-1R-AGG-5 и R-AGG-X1R-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-X3registerEvent + подписчик в отдельной транзакции
Регистрация события вне корня (в Handler / репозитории)R-AGG-X4this.registerEvent(...) внутри метода агрегата
Ссылка на объект другого агрегата (customer: Customer)R-AGG-5customerId: 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(...).