ベストプラクティス集 ── イベント設計から運用まで
Event Sourcing は「動き始める」のは比較的簡単だ。難しいのは 「3年後・5年後も健全に動き続けさせる」 ことだ。イベントの設計判断、スキーマ進化、運用、テスト ── これらが噛み合わないと、半年もすれば「触りたくないシステム」が完成する。
この章では、現場で効くベストプラクティスを 10個 に分けて整理する。それぞれに「なぜこの実践が必要か」と「どう適用するか」を示す。
BP1:イベントは過去形で命名する
なぜ
イベントは「すでに起きた事実」を表す。命令形(ScheduleFirstInterview)はコマンドであり、イベントとは責務が異なる。命名で混乱すると、コードレベルで両者を混同するバグに直結する。
どう適用するか
// ❌ 悪い例
class ScheduleFirstInterview { /* これは Command */ }
// ✅ 良い例
class FirstInterviewScheduled { /* Event:すでにスケジュールされた事実 */ }
// 命名の対応関係:
// Command(命令形) Event(過去形・受動)
// ─────────────────────────────────────
// AdvanceScreening ScreeningAdvanced
// ScheduleInterview InterviewScheduled
// CancelOffer OfferCancelled
// ApproveCandidate CandidateApproved
過去形にすることで、コードを読む人は 「この瞬間に起きたこと」 として自然に解釈できる。
BP2:イベント名はドメイン語彙で書く
なぜ
UserUpdated・DataChanged・StatusModified のような技術用語は、何が業務上起きたかを伝えない。後から「なぜこのイベントが発行されたのか」を追跡するときに、ペイロードを覗かないと分からなくなる。
どう適用するか
// ❌ 悪い例:技術用語、意図が読み取れない
class ScreeningProcessUpdated { /* 何が変わったのか分からない */ }
class UserStatusChanged { /* どう変わったかも分からない */ }
class DataModified { /* 業務的に何の意味もない */ }
// ✅ 良い例:ドメイン語彙、意図が読み取れる
class FirstInterviewCancelledByCandidate // 候補者都合のキャンセル
class FirstInterviewCancelledByCompany // 会社都合のキャンセル
class CandidateWithdrew // 辞退
class InterviewerReassigned // 面接官の差し替え
class ScreeningStageRolledBack // ステージを戻した
「なぜ起きたか」を型で区別する。同じ「ステージが変わった」でも理由が違うなら、別のイベントにする。
BP3:イベントの粒度は「業務の意思決定の単位」に合わせる
なぜ
イベントは粗すぎても細かすぎても問題になる。
粗すぎる(CRUD 的):
ScreeningProcessUpdated(中身に多数のフィールド)
→ 「何が変わったか」が分からず、後から見ても役に立たない
細かすぎる:
StageFieldChanged, UpdatedAtFieldChanged, ...
→ 業務上の意味のない「フィールド単位の変更通知」が量産される
どう適用するか
イベントの粒度は 「業務上1つの意思決定として扱う単位」 に合わせる。
// 例:「一次面接を実施した」という1つの意思決定
class FirstInterviewCompleted {
constructor(
readonly aggregateId: string,
readonly occurredAt: Date,
readonly interviewerId: string,
readonly result: 'passed' | 'rejected' | 'pending',
readonly notes: string,
readonly nextActionScheduledFor?: Date, // 次のアクション予定
) {}
}
これは「面接結果を記録する」という1つの業務行動なので、InterviewResultRecorded・InterviewerSelected・NextActionScheduled のような3つのイベントに分割する必要はない。
判断基準:
1つのイベントにまとめてよい:
- 業務上1つの意思決定として扱われる
- 後から「片方だけが起きた」状態が許されない(原子的)
- 同じ操作者が同じ瞬間に決めた事柄
別のイベントに分けるべき:
- 業務上、別の意思決定として扱われる
- 「片方だけ」の起きる可能性がある
- 別の権限者・別のタイミングで決まる
BP4:イベントには「不変な事実」だけを入れる
なぜ
イベントは過去の事実なので、そのイベントの瞬間に確定していなかった情報 を含めるべきではない。
// ❌ 悪い例:派生情報・外部依存情報を含めている
class FirstInterviewScheduled {
constructor(
readonly currentStage: string, // 派生情報(イベント列から計算可能)
readonly candidateName: string, // 外部集約からの参照(変わりうる)
readonly companyName: string, // 同上
readonly weatherForecast: string, // ?? 何の意味があるのか
) {}
}
どう適用するか
// ✅ 良い例:イベント時点で確定した、業務的に意味のある事実だけ
class FirstInterviewScheduled {
constructor(
readonly aggregateId: string,
readonly occurredAt: Date,
readonly scheduledFor: Date,
readonly interviewerId: string, // 誰がやるか(その時点で決定)
readonly venue: 'online' | 'onsite',
readonly meetingUrl?: string, // online の場合
readonly officeAddress?: string, // onsite の場合
) {}
}
「候補者の名前は知りたい」と感じたら、それは Read Model の責務だ。イベントには候補者 ID だけ含め、Projection で名前を解決する。
BP5:スキーマ進化に Versioning と Upcaster で備える
なぜ
Event Sourcing 最大の運用課題は 「イベントのスキーマが時間とともに変わる」 ことだ。Event Store は append-only なので、過去のイベントを書き換えることはできない。3年前に書き込んだ FirstInterviewScheduled が、当時のスキーマのまま残っている。
時系列でのスキーマ変化:
v1(2023年):{ scheduledFor, interviewerId }
v2(2024年):{ scheduledFor, interviewerId, venue } ← venue 追加
v3(2025年):{ scheduledFor, interviewerIds: [], venue, meetingUrl } ← interviewer 複数化
どう適用するか
選択肢は3つある。
graph LR
subgraph store["Event Store(追記専用)"]
V1["v1 (2023)<br/>{ scheduledFor,<br/> interviewerId }"]
V2["v2 (2024)<br/>{ ..., venue }"]
V3["v3 (2025)<br/>{ ..., interviewerIds[] }"]
end
V1 --> UC["Upcaster<br/>v1→v2: venue='onsite' を補完<br/>v2→v3: 単数→複数化"]
V2 --> UC
V3 --> UC
UC --> Latest["最新スキーマの<br/>イベントオブジェクト"]
Latest --> App["アプリケーション"]
style store fill:#fff3e0,stroke:#e65100
style UC fill:#e3f2fd,stroke:#1565c0
style Latest fill:#e8f5e9,stroke:#2e7d32
選択肢A:Upcaster(推奨)
イベントを読み出す時点で、古いスキーマを最新スキーマへ「変換」する。
class FirstInterviewScheduledUpcaster {
upcast(rawEvent: RawEvent): FirstInterviewScheduled {
const v = rawEvent.schemaVersion ?? 1
let payload = rawEvent.payload
if (v < 2) {
payload = { ...payload, venue: 'onsite' } // v1→v2: デフォルト値を補完
}
if (v < 3) {
// v2→v3: 単数を複数化、古いフィールドは新オブジェクトから外す
const { interviewerId, ...rest } = payload
payload = { ...rest, interviewerIds: [interviewerId] }
}
return new FirstInterviewScheduled(payload)
}
}
利点:
- 過去のイベントを書き換えない(Append-only を破らない)
- 古いイベントも最新コードで自然に扱える
欠点:
- Upcaster のチェーンが長くなりがち
- 変換ロジックの保守が必要
選択肢B:Lazy Migration(特定条件で書き換える)
集約を読み出すたびに、古いイベントを新スキーマで書き直す
…が、Event Sourcing の原則(Append-only)と衝突する
→ 通常は採用しない。Upcaster で十分。
選択肢C:Copy-and-Replace(最終手段)
破壊的変更が避けられないとき、新しい Stream に再生成して移行する。
1. 旧 Stream のイベントを順に読む
2. Upcaster で新しいイベント型に変換
3. 新しい Stream に書き込む
4. アプリを切り替えて、新 Stream を参照する
5. 旧 Stream を archive する
Greg Young の有名な格言:「The biggest mistake people make in event sourcing is being afraid to start over.(一番の間違いは、やり直すことを恐れることだ)」── 健全に進化させ続けることが Event Sourcing の運用そのものだ。
命名規則のヒント
// イベントメタデータにバージョンを必ず含める
interface DomainEvent {
// ...
schemaVersion: number // 1, 2, 3, ...
}
// 大きな破壊的変更は型自体を変える
class FirstInterviewScheduledV2 {} // V1 とは別型として並走
BP6:Snapshot は「N イベントごと」が現実解
なぜ
ch04 で触れた通り、Snapshot は復元コストを下げる最適化だ。だが「いつ Snapshot を取るか」の戦略を決めかねる場面が多い。
どう適用するか
ほとんどのケースで、「N イベントごとに取る」が最も実装が単純で、十分に効く。
async save(process: ScreeningProcess): Promise<void> {
// ...イベント保存...
// 100イベントごとに Snapshot
if (process.version % 100 === 0) {
await this.snapshotStore.save(process.toSnapshot())
}
}
戦略の使い分け:
集約タイプ 推奨戦略
────────────────────────────────────────────────────
短命(数十イベントで完結) Snapshot 不要 or 完了時のみ
中規模(数百〜数千) Nイベントごと(N=100〜500)
長命(数万以上) 時間ベース or ビジネス節目
高頻度更新 時間ベース(毎時等)
重要:Snapshot は捨てて作り直せる前提で運用
Snapshot のスキーマが変わったら?
→ 全 Snapshot を削除し、次回読み出し時に Stream から再生成
Snapshot の値が壊れていたら?
→ 同じく削除して再生成
「Snapshot を upgrade する」よりも「捨てて作り直す」が圧倒的に楽
これが「Snapshot は派生物」という設計原則の実務的な意味だ。
BP7:書き込みの結果を外部に伝えるには Outbox パターン
なぜ
Event Sourcing で書き込みが終わった後、その情報を 他のサービス や 外部のメッセージブローカー(Kafka 等) に流したい場合、次の問題が発生する。
素朴な実装:
1. Event Store に書き込み
2. Kafka にイベントを publish
問題:
ステップ1とステップ2が原子的でない。
- 1が成功し、2でクラッシュ → イベントがロストする
- 1が成功、2も成功、しかし1のレスポンス受信前に切断 → 重複 publish
→ 「Two Generals' Problem」(合意の不可能性)
どう適用するか:Outbox パターン
書き込みと同じトランザクションで、「これから外部に流すべきイベント」を Outbox テーブルに記録 する。別プロセスがそれを読み取って外部に流す。
sequenceDiagram
participant App as アプリケーション
participant DB as DB(同一トランザクション)
participant ES as Event Store
participant OB as Outbox テーブル
participant Pub as Publisher Process
participant Kafka as Kafka
rect rgb(232, 244, 255)
Note over App,OB: ① 1つのトランザクション内で原子的に
App->>DB: BEGIN
App->>ES: append events
App->>OB: insert outbox row<br/>(status='pending')
App->>DB: COMMIT
end
Note over Pub: ② 別プロセスがOutboxを定期読み取り
Pub->>OB: SELECT WHERE status='pending'
OB-->>Pub: 未送信イベント
Pub->>Kafka: publish
Kafka-->>Pub: ack
Pub->>OB: UPDATE status='sent'
Note over App,Kafka: ロストなし・重複は受信側の冪等性で吸収
-- Outbox テーブル(Event Store と同じ DB に置く)
CREATE TABLE event_outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE, -- Event Store のイベント ID
payload JSONB NOT NULL,
destination TEXT NOT NULL, -- 'kafka:events.screening' 等
status TEXT NOT NULL DEFAULT 'pending', -- pending / sent / failed
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
sent_at TIMESTAMP
);
// 書き込み処理:1つのトランザクションで Event Store と Outbox に書く
await db.transaction(async (tx) => {
await eventStore.append(streamId, expectedVersion, events, tx)
for (const e of events) {
await tx.execute(
`INSERT INTO event_outbox (event_id, payload, destination)
VALUES ($1, $2, $3)`,
[e.eventId, JSON.stringify(e), 'kafka:events.screening']
)
}
})
// 別プロセスが Outbox をポーリングして外部に流す
async function publishOutbox() {
const pending = await db.execute(
`SELECT * FROM event_outbox WHERE status='pending' ORDER BY id LIMIT 100`
)
for (const row of pending) {
await kafka.publish(row.destination, row.payload)
await db.execute(
`UPDATE event_outbox SET status='sent', sent_at=NOW() WHERE id=$1`,
[row.id]
)
}
}
期待できる保証
- At-least-once delivery(少なくとも1回配送)
→ 受信側は冪等性を持つ必要がある
→ イベント ID で重複検出する
- 順序保証
→ Outbox を ID 順に処理すれば、原則として保たれる
→ ただし複数 Outbox プロセス並列の場合は注意
CDC(Change Data Capture)を使って Outbox テーブルの変更を直接 Kafka に流す Transactional Outbox + Debezium という構成も近年は一般的だ。
BP8:個人情報(PII)には Cryptographic Erasure
なぜ
Event Store は append-only なので、個別のイベントを削除できない。だが GDPR や個人情報保護法は「忘れられる権利(個人情報の削除請求)」を要求する。
ジレンマ:
- 法的要件:「この候補者の個人情報を全削除せよ」
- 技術原則:「イベントは不変、削除しない」
どう適用するか:Cryptographic Erasure(暗号鍵削除)
PII を暗号化して保存し、暗号鍵を別のストアで管理する。削除要求が来たら 鍵を削除 する。データそのものは残るが、鍵がなければ復号できないので、実質的に削除されたのと同じ状態になる。
graph TB
subgraph before["削除前:通常運用"]
ES1[("Event Store<br/>━━━━━<br/>暗号化済PII")]
KS1[("KMS(鍵ストア)<br/>━━━━━<br/>candidate-X の鍵")]
ES1 -->|読み出し| Dec1["✅ 復号可能"]
KS1 -.鍵を提供.-> Dec1
end
subgraph after["削除後:GDPR対応"]
ES2[("Event Store<br/>━━━━━<br/>暗号化済PII<br/>(物理的に残る)")]
KS2[("KMS(鍵ストア)<br/>━━━━━<br/>❌ 鍵を削除")]
ES2 -->|読み出し| Dec2["🔒 復号不可<br/>= 実質削除"]
KS2 -.鍵がない.-> Dec2
end
before -->|削除要求| after
style before fill:#e8f5e9,stroke:#2e7d32
style after fill:#fff3e0,stroke:#e65100
style Dec2 fill:#ffcdd2
「Event Store は append-only」と「個別データを削除可能」を両立させる、現実的な唯一の方法だ。
// 1. 候補者ごとに暗号鍵を生成して保管
class CandidateEncryptionKeyStore {
async getOrCreate(candidateId: string): Promise<CryptoKey> { /* ... */ }
async delete(candidateId: string): Promise<void> {
// GDPR 削除要求時にこれを呼ぶ
await this.kms.scheduleDeletion(candidateId)
}
}
// 2. イベント書き込み時、PII フィールドを暗号化
class FirstInterviewScheduled {
constructor(
readonly aggregateId: string,
readonly occurredAt: Date,
readonly scheduledFor: Date,
readonly encryptedInterviewerNote: EncryptedField, // ← 暗号化
) {}
}
async function writeEvent(event, candidateId) {
const key = await keyStore.getOrCreate(candidateId)
event.encryptedInterviewerNote = encrypt(plainNote, key)
await eventStore.append(...)
}
// 3. イベント読み込み時、復号を試みる
async function readEvent(rawEvent, candidateId) {
const key = await keyStore.get(candidateId)
if (!key) {
// 鍵がない = 削除されている
rawEvent.encryptedInterviewerNote = '<REDACTED>'
} else {
rawEvent.encryptedInterviewerNote = decrypt(rawEvent.encryptedInterviewerNote, key)
}
return rawEvent
}
注意点
1. 鍵管理は KMS(AWS KMS / GCP KMS / HashiCorp Vault)に任せる
自前のキーストアは絶対に作らない
2. PII でない情報(イベント発生時刻、ステージ等)は暗号化しない
集計や分析に必要な情報まで暗号化すると Read Model が壊れる
3. Snapshot にも同じ仕組みを適用
Snapshot は派生物だが、PII を含むなら同様に暗号化が必要
4. 法律家と要件を擦り合わせる
「実質的削除」が法的に認められる範囲は地域や業界で異なる
BP9:テストは Given-When-Then で書く
なぜ
Event Sourcing のテストは、「過去のイベント列を Given として与え、コマンドを When で実行し、新しく発行されるイベントを Then で検証する」 という構造に綺麗に乗る。
どう適用するか
describe('ScreeningProcess', () => {
it('一次面接後、二次面接にスケジュールできる', () => {
// Given:過去に起きたイベント列
const events = [
new ScreeningProcessStarted('sp-001', 'c-1', 'j-1', new Date('2026-01-01')),
new DocumentScreeningPassed('sp-001', new Date('2026-01-05')),
new FirstInterviewScheduled('sp-001', new Date('2026-01-10'), 'iv-1', 'online'),
new FirstInterviewCompleted('sp-001', new Date('2026-01-10'), 'iv-1', 'passed'),
]
const process = ScreeningProcess.reconstitute(events)
// When:コマンドを実行
process.scheduleSecondInterview(new Date('2026-01-20'), 'iv-2')
// Then:期待されるイベントが発行されたか
const newEvents = process.getUncommittedEvents()
expect(newEvents).toHaveLength(1)
expect(newEvents[0]).toBeInstanceOf(SecondInterviewScheduled)
expect(newEvents[0].scheduledFor).toEqual(new Date('2026-01-20'))
})
it('一次面接が完了していない状態では二次面接をスケジュールできない', () => {
// Given:一次面接が「予定済みだが未完了」の状態
const events = [
new ScreeningProcessStarted('sp-001', 'c-1', 'j-1', new Date('2026-01-01')),
new DocumentScreeningPassed('sp-001', new Date('2026-01-05')),
new FirstInterviewScheduled('sp-001', new Date('2026-01-10'), 'iv-1', 'online'),
// FirstInterviewCompleted はまだ起きていない
]
const process = ScreeningProcess.reconstitute(events)
// When + Then:例外が発生する
expect(() => {
process.scheduleSecondInterview(new Date('2026-01-20'), 'iv-2')
}).toThrow('First interview not completed')
})
})
この形のテストの強み
1. テストの可読性が高い
「この状況で、これをすると、これが起きる」が一目で分かる
2. ドメイン専門家とのレビューが容易
業務ルールがそのままテストになる
3. テスト DB やインフラへの依存が少ない
純粋なドメインロジックのテストになる
4. リグレッション時の特定が容易
Given にイベントを増やすだけで境界条件のバリエーションが増やせる
BP10:Read Model は再構築できる前提で運用する
なぜ
ch04 で触れた通り、Read Model は派生物だ。現実の運用では次が頻繁に起きる。
- Read Model のスキーマを変更したい(カラム追加・型変更)
- Projection ロジックにバグがあり、Read Model が壊れている
- 新しい Read Model を追加したい(既存イベント全てを処理して構築)
どう適用するか
Read Model は 「Event Store から再構築可能な、派生ビュー」 という前提で設計する。
class ScreeningDashboardProjection {
async on(event: DomainEvent): Promise<void> { /* ... */ }
// 再構築用のメソッドを最初から用意しておく
async rebuild(fromVersion?: number): Promise<void> {
if (!fromVersion) {
await this.readDb.execute('TRUNCATE TABLE screening_views')
}
const stream = eventStore.readAllEvents({ fromVersion })
for await (const event of stream) {
await this.on(event)
}
}
}
運用パターン:「並走 + 切替」
新スキーマの Read Model に切り替えるとき、ダウンタイムなしで切り替えるパターン。
ステップ1:新 Read Model を別テーブルとして並走で作る
┌─ 旧 Read Model(screening_views_v1)── 古い Projection が更新
Stream
└─ 新 Read Model(screening_views_v2)── 新しい Projection が更新(再生中)
ステップ2:新 Read Model が現在に追いついたら、アプリを切り替える
ステップ3:旧 Read Model を削除
これが Event Sourcing で得られる 「Read 側を恐れずに変更できる」 という運用上の自由度だ。
ベストプラクティスのまとめ
mindmap
root((Event Sourcing<br/>10のBP))
イベント設計
BP1 過去形で命名
BP2 ドメイン語彙
BP3 業務意思決定の単位
BP4 不変な事実だけ
スキーマと永続化
BP5 Upcaster で進化
BP6 N件ごとSnapshot
システム連携
BP7 Outboxパターン
BP8 Cryptographic Erasure
開発と運用
BP9 Given-When-Then
BP10 Read Modelは再構築前提
これら10個は独立しているように見えて、実は 「イベントは不変、Read Model は派生物、書き込みと連携は分離」 という1つの原則の異なる側面だ。
逆に言うと、これらを破ると何が起きるか ── それを次章で見る。アンチパターン集 だ。
「症状・根本原因・脱出法」の3点セットで、現場でよく見る8つの典型的なアンチパターンを解説する。