目次を表示する

Event Sourcing 深掘り ── 経緯・必須要素・デファクトツール・実践と落とし穴

アンチパターン集 ── 症状・根本原因・脱出法

アンチパターン集 ── 症状・根本原因・脱出法

ベストプラクティスを「正しい使い方」だとすると、アンチパターンは「実装者が陥りがちな罠」だ。Event Sourcing は 概念は単純だが、CRUD のメンタルモデルを引きずると間違う ──そんな性質を持つ。

この章では、現場でよく見る 8つのアンチパターン を、すべて同じ形式で解説する。

症状     :どんな問題が観測されるか
根本原因 :なぜそれが起きるか
脱出法   :どう直すか(コード付き)

最後に、判別の早見表を置く。


AP1:「Updated」イベント地獄

症状

イベントの型名が XxxUpdated ばかりになる。UserUpdatedOrderUpdatedScreeningProcessUpdated。ペイロードには変更後のフィールドが入っているが、「何が変わったか・なぜ変わったか」がコードレベルで判別できない

// よく見る悪例
class ScreeningProcessUpdated {
  constructor(
    readonly aggregateId: string,
    readonly fields: { [key: string]: any },  // 何でも入る
    readonly updatedBy: string,
  ) {}
}

根本原因

CRUD のメンタルモデルを引きずって、「UPDATE 文 = Updated イベント」と直訳してしまっている。Event Sourcing が応える「意図の保持」(ch03 の問題2)の利点が、完全に殺されている。

CRUD的発想:
  「ステータスフィールドが変わったので Updated イベントを発行する」

Event Sourcing 的発想:
  「候補者が辞退した → CandidateWithdrew」
  「会社都合で取り消した → ScreeningCancelledByCompany」
  「面接結果を反映した → FirstInterviewResultRecorded」

脱出法

イベントを 「業務上の意図」 で分類し直す。

// ❌ 悪い例
class ScreeningProcessUpdated {
  fields: { stage: 'rejected', reason: 'candidate_withdrew' }
}

// ✅ 良い例:意図ごとに型を分ける
class CandidateWithdrew { /* 候補者からの辞退 */ }
class ScreeningRejectedByCompany { /* 会社からの不採用通知 */ }
class ScreeningRolledBackByMistake { /* 操作ミスによる取り消し */ }

判断基準は ch06 の BP2 と同じ:「この変更は、業務上、別の意思決定として扱われるか?」── Yes なら別の型に分ける。


AP2:イベントが「コマンドの再来」になっている

症状

scheduleInterview メソッドを呼んだら ScheduleInterview イベントが発行される。命令形で命名されているか、{ command: "ScheduleInterview", payload: {...} } という構造になっている。

// よく見る悪例
class ScheduleInterview {  // 名前が命令形
  constructor(
    readonly action: 'schedule',
    readonly target: 'interview',
    readonly payload: { ... },
  ) {}
}

根本原因

イベントとコマンドの 責務分離 が腹落ちしていない。「この情報を保存しておけばいい」という発想で、コマンドをそのまま保存している。結果、後から見ると 「この時点で実際に起きたこと」が分からない。コマンドは拒否されることがあるが、イベントは「起きてしまった事実」だ。

脱出法

過去形で命名し、「結果として何が起きたか」を表す。

// ✅ 良い例
class FirstInterviewScheduled {  // 過去形
  constructor(
    readonly aggregateId: string,
    readonly occurredAt: Date,
    readonly scheduledFor: Date,
    readonly interviewerId: string,
  ) {}
}

ch06 BP1 を参照。


AP3:「巨大イベント」 ── ペイロードに何でも詰める

症状

イベントのペイロードに 集約のフルスナップショット が入っている。書き込みのたびに数百KB〜MBのデータが Event Store に積み上がる。

// 巨大イベントの例
class ScreeningProcessUpdated {
  readonly fullScreeningProcess: ScreeningProcess  // 中身が全部入っている
  readonly relatedCandidate: Candidate              // 関連集約まで含まれる
  readonly relatedJob: Job                          // 同上
  readonly auditMetadata: { ... 巨大 ... }
}

症状(運用面)

- Event Store のディスク使用量が指数関数的に増える
- Stream の読み込みが遅い
- ネットワーク帯域を食う
- スキーマ変更時の Upcaster が複雑になる

根本原因

「とりあえず後で必要になりそうな情報を全部入れておこう」という防御的設計。または、Event Sourcing と Snapshot の役割分担が曖昧になっている。

脱出法

1. イベントには「その瞬間に変わった事実」だけを入れる
    現在状態の全コピーは Snapshot の役割
2. 関連集約の情報は ID で参照する(埋め込まない)
    名前等は Read Model で解決する
3. 監査メタデータは別ストアに分離する選択肢もある
// ✅ 良い例:差分だけを記録
class FirstInterviewCompleted {
  constructor(
    readonly aggregateId: string,
    readonly occurredAt: Date,
    readonly interviewerId: string,        // ID 参照
    readonly result: 'passed' | 'rejected' | 'pending',
    readonly notes: string,
    // 候補者の名前や求人の詳細は含めない
  ) {}
}

AP4:Snapshot 不在で Replay 地獄

症状

長寿命の集約の復元が遅い。本番で「読み込みに30秒かかる集約」が出始める。タイムアウト・ユーザー体験の劣化に繋がる。

顧客口座集約:
  作成から3年経過、累計イベント数:23,000件
  毎回 23,000 件を再生 → 5〜30秒

根本原因

Snapshot を実装していないか、Snapshot を「最終状態のキャッシュ」として誤用 している(書き込み時に Read Model を更新する代わりに Snapshot を毎回更新する)。Event Sourcing を「概念だけ」導入して運用機構を整備していない。

脱出法

即時対応:Snapshot を導入する

async load(aggregateId: string): Promise<Aggregate> {
  const snapshot = await this.snapshotStore.loadLatest(aggregateId)
  const aggregate = snapshot
    ? Aggregate.fromSnapshot(snapshot)
    : Aggregate.empty(aggregateId)

  const streamId = `aggregate-${aggregateId}`
  const events = await this.eventStore.readStream(streamId, {
    fromVersion: snapshot ? snapshot.version + 1 : 1
  })
  for (const event of events) aggregate.apply(event)
  return aggregate
}

ロード処理の詳細は ch04(Snapshot 節)、戦略の選び方は ch06 BP6 を参照。

設計判断:そもそも集約を分割すべきでないか

長すぎる Stream は、集約境界の見直しが必要 という設計上のシグナルでもある。

例:1つの「顧客」集約に何でも入れている
  → 「顧客の連絡先」「顧客のサブスクリプション」「顧客の配送先」を別集約に分離
  → それぞれが短い Stream を持つ
  → Snapshot 不要レベルになる

AP5:書き込みと Read Model の整合性誤解

症状

書き込み直後にクエリすると、まだ Read Model に反映されていない。「保存ボタンを押した直後に画面を再読み込みすると、変更が消える」とユーザーから報告が上がる。

根本原因

Event Sourcing + CQRS では、Read Model は通常 非同期に更新 される。POST /screenings のレスポンスを返した時点では、GET /screenings の結果にまだ反映されていない。これを 「即時整合的な CRUD」と同じ感覚 で扱うと矛盾が起きる。

CRUD:
  POST /screenings → DB UPDATE → レスポンス
  GET /screenings  → 同じ DB から読む → 即整合

ES + CQRS:
  POST /screenings → Event Store 追記 → レスポンス

                    (非同期に)Projection → Read Model
  GET /screenings  → Read Model から読む → 一時的に未反映

脱出法

sequenceDiagram
    participant UI as UI/Client
    participant API as API Server
    participant ES as Event Store
    participant P as Projection
    participant RM as Read Model

    Note over UI,RM: ❌ 素朴な実装:書いた直後に読める前提

    UI->>API: POST /screenings (advance)
    API->>ES: append events
    ES-->>API: ✅ committed
    API-->>UI: 200 OK
    UI->>API: GET /screenings
    API->>RM: SELECT
    RM-->>API: 古いデータ(未反映)
    API-->>UI: ❌ 変更が消えて見える

    Note over UI,RM: 並行して...
    ES->>P: subscribe
    P->>RM: UPDATE
    Note over RM: ようやく反映(数ms〜数秒)

主な対処法は4つ:

対処A:UI 側で楽観的更新

書き込みが成功したら、サーバーレスポンスを待たずに UI 側で結果を反映 する(React の楽観的 UI 更新パターン)。

function onScheduleInterview() {
  // UI を即座に更新
  setLocalState(prev => ({ ...prev, stage: 'first_interview_scheduled' }))
  // バックグラウンドで POST
  api.scheduleInterview(...).catch(rollback)
}

対処B:書き込みレスポンスに最新の version を含める

// 書き込みレスポンスに「期待する Read Model のバージョン」を返す
{
  success: true,
  expectedReadModelVersion: 42,
}

// クライアントはこれを保持し、後続の GET で「v42 以降が反映されているか」を確認
// 反映されていない場合:少し待って再試行(poll)または WebSocket 通知を待つ

対処C:書き込み側から直接 Read Model を初期投影

書き込みトランザクション内で、最低限の Read Model 投影を同期的に実行する。Event Sourcing のスケーラビリティ利点とのトレードオフだが、UX 上の要件によっては妥当な妥協。

対処D:そもそも結果整合性を業務に説明する

「保存しました。一覧への反映には数秒かかることがあります」を UI に表示するだけで解決するケースも多い。


AP6:全集約に Event Sourcing を適用してしまう

症状

新規プロジェクトで「Event Sourcing を採用しよう」と決めた結果、マスタデータも参照データも設定値も全て Event Sourcing で実装する。チームの実装速度が落ち、シンプルな機能追加にも時間がかかる。

Event Sourcing で実装されているもの(現実にあるべきか?):
  - 国コードリスト(ほぼ変更されない)
  - フィーチャーフラグ(履歴は要らない)
  - システム設定(履歴より「現在値」が大事)
  - メールテンプレート(CRUD で十分)

根本原因

「全部 Event Sourcing にすれば一貫性がある」という素朴な美意識。Event Sourcing が 「履歴と意図が業務上重要なドメイン」に向いた設計 だという前提を見失っている(ch03 を参照)。

脱出法

集約ごとに採用判断を分ける。

判断フロー:
  この集約で「過去状態の再現」「変更理由の追跡」が業務上必要か?
    Yes → Event Sourcing
    No  → CRUD で十分

採用管理システムの例:
  Event Sourcing:
    - ScreeningProcess(選考プロセス)
    - InterviewRecord(面接記録)
    - JobApplication(応募)

  CRUD:
    - JobPosting(求人内容のマスタ)
    - User / Recruiter(ユーザー情報)
    - InterviewVenue(面接会場マスタ)
    - SystemSettings(設定値)

両者は 同じシステム内で共存できる。ベストな選択は「混在」であって「全部 Event Sourcing」ではない。Vernon が前作で繰り返し述べていた「すべての集約に適用すべきではない」がここに直結する。


AP7:イベントの破壊的変更を恐れてフリーズする

症状

イベントスキーマを変えたいが、「過去のイベントが読めなくなる」のが怖くて動けない。新機能のために本来必要な変更が後回しになり、技術的負債が積み上がる。コード上には「`// TODO: できればこのフィールドを使いたい」というコメントが増えていく。

根本原因

Event Sourcing のスキーマ進化戦略(ch06 BP5)が整備されていない。Upcaster や Copy-and-Replace の仕組みがない ため、変更コストが「全システム停止+一斉移行」と認識されている。

脱出法

短期:Upcaster を整備する

最初の Upcaster を1つ書くと、以降の変更は怖くなくなる。

class EventUpcasterRegistry {
  private upcasters = new Map<string, Upcaster[]>()

  register(eventType: string, upcaster: Upcaster) { /* ... */ }

  upcast(rawEvent: RawEvent): DomainEvent {
    const chain = this.upcasters.get(rawEvent.eventType) ?? []
    let event = rawEvent
    for (const u of chain) event = u.upcast(event)
    return event
  }
}

中期:イベントのバージョン番号をメタデータに必ず含める

これが入っていないと、Upcaster が機能しない。

interface DomainEvent {
  eventType: string
  schemaVersion: number  // ← これ
  // ...
}

長期:必要なら Copy-and-Replace を実行する

「Upcaster の鎖が長すぎて保守が辛い」状態になったら、Stream を再生成して新しい Stream に書き換える。

1. 旧 Stream のイベントを読む
2. Upcaster で最新スキーマに変換
3. 新 Stream に書き込む(旧スキーマのイベントは含まない)
4. アプリの参照先を新 Stream に切り替える
5. 旧 Stream を archive(または削除)

これは「リファクタリング」と同じ。怖いが、健全な進化に必要な手続き。

Greg Young の格言:「やり直すことを恐れるな」── Event Sourcing は「不変な過去」を持つが、「不変な設計」を持つわけではない。


AP8:Projection に業務ロジックを埋め込む

症状

Projection の中で、集約と同じバリデーションや判断ロジック を実装している。同じビジネスルールが2箇所に書かれ、変更時に片方だけ修正してバグになる。

// 悪例:Projection 内でバリデーション
class ScreeningDashboardProjection {
  async on(event: FirstInterviewScheduled): Promise<void> {
    // ❌ ここに「面接が二次面接にスキップしていないか」のチェックが入っている
    if (event.skippedSecondInterview && !this.isExceptionalCase(event)) {
      throw new Error('Cannot skip second interview')
    }
    // ↑ これは集約の責務であって、Projection の責務ではない
  }
}

根本原因

「集約 = 書き込み時の真実、Projection = 読み取り用の派生」という責務分離が曖昧になっている。集約はバリデーションを通したからこそイベントを発行している。Projection の時点では、そのイベントは「すでに起きた事実」として無条件に処理されるべきだ。

脱出法

集約(書き込み側)の責務:
  - コマンドを受ける
  - バリデーション・業務ルールチェック
  - イベントを発行する

Projection(読み取り側)の責務:
  - イベントを受け取る(無条件で)
  - Read Model に投影する
  - 一切のドメインロジックを書かない

「Projection で例外を投げるべきか?」── Projection の中で例外を投げる正当な理由は、外部依存の障害(Read DB に書けない等) だけだ。業務ロジックでの例外は集約に押し戻す。

// ✅ 良い例:Projection は素朴に投影するだけ
class ScreeningDashboardProjection {
  async on(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case 'FirstInterviewScheduled':
        await this.readDb.execute(
          `UPDATE screening_views SET stage = 'first_interview', ... WHERE id = ?`,
          [event.aggregateId]
        )
        break
      // 業務判断は一切しない
    }
  }
}

アンチパターン早見表

#アンチパターン主な症状根本原因
AP1「Updated」イベント地獄意図が型から読めないCRUD直訳
AP2コマンド形のイベント「実際に起きたこと」が不明責務分離未理解
AP3巨大イベントストレージ肥大、読み込み遅い防御的設計
AP4Snapshot 不在長寿命集約の復元が遅い運用機構未整備
AP5書き込み直後の整合性誤解UI 上で変更が消えて見えるCQRS 結果整合性の理解不足
AP6全集約に ES 適用機能追加速度が落ちる「銀の弾丸」志向
AP7スキーマ変更フリーズ技術的負債蓄積Upcaster 未整備
AP8Projection に業務ロジックバグ・ロジック二重化責務分離曖昧
mindmap
  root((8つの<br/>アンチパターン))
    CRUD直訳系
      AP1 Updated地獄
      AP2 コマンド形イベント
      AP6 全集約にES適用
    結果整合性誤解系
      AP5 書き込み直後の不整合
    設計過剰系
      AP3 巨大イベント
    運用機構不在系
      AP4 Snapshot不在
      AP7 スキーマ変更フリーズ
    責務分離曖昧系
      AP8 Projectionに業務ロジック

アンチパターンに気づくサイン

設計レビュー・コードレビュー時に 以下のサインがあれば AP のどれかに該当している可能性が高い ──というチェックリストを置いておく。

🚨 イベント名一覧に "Updated" が3つ以上ある
   → AP1 を疑う

🚨 イベントクラス名が動詞の命令形になっている
   → AP2 を疑う

🚨 1イベントのペイロードが10KB を超える
   → AP3 を疑う

🚨 集約の復元時間が 1 秒を超える
   → AP4 を疑う

🚨 「保存後すぐに反映されない」ユーザー報告がある
   → AP5 を疑う

🚨 マスタテーブルすら ES で実装している
   → AP6 を疑う

🚨 イベント型に「v2」などの添字がついて1年以上放置されている
   → AP7 を疑う

🚨 Projection クラスに throw new Error が業務ルール由来で書かれている
   → AP8 を疑う

なぜアンチパターンが生まれるのか

8つのアンチパターンを通じて見ると、根本原因は3つに集約される

graph LR
    R1["原因1<br/>CRUD のメンタルモデルを<br/>引きずっている"]
    R2["原因2<br/>運用機構を概念と一緒に<br/>整備していない"]
    R3["原因3<br/>防御的設計"]

    R1 --> AP1["AP1<br/>Updated地獄"]
    R1 --> AP2["AP2<br/>コマンド形"]
    R1 --> AP5["AP5<br/>結果整合性誤解"]
    R1 --> AP6["AP6<br/>全集約に適用"]

    R2 --> AP4["AP4<br/>Snapshot不在"]
    R2 --> AP7["AP7<br/>スキーマ変更フリーズ"]
    R2 --> AP8["AP8<br/>Projectionに業務ロジック"]

    R3 --> AP3["AP3<br/>巨大イベント"]

    style R1 fill:#ffe0b2,stroke:#e65100
    style R2 fill:#c5cae9,stroke:#3949ab
    style R3 fill:#f8bbd0,stroke:#c2185b

これらは 「Event Sourcing が難しい」のではなく、「Event Sourcing と CRUD の差を腹落ちさせる時間が足りていない」 という現象だ。チーム全員が「変化を記録する」発想に慣れるまでに3〜6ヶ月かかると ch03 で書いたが、アンチパターンの大半はその慣熟過程で発生する。

設計レビューで「これ、CRUD の発想じゃないか?」と指摘し合える文化が、最も効くアンチパターン対策だ。


最終章では、ここまでの全ての内容を統合する。Event Sourcing を採用すべきか の判断フローを整理し、DDD・CQRS・DCB との位置関係マップ上に Event Sourcing を据え直す。