LESSON 40分

ストーリー

高橋アーキテクト
Bounded Context間の連携にはドメインイベントが重要だと言ったね
高橋アーキテクト
ドメインイベントは”ドメイン内で起きた重要な出来事”を表すオブジェクトだ。そして、ドメインイベントを使ってシステムを設計する手法が”イベントストーミング”だ。今日はこの2つを学ぼう

ドメインイベントとは

ドメインイベントは、ドメイン内で発生した重要な出来事を表現するオブジェクトです。過去形で命名するのが慣例です。

// ドメインイベントの基底
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分