目次を表示する

戦術的DDD実践ガイド 2026

Domain Events ── 疎結合の光と影

本章の方針

Domain Events(ドメインイベント)は、集約間の結合を疎にする 強力な武器だ。同時に、運用観測性(observability)が追いつかないと、すぐに「何が起きているか分からないシステム」 を生む諸刃の剣でもある。本章は、Domain Events の3つの配信モード、実装パターン、そして陥りがちな落とし穴を扱う。


Domain Events とは何か

Domain Event の定義は単純だ。

ドメインイベント = ドメイン内で「起きた事実」を表す不変オブジェクト

重要なのは 「起きた事実」 という点だ。upgradeSubscription() のような命令(コマンド)ではなく、SubscriptionUpgraded(契約がアップグレードされた)のような 完了した事実 を表す。

SaaS契約管理での例を見る。

// [Domain Event] SubscriptionUpgraded
class SubscriptionUpgraded {
  readonly type = 'SubscriptionUpgraded' as const
  readonly occurredAt: Date

  constructor(
    readonly subscriptionId: SubscriptionId,
    readonly customerId: CustomerId,
    readonly previousPlanId: PlanId,
    readonly newPlanId: PlanId,
    readonly effectiveFrom: Date,
  ) {
    this.occurredAt = new Date()
  }
}

イベントは 過去形で命名 する。UpgradeSubscription ではなく SubscriptionUpgraded。「アップグレードせよ」ではなく「アップグレードされた」だ。


集約がイベントを発火する

イベントは集約(Entity)から発火される。

// [集約] Subscription が イベントを蓄積
class Subscription {
  private _events: DomainEvent[] = []

  upgrade(newPlan: Plan): void {
    if (this._status !== 'active') throw new Error('Inactive')

    const previousPlanId = this._planId
    this._planId = newPlan.id

    // ← イベントを発火
    this._events.push(new SubscriptionUpgraded(
      this.id,
      this.customerId,
      previousPlanId,
      newPlan.id,
      new Date(),
    ))
  }

  // 発火したイベントを取り出す(Application Serviceが取り出して配信する)
  pullEvents(): DomainEvent[] {
    const events = [...this._events]
    this._events = []
    return events
  }
}

3つの配信モード

Domain Events の配信方法は3種類ある。プロジェクトの要件で選ぶ。

モード1:同期(In-Process Event Bus)

同一プロセス内で、保存後に同期的にハンドラを呼ぶ

class UpgradeSubscriptionHandler {
  async handle(command: UpgradeSubscriptionCommand): Promise<void> {
    await this.unitOfWork.transaction(async () => {
      const subscription = await this.repo.findById(command.subscriptionId)
      subscription.upgrade(newPlan)
      await this.repo.save(subscription)

      // ← 同一トランザクション内で同期配信
      for (const event of subscription.pullEvents()) {
        await this.eventBus.dispatchSync(event)
      }
    })
  }
}
メリット:実装が単純、即座に結果が伝搬、強整合性
デメリット:ハンドラが失敗するとコマンドが失敗、疎結合性が限定的、外部サービス連携に弱い

モード2:Outbox パターン(最も推奨される)

イベントを DBのouboxテーブルに保存 し、別のワーカーが非同期に配信する。

class UpgradeSubscriptionHandler {
  async handle(command: UpgradeSubscriptionCommand): Promise<void> {
    await this.unitOfWork.transaction(async () => {
      const subscription = await this.repo.findById(command.subscriptionId)
      subscription.upgrade(newPlan)
      await this.repo.save(subscription)

      // ← 同じトランザクションで outbox に書く
      for (const event of subscription.pullEvents()) {
        await this.outbox.append(event)
      }
    })
    // トランザクション完了後、別ワーカーが outbox → Kafka/SQS に配信
  }
}
sequenceDiagram
  participant C as Client
  participant AS as Application Service
  participant DB as DB (Subscription + outbox)
  participant W as Outbox Worker
  participant MQ as Message Broker
  participant H as Event Handler

  C->>AS: upgrade command
  AS->>DB: BEGIN TX
  AS->>DB: UPDATE subscriptions
  AS->>DB: INSERT INTO outbox
  AS->>DB: COMMIT
  AS->>C: OK
  loop 非同期配信
    W->>DB: SELECT FROM outbox WHERE published = false
    W->>MQ: publish event
    MQ->>H: deliver
    W->>DB: UPDATE outbox SET published = true
  end
メリット:強整合性(DBコミットとイベント登録が同一トランザクション)
        配信は非同期で疎結合
        At-least-once 配信が保証される
デメリット:ouboxテーブルの運用、冪等性設計、配信遅延の受容が必要

2026年時点、分散環境でのDomain Events配信は Outbox が事実上の標準 だ。

モード3:イベントソーシング(Event Sourcing)

イベント自体を永続化の真実とする。集約の状態はイベントの再生で復元する。

// Subscriptionの状態はイベント列で表現
const events = [
  new SubscriptionStartedTrial(...),
  new SubscriptionActivated(...),
  new SubscriptionUpgraded(...),
]

// 現在状態はイベントを畳み込んで復元
const subscription = events.reduce(
  (state, event) => state.apply(event),
  Subscription.empty(),
)
メリット:完全な監査ログ、過去状態の再現、DCBと相性抜群
デメリット:学習コスト高、スキーマ変更(イベントバージョニング)が複雑、
         全クエリに読み取りモデル投影が必要

Event Sourcing は強力だが、プロジェクト全体を飲み込むスタイルの変化 だ。「このシステムはEvent Sourcingで構築する」という初期判断が必要になる。後から部分的に導入するのは難しい。


Outbox パターンの実装(2026年版)

Outbox の具体実装を示す。

// outboxテーブルのスキーマ
/*
CREATE TABLE outbox (
  id UUID PRIMARY KEY,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  published_at TIMESTAMPTZ,
  attempts INT DEFAULT 0
);
CREATE INDEX ON outbox (published_at) WHERE published_at IS NULL;
*/

class OutboxWorker {
  constructor(
    private readonly db: DatabaseClient,
    private readonly broker: MessageBroker,
  ) {}

  async run(): Promise<void> {
    while (true) {
      const batch = await this.db.query(
        `SELECT * FROM outbox
         WHERE published_at IS NULL
         ORDER BY occurred_at
         LIMIT 100
         FOR UPDATE SKIP LOCKED`,
      )

      for (const row of batch) {
        try {
          await this.broker.publish(row.event_type, row.payload)
          await this.db.query(
            `UPDATE outbox SET published_at = NOW() WHERE id = $1`,
            [row.id],
          )
        } catch (e) {
          await this.db.query(
            `UPDATE outbox SET attempts = attempts + 1 WHERE id = $1`,
            [row.id],
          )
        }
      }

      await sleep(1000)
    }
  }
}

実装上のポイントは4つ。

  1. FOR UPDATE SKIP LOCKED:複数ワーカーの並行動作を許容
  2. published_at IS NULL にインデックス:未配信イベントの高速検索
  3. At-least-once を前提に設計:イベント消費側は冪等にする
  4. 再試行時の指数バックオフ:失敗イベントが詰まらないように

2026年時点では Debezium のようなCDC(Change Data Capture)ツールを使って、outbox テーブルから Kafka への配信を自動化 するのが一般的だ。


イベント消費側の冪等性

Domain Events を At-least-once で配信する場合、消費側は必ず冪等(idempotent) でなければならない。同じイベントを2回受け取っても、結果は1回目と同じでなければならない。

// [Handler] 請求書発行
class IssueInvoiceHandler {
  constructor(
    private readonly invoiceRepo: InvoiceRepository,
    private readonly processedEvents: ProcessedEventStore,
  ) {}

  async handle(event: SubscriptionUpgraded): Promise<void> {
    // 冪等性チェック:同じイベントIDを処理済みなら無視
    const processed = await this.processedEvents.has(event.eventId)
    if (processed) return

    // 業務処理
    const invoice = Invoice.createForUpgrade(
      event.customerId,
      event.previousPlanId,
      event.newPlanId,
      event.effectiveFrom,
    )
    await this.invoiceRepo.save(invoice)

    // 処理済みマーク
    await this.processedEvents.mark(event.eventId)
  }
}

冪等性の設計を忘れると、Domain Eventsの導入は逆に障害を増やす。同じイベントの重複配信で、請求書が二重発行される、メールが二回飛ぶ、といった事故が起きる。


Domain Events の肯定的な体験

体験1:通知チームとの分離が楽になった

かつては「契約がアップグレードされたらメールを送る」ロジックが、契約管理コンテキストのコードに埋め込まれていた。SubscriptionUpgraded イベントを発火して通知コンテキストが受信する形に変えた結果、通知チームが独立してデプロイできる ようになった。

体験2:監査ログが副作用として得られた

Outbox テーブルは 事実上の監査ログ として機能する。「いつ、誰が、何を変更したか」の問い合わせに対して、outbox テーブルを参照するだけで答えられる。別途監査ログを書く必要がなくなった のは大きな副産物だった。

体験3:機能追加の速度が上がった

「契約アップグレード時にSlack通知を追加したい」という要望に対し、既存コードを一切触らず、新しいハンドラを追加するだけで済む ようになった。オープン・クローズド原則がイベント駆動で自然に実現した。


Domain Events の否定的な体験

体験1:「何がいつ起きているか」が追えなくなった

イベントハンドラが10個を超えると、1つの契約アップグレードで何が起きるかが追えない 状態になった。新しく参画したメンバーが「アップグレード処理を読もう」としたとき、ファイル横断でハンドラを探し回ることになる。

対策:ドメインイベントごとに「誰が購読しているか」を README.md やドメインイベントカタログ に明記する運用を導入した。

体験2:デバッグが劇的に難しくなった

「なぜこの請求書が生成されたのか」を追うとき、同期的コードなら1ファイル読めば分かる。イベント駆動では、outbox → Kafka → Handler → Invoice という3ホップ を横断する必要がある。Distributed Tracing(OpenTelemetry)の導入 が事実上必須になった。

体験3:イベントのバージョニング

最初は SubscriptionUpgradednewPlanId しか持たせていなかった。半年後、effectiveFrom を追加する必要が出た。既存の consumer は古いスキーマを期待している。イベントスキーマの進化 は Event Sourcing でなくとも発生し、設計時に見落としていた領域だった。

イベントのバージョニングの3つのアプローチ:
  1. 後方互換フィールド追加のみ許可(破壊的変更は新イベント型)
  2. スキーマレジストリ(Avro/Protobuf)で管理
  3. Upcaster(古いイベントを新形式に変換する関数)を用意する

アンチパターン:イベント駆動の失敗

Domain Events で陥りがちな失敗を整理する(Ch.8でも扱う)。

アンチパターン1:Event-Carried State Transfer の乱用

// ❌ イベントに集約の全状態を詰め込む
class SubscriptionUpgraded {
  constructor(
    readonly subscription: SubscriptionSnapshot,  // ← 契約の全フィールド
    readonly customer: CustomerSnapshot,          // ← 顧客の全フィールド
    readonly previousPlan: PlanSnapshot,
    readonly newPlan: PlanSnapshot,
  ) {}
}

これは Event-Carried State Transfer(Martin Fowlerが命名)と呼ばれるパターンで、意図的に使う場合もあるが、安易に採用すると集約間の境界が崩壊 する。受信側が「相手側の集約の全てを知っている」状態になる。

原則:イベントは「何が起きたか」の 最小限の情報 を持つ。必要に応じて受信側が他の集約から情報を取得する。

アンチパターン2:イベント連鎖の暴走

「イベントAが来たらイベントBを発火」「イベントBが来たらイベントCを発火」…のような連鎖は、無限ループや予期せぬ副作用の温床 になる。

対策:イベント連鎖は2段までに制限する、ハンドラで新イベントを発火する前に「本当に必要か」を検討する。

アンチパターン3:ドメインイベントと通信イベントの混同

ドメインイベント:ドメイン内で「起きた事実」(例:SubscriptionUpgraded)
通信イベント:システム間の通信用メッセージ(例:SendEmailRequest)

両者は用途が異なる。通信イベントをドメインイベントと同じチャネルに流すと、「ドメインの事実」と「システム間の依頼」が混ざる。別チャネルで扱う。


本章のまとめ

・Domain Events = ドメインで「起きた事実」を表す不変オブジェクト(過去形で命名)
・配信モードは3種類:同期/Outbox/Event Sourcing
・2026年の標準は Outbox パターン
・消費側の冪等性は必須
・観測性(Distributed Tracing・イベントカタログ)が追いついて初めて機能する
・イベントは最小限の情報を持つ。状態丸ごと渡さない

次章では、ここまで扱ってきた全パターンの失敗を アンチパターン・カタログ として症状・根本原因・脱出法の形式で整理する。