Опирается на правила:
R-EVT-1…R-EVT-5иR-EVT-X1…R-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-X1 | readonly поля + Object.freeze(this) в конструкторе |
| Ссылка на агрегат или Entity в событии | R-EVT-X2 | Только ID и плоские VO / примитивы |
| Публикация из контроллера или создание события в Handler | R-EVT-X3 | this.registerEvent(...) только в методе корня |
EventEmitter2-listener для критичных эффектов | R-EVT-X4 | Outbox 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>/.