ストーリー
ドメインイベントとは
ドメインイベントは、ドメイン内で発生した重要な出来事を表現するオブジェクトです。過去形で命名するのが慣例です。
// ドメインイベントの基底
interface DomainEvent {
readonly eventId: string;
readonly occurredAt: Date;
readonly eventType: string;
}
// 「注文が作成された」イベント
class OrderCreatedEvent implements DomainEvent {
readonly eventType = 'order.created';
readonly eventId: string;
readonly occurredAt: Date;
constructor(
readonly orderId: string,
readonly customerId: string,
readonly items: Array<{ productId: string; quantity: number }>,
readonly totalAmount: number
) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date();
}
}
// 「注文がキャンセルされた」イベント
class OrderCancelledEvent implements DomainEvent {
readonly eventType = 'order.cancelled';
readonly eventId: string;
readonly occurredAt: Date;
constructor(
readonly orderId: string,
readonly reason: string
) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date();
}
}
// 「決済が完了した」イベント
class PaymentCompletedEvent implements DomainEvent {
readonly eventType = 'payment.completed';
readonly eventId: string;
readonly occurredAt: Date;
constructor(
readonly orderId: string,
readonly paymentId: string,
readonly amount: number
) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date();
}
}
ドメインイベントの発行
Entityの状態変更時にイベントを発行します。
class Order {
private _domainEvents: DomainEvent[] = [];
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('保留中の注文のみ確認できます');
}
this._status = OrderStatus.CONFIRMED;
// ドメインイベントを記録
this._domainEvents.push(
new OrderConfirmedEvent(this._id.value, this._customerId)
);
}
cancel(reason: string): void {
if (this._status === OrderStatus.SHIPPED) {
throw new Error('出荷済みの注文はキャンセルできません');
}
this._status = OrderStatus.CANCELLED;
this._domainEvents.push(
new OrderCancelledEvent(this._id.value, reason)
);
}
// Use CaseがイベントをEntityから取り出して発行する
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
イベントハンドラ
発行されたイベントを処理するハンドラを定義します。
// イベント発行のインターフェース
interface DomainEventPublisher {
publish(event: DomainEvent): Promise<void>;
publishAll(events: DomainEvent[]): Promise<void>;
}
// Use Case内でイベントを発行
class ConfirmOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private eventPublisher: DomainEventPublisher
) {}
async execute(input: ConfirmOrderInput): Promise<void> {
const order = await this.orderRepo.findById(
OrderId.fromString(input.orderId)
);
if (!order) throw new NotFoundError('注文が見つかりません');
order.confirm();
await this.orderRepo.save(order);
// Entityから蓄積されたイベントを取り出して発行
const events = order.pullDomainEvents();
await this.eventPublisher.publishAll(events);
}
}
// 在庫BCのイベントハンドラ
class OrderConfirmedHandler {
constructor(private stockService: StockReservationService) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
// 注文が確認されたら在庫を引き当てる
for (const item of event.items) {
await this.stockService.reserve(item.productId, item.quantity);
}
}
}
// 通知のイベントハンドラ
class OrderConfirmedNotificationHandler {
constructor(private notifier: NotificationSender) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
await this.notifier.sendOrderConfirmation(event.orderId);
}
}
イベントストーミング
イベントストーミングは、ドメインイベントを起点にシステムを設計するワークショップ手法です。Alberto Brandinoliが考案しました。
手順
Step 1: ドメインイベントを洗い出す(オレンジ付箋)
─────────────────────────────────────────────
[注文が作成された] [決済が完了した] [商品が出荷された]
[在庫が引き当てられた] [注文がキャンセルされた] [返品が受付された]
Step 2: コマンド(トリガー)を特定する(青い付箋)
─────────────────────────────────────────────
[注文を作成する] → [注文が作成された]
[決済を実行する] → [決済が完了した]
[出荷を指示する] → [商品が出荷された]
Step 3: アクター(誰がトリガーするか)を特定する(黄色い付箋)
─────────────────────────────────────────────
[顧客] → [注文を作成する] → [注文が作成された]
[システム] → [決済を実行する] → [決済が完了した]
[倉庫スタッフ] → [出荷を指示する] → [商品が出荷された]
Step 4: 集約を特定する(大きな黄色い付箋)
─────────────────────────────────────────────
<Order>: [注文を作成する][注文をキャンセルする] → [注文が作成された][注文がキャンセルされた]
<Payment>: [決済を実行する][返金する] → [決済が完了した][返金された]
<Shipment>: [出荷を指示する] → [商品が出荷された]
Step 5: Bounded Contextを発見する
─────────────────────────────────────────────
┌─注文BC──────┐ ┌─決済BC─────┐ ┌─配送BC──────┐
│ <Order> │ │ <Payment> │ │ <Shipment> │
│ 注文作成 │ │ 決済実行 │ │ 出荷指示 │
│ 注文キャンセル│ │ 返金 │ │ 配達完了 │
└─────────────┘ └────────────┘ └─────────────┘
イベントストーミングの成果物
| 成果物 | 説明 |
|---|---|
| ドメインイベント一覧 | システムで発生する重要な出来事 |
| コマンド一覧 | イベントを発生させる操作 |
| アクター一覧 | コマンドを実行する人やシステム |
| 集約一覧 | データの一貫性境界 |
| Bounded Context | サービスの境界 |
| ポリシー | イベント間の因果関係 |
ポリシー(イベント間の因果関係)
[注文が作成された]
↓ ポリシー: 「注文が作成されたら決済を実行する」
[決済を実行する] → [決済が完了した]
↓ ポリシー: 「決済が完了したら在庫を引き当てる」
[在庫を引き当てる] → [在庫が引き当てられた]
↓ ポリシー: 「在庫が引き当てられたら出荷を指示する」
[出荷を指示する] → [商品が出荷された]
// ポリシーはイベントハンドラとして実装される
class PaymentPolicy {
constructor(private processPayment: ProcessPaymentUseCase) {}
// 「注文が作成されたら決済を実行する」
async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.processPayment.execute({
orderId: event.orderId,
amount: event.totalAmount,
});
}
}
まとめ
| ポイント | 内容 |
|---|---|
| ドメインイベント | ドメイン内で起きた重要な出来事(過去形で命名) |
| イベント発行 | Entityが記録し、Use Caseが取り出して発行 |
| イベントハンドラ | イベントに反応して処理を実行 |
| イベントストーミング | イベント起点のワークショップ設計手法 |
| ポリシー | イベント間の因果関係(ハンドラとして実装) |
チェックリスト
- ドメインイベントの命名規則(過去形)を理解した
- Entityからイベントを発行する仕組みを理解した
- イベントストーミングの5つの手順を把握した
- ポリシーの概念とハンドラとしての実装を理解した
次のステップへ
次は「コンテキストマップとチーム境界」を学びます。Bounded Context間の関係パターンと、組織構造との対応を理解しましょう。
推定読了時間: 40分