Опирается на правила: R-EVT-1R-EVT-5 и R-EVT-X1R-EVT-X4 из DDD Tactical Style Guide → раздел 4. Domain Event.

Важно знать

  • Domain Event = факт, который уже произошёл в домене. Имя — глагол в прошедшем времени: OrderConfirmed, CustomerRegistered, не ConfirmOrder и не OrderEvent.
  • Наследует DomainEvent из core/shared/building-blocks.ts. Несёт eventId (uuid), occurredAt (Date), aggregateId (string).
  • Иммутабельность: все поля readonly, Object.freeze(this) в конструкторе.
  • В полях — только примитивы и VO, не агрегаты и не Entity. OrderId, Money, Date — да. Order — нет.
  • Корень регистрирует событие через this.registerEvent(...). После repository.save() Handler вызывает order.pullEvents() и публикует события в Outbox в той же транзакции.
  • EventEmitter2-listener после ответа — критичные эффекты так не доставлять: процесс может упасть между commit и emit. Нужен Outbox.

Domain Event — механизм, через который агрегат сообщает миру «у меня изменилось состояние», не зная заранее, кто будет слушать. Подписчики (другой агрегат, проекция read-model, интеграция с внешней системой) подключаются независимо. Без событий все взаимодействия пришлось бы зашивать прямыми вызовами. Раскрытие раздела 4 гайда.

Базовый класс DomainEvent

R-EVT-1: событие наследует DomainEvent, передаёт в конструктор eventId, occurredAt, aggregateId:

// core/shared/building-blocks.ts
export abstract class DomainEvent {
  protected constructor(
    readonly eventId: string,
    readonly occurredAt: Date,
    readonly aggregateId: string,
  ) {}
}

Имя в прошедшем времени

R-EVT-2: OrderConfirmed, CustomerRegistered, PaymentProcessed. Не ConfirmOrder (это команда), не OrderConfirmationEvent (тавтология).

// core/order/event/order-confirmed.ts
import { v7 as uuidv7 } from 'uuid';
import { DomainEvent } from '../../shared/building-blocks';
import { OrderId, CustomerId } from '../value-object/ids';
import { Money } from '../value-object/money';

export class OrderConfirmed extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    readonly orderId: OrderId,
    readonly customerId: CustomerId,
    readonly total: Money,
  ) {
    super(eventId, occurredAt, orderId);
    Object.freeze(this);
  }
}

R-EVT-3: Object.freeze(this) в конструкторе. Все поля — readonly. После создания событие не меняется.

Почему категорически: событие — факт в прошлом. Изменить факт можно только новым фактом (OrderTotalCorrected). Подписчик доверяет данным события; если они могли измениться по дороге — доверие теряется.

В полях — только примитивы и VO

R-EVT-4, R-EVT-X2:

// ПЛОХО — ссылка на агрегат
export class OrderConfirmed extends DomainEvent {
  constructor(eventId: string, occurredAt: Date, readonly order: Order) {
    super(eventId, occurredAt, order.id);
    Object.freeze(this);
  }
}

// ХОРОШО — только ID и плоские значения
export class OrderConfirmed extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    readonly orderId: OrderId,
    readonly customerId: CustomerId,
    readonly total: Money,
  ) {
    super(eventId, occurredAt, orderId);
    Object.freeze(this);
  }
}

Что не так со ссылкой на агрегат:

  • К моменту обработки события Order уже изменился — нарушается «факт в прошлом».
  • Сериализация в Outbox / Kafka: Order несёт десятки полей, мутабельное внутреннее состояние, циклы ссылок. Плоский payload — стабильный контракт.

Что включаем в событие: ID агрегата, ключевые значения на момент события, технические метаданные. Что не включаем: всё остальное состояние агрегата.

Ещё примеры событий

// core/order/event/order-cancelled.ts
export class OrderCancelled extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    readonly orderId: OrderId,
    readonly reason: string,
  ) {
    super(eventId, occurredAt, orderId);
    Object.freeze(this);
  }
}

// core/customer/event/customer-registered.ts
export class CustomerRegistered extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    readonly customerId: CustomerId,
    readonly email: string,
  ) {
    super(eventId, occurredAt, customerId);
    Object.freeze(this);
  }
}

// core/product/event/product-price-changed.ts
export class ProductPriceChanged extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    readonly productId: ProductId,
    readonly oldPrice: Money,
    readonly newPrice: Money,
  ) {
    super(eventId, occurredAt, productId);
    Object.freeze(this);
  }
}

Публикация: registerEvent → save → pullEvents → Outbox

R-EVT-5: события собираются в агрегате, публикуются через Outbox после repository.save(), затем pullEvents() очищает список.

// core/order/usecase/command/confirm-order.handler.ts
import { Injectable } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { ORDER_REPOSITORY, OrderRepository } from '../../port/order-repository';
import { OUTBOX_PUBLISHER, OutboxPublisher } from '../../../shared/port/outbox-publisher';
import { ConfirmOrder } from './confirm-order';
import { OrderNotFoundError } from '../../domain-error';

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

  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());              // ← registerEvent внутри корня

    await this.orderRepository.save(order); // ← сохранить агрегат

    const events = order.pullEvents();      // ← забрать и очистить список
    await this.outboxPublisher.publishAll(events); // ← в Outbox (в той же транзакции или сразу после)
  }
}

Почему pullEvents() после save: если транзакция упала — список событий на агрегате остаётся нетронутым, повторный save снова попробует их опубликовать.

R-EVT-X4: EventEmitter2-listener, который отрабатывает асинхронно после ответа контроллера — опасен для критичных эффектов. Если NestJS-процесс упадёт между HTTP-ответом и выполнением listener'а, событие потеряется. Для денег, остатков, критичных интеграций — Outbox в той же TypeORM-транзакции (cross-ref R-TYPEORM-TX-*).

R-EVT-X3: событие не публикуется из контроллера и не создаётся в Handler-е напрямую. Контроллер → Handler → order.confirm()order.registerEvent(new OrderConfirmed(...)) — только так.

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

АнтипаттернПравилоЧто взамен
Мутабельные поля события (без readonly, без freeze)R-EVT-X1readonly поля + Object.freeze(this) в конструкторе
Ссылка на агрегат или Entity в событииR-EVT-X2Только ID и плоские VO / примитивы
Публикация из контроллера или создание события в HandlerR-EVT-X3this.registerEvent(...) только в методе корня
EventEmitter2-listener для критичных эффектовR-EVT-X4Outbox pattern в той же транзакции
Имя события — императив (ConfirmOrder, CreateCustomer)R-EVT-2Прошедшее время: OrderConfirmed, CustomerCreated

Куда дальше

  • DDD Tactical → раздел 4. Domain Event — нормативные формулировки R-EVT-*.
  • node/aggregate-root.md — где registerEvent вызывается и почему только в корне.
  • node/repository.md — save + pullEvents на границе транзакции.
  • node/module-structure.md — папка event/ в core/<bc>/.