アンチパターン集 ── 症状・根本原因・脱出法
ベストプラクティスを「正しい使い方」だとすると、アンチパターンは「実装者が陥りがちな罠」だ。Event Sourcing は 概念は単純だが、CRUD のメンタルモデルを引きずると間違う ──そんな性質を持つ。
この章では、現場でよく見る 8つのアンチパターン を、すべて同じ形式で解説する。
症状 :どんな問題が観測されるか
根本原因 :なぜそれが起きるか
脱出法 :どう直すか(コード付き)
最後に、判別の早見表を置く。
AP1:「Updated」イベント地獄
症状
イベントの型名が XxxUpdated ばかりになる。UserUpdated・OrderUpdated・ScreeningProcessUpdated。ペイロードには変更後のフィールドが入っているが、「何が変わったか・なぜ変わったか」がコードレベルで判別できない。
// よく見る悪例
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 | 巨大イベント | ストレージ肥大、読み込み遅い | 防御的設計 |
| AP4 | Snapshot 不在 | 長寿命集約の復元が遅い | 運用機構未整備 |
| AP5 | 書き込み直後の整合性誤解 | UI 上で変更が消えて見える | CQRS 結果整合性の理解不足 |
| AP6 | 全集約に ES 適用 | 機能追加速度が落ちる | 「銀の弾丸」志向 |
| AP7 | スキーマ変更フリーズ | 技術的負債蓄積 | Upcaster 未整備 |
| AP8 | Projection に業務ロジック | バグ・ロジック二重化 | 責務分離曖昧 |
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 を据え直す。