event-sourcing について
イベントソーシングを意図的に採用するチーム向けのスキルです。適用判断から、ドメインロジックとインフラを分離したイベントストリームのモデリング、永続化、再生、進化までを扱います。
主な機能
- 01イベントソーシングの適用判断
- 02イベントとストリームのモデリング原則
- 03デシダーと状態再構築のパターン
- 04イベントストアポートと並行性制御
- 05プロジェクション、バージョニング、テストの指針
活用シーン
- 01境界づけられたコンテキストでの採用評価
- 02イベントストリームとコマンド処理の設計
- 03読み取りモデルの構築と再構築
- 04イベントスキーマの進化と本番運用の計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill event-sourcingインストール後、Claude Code で/event-sourcingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
resources/decider-and-rehydration.mdevent-store.mdevent-versioning.mdmodelling-events.mdproduction-concerns.mdprojections-and-read-models.mdreferences.mdtesting-event-sourced-systems.mdwhen-to-use-event-sourcing.md詳細
イベントソーシング
イベントソーシングでは、アプリケーションの状態に対する変更をすべて、不変で追記専用の過去形のドメインイベントのシーケンスとして保存します。現在の状態が直接保存されることはありません。イベントを左畳み込みした結果が現在の状態です:state = events.reduce(evolve, initialState)。イベントログが信頼できる唯一の情報源です。それ以外の表現(集約状態、読み取りモデル、検索インデックス)はすべて、イベントを再生して再構築できる派生物であり、いつでも破棄できます。
このスキルは、イベントソーシングを意図的に採用したプロジェクト、またはプロジェクト内の特定の境界づけられたコンテキストにのみ適用されます。これは複雑さの段階を上り詰めた先にあるものであり、デフォルトではありません。 まず「イベントソーシングを使うタイミング」を読んでください。ほとんどの機能は、明示的な戻り値、監査テーブル、またはアウトボックスパターンで十分です。
このスキルは3つのスキルを基盤としているため、あわせて読み込んでください:
domain-driven-design— Decider(decide/evolve/initialState)は、そのresources/domain-events.mdで定義されています。イベントソーシングは、同じデサイダーを永続化して再生します。このスキルでは、同リソースを読んでいることを前提とします。hexagonal-architecture— イベントストアはアダプターを持つ駆動ポートであり、ドメインは純粋な状態を保ちます。読み取りにはCQRS-liteの分割(resources/cqrs-lite.md)を使います。typescript-strict— イベントはストレージへの書き込み時とストレージからの読み込み時に信頼境界を越えるため、ブランド付き ID を用いたスキーマファーストな形式にします。
詳細リソースは resources/ にあります。必要に応じて読み込んでください:
| リソース | 読み込むタイミング |
|---|---|
when-to-use-event-sourcing.md | イベントソーシングを採用するか決めるとき。CQRS、イベント駆動アーキテクチャ、ストリーミング、監査ログと比較するとき |
modelling-events.md | イベントを見つけて命名するとき(EventStorming)、イベントの粒度を決めるとき、イベントに含めるデータを決めるとき |
decider-and-rehydration.md | decide/evolve のペア、再水和、コマンドハンドラーのループ、デサイダーの合成を実装するとき |
event-store.md | EventStore ポート、Postgres スキーマ、楽観的並行性制御、イベントエンベロープ、TS/Node ツールの状況を設計するとき |
projections-and-read-models.md | 読み取りモデル、インラインと非同期のプロジェクション、再構築、チェックポイント、結果整合性を扱うとき |
event-versioning.md | アップキャスティング、寛容なリーダー、弱いスキーマ、ストリームのコピーと変換などを通じて、イベントスキーマを進化させるとき |
testing-event-sourced-systems.md | 既存のテスト DSL を直接使うか、それを通じてデサイダー、プロジェクション、アップキャスターを振る舞いとしてテストするとき |
production-concerns.md | スナップショット、GDPR/暗号シュレッディング、冪等な配信、運用性、アンチパターンのカタログを扱うとき |
references.md | このガイダンスの根拠と一次資料を確認するとき |
このスキルに含まれる信頼できる情報源については、resources/references.md を参照してください。
イベントソーシングを使う場面
イベントソーシングは、変更の履歴そのものがドメインの一部である場合に限り、そのかなりの複雑さに見合います。これはストレージの最適化ではなく、モデリング上の判断です。
イベントソーシングを使う場面:
- 監査証跡が第一級の要件である場合 — 金融、医療、コンプライアンスなどで、「この日付時点で状態はどうなっていて、なぜそうなったのか?」が現実的な問いである。
- 時間に基づくクエリやタイムトラベルが必要な場合 — 過去の状態を再構築する、バグを再現するためにリプレイする、またはデータを書き込んだ時点では誰も考えていなかった問いに答える。
- ドメインが真にイベント駆動型である場合 — ビジネス上のやり取りがイベント(「支払いが確定した」「保険契約が失効した」)で表現され、複数の下流モデルがそれに反応する。
- 同じ事実に対して、複数の、独立して進化する読み取りモデルが必要であり、履歴をリプレイして新しいモデルを遡及的に追加したい。
イベントソーシングを使わない場面:
- ドメインがCRUDである場合 — 意味のある履歴を持たない、データを扱うフォーム。現在の状態を保存してください。
- 必要なのが監査ログだけである場合 — 現在の状態を保存するテーブルの横に、追記専用の履歴テーブルを追加してください。はるかに低コストです。
- 必要なのが集約をまたぐ副作用だけである場合 — ドメインイベントとアウトボックスパターンを使ってください(
domain-driven-design/resources/domain-events.mdを参照)。これはイベント駆動であり、イベントソーシングではありません。 - チームに経験がなく、ドメインもそれを必要としていない場合。失敗すると、バージョニング戦略のない中途半端なイベントストアができ、変更できなくなります。
複雑さの段階(DDD の domain-events.md を拡張):明示的な戻り値 → プロセス内ドメインイベント → アウトボックスパターン → イベントソーシング。必要なことを現在の段階で表現できない場合に限り、一段ずつ進んでください。イベントソーシングは最後の段階です。慎重に採用し、通常はシステム全体ではなく、1つの境界づけられたコンテキストに適用してください。
イベントソーシングは CQRS ではありません。 これらは互いに独立しています。CQRS は書き込みモデルと読み取りモデルを分離し、イベントソーシングはイベントを書き込みモデルのストレージとして採用します。イベントソーシングなしで CQRS を実現することも(それがヘキサゴナルスキルの CQRS ライトです)、まれに CQRS なしでイベントソーシングを使うこともできます。実際には、保存されたイベントはクエリに適した形ではないため、読み取りモデルに投影する必要があり、イベントソーシングはほぼ必ず CQRS につながります。イベント駆動アーキテクチャ、ストリーミング(Kafka)、監査ログとの完全な比較については、when-to-use-event-sourcing.md を参照してください。
中核となるメンタルモデル
このパターン全体を支える考え方は4つあります。
-
イベントが信頼できる唯一の情報源です。 イベントは不変の事実であり、過去形の名前が付けられ、何が起きたか、そしてなぜ起きたかを記録します。イベントを
UPDATEしたりDELETEしたりすることはなく、appendするだけです。間違いを訂正するには、会計士が帳簿の記載を決して消さないのと同じように、相殺する新しいイベントを追加します。 -
状態はイベントの左畳み込みです。 保存された「現在の状態」はありません。
evolve関数をストリームに対して畳み込むことで、必要なときに再構築します。この再構築は 再水和 または リプレイ と呼ばれます。Greg Young の表現では、「現在の状態は、これまでの振る舞いを左畳み込みしたものです。」const rehydrate = (events: readonly AccountEvent[]): AccountState => events.reduce(evolve, initialState); -
ストリームが整合性境界です。 集約のインスタンスは、必ず1つのストリームに対応します(例:
account-4f3c…)。その集約に関するすべての不変条件はストリーム内で適用され、追加にはストリームのバージョンに対する 楽観的同時実行制御 を使用します。集約の不変条件を保つために、ストリームをまたぐトランザクションに依存することはありません。ストリームが整合性境界であり、集約をまたぐ処理はプロセスマネージャー/サーガで行います(一部のストアでは複数のストリームに対するアトミックな追加が可能ですが、適切にモデル化された集約でそれが必要になることはありません)。 -
読み取りモデルは破棄可能な派生物です。 状態は
fold(events)であるため、読み取りに最適化されたビューは、単に異なる畳み込みを行ったものです。プロジェクションを削除して、イベントの先頭から再構築できます。これにより、「過去に遡って新しいレポートを追加する」ことが簡単にできます。
用語集(このスキル全体で一貫して使用します):
| 用語 | 意味 |
|---|---|
| イベント | 不変の過去形の事実:MoneyDeposited、OrderShipped。保存の単位です。 |
| コマンド | 変更を試みる要求:DepositMoney。拒否される場合があります。保存されることはありません。 |
| ストリーム | 1つの集約インスタンスに属する、順序付けられたイベントの列です。整合性境界です。 |
| デサイダー | 純粋な書き込みモデルです。decide(コマンド+状態 → イベント)と evolve(状態+イベント → 状態)で構成されます。 |
| 再水和/リプレイ | ストリームのイベントに対して evolve を畳み込み、状態を再構築することです。 |
| プロジェクション | イベントを読み取りモデル(クエリに最適化されたビュー)へ畳み込んだものです。 |
| スナップショット | 長いストリームのリプレイを避けるために保存される、畳み込み結果のキャッシュです。最適化のためのものであり、信頼できる唯一の情報源ではありません。 |
| イベントストア | ストリームを永続化するための、追記専用で順序付けられ、楽観的同時実行制御に対応したストアです。駆動されるポートです。 |
| 期待バージョン | コマンドの決定時に基準としたストリームのバージョンです。追加時に同時書き込みを検出するために使用します。 |
Decider は書き込みモデルです
イベントソーシングのために新たなドメインロジックを書く必要はありません。DDD スキルですでに用意した Decider をそのまま永続化します。関数は同じ 3 つですが、このスキルでは decide の戻り値に改良を加え、DDD の例のようなイベント配列だけではなく、明示的な Decision(イベントを伴う承認、または理由を伴う拒否)を返します。
// domain/account/account.ts — pure, no infrastructure imports
type AccountState =
| { readonly status: 'unopened' }
| { readonly status: 'open'; readonly balance: Money };
type Money = { readonly minorUnits: number; readonly currency: Currency };
type AccountCommand =
| { readonly type: 'Open'; readonly currency: Currency }
| { readonly type: 'Deposit'; readonly amount: Money }
| { readonly type: 'Withdraw'; readonly amount: Money };
type AccountEvent =
| { readonly type: 'AccountOpened'; readonly currency: Currency }
| { readonly type: 'MoneyDeposited'; readonly amount: Money }
| { readonly type: 'MoneyWithdrawn'; readonly amount: Money };
const initialState: AccountState = { status: 'unopened' };
const isPositiveMoney = (money: Money): boolean =>
Number.isSafeInteger(money.minorUnits) && money.minorUnits > 0;
// decide: what SHOULD happen? Returns events (or a rejection). Enforces invariants.
const decide = (command: AccountCommand, state: AccountState): Decision<AccountEvent> => {
switch (command.type) {
case 'Open':
if (state.status === 'open') return reject('already-open');
return accept([{ type: 'AccountOpened', currency: command.currency }]);
case 'Deposit':
if (state.status !== 'open') return reject('not-open');
if (!isPositiveMoney(command.amount)) return reject('invalid-amount');
if (command.amount.currency !== state.balance.currency) return reject('currency-mismatch');
if (!Number.isSafeInteger(state.balance.minorUnits + command.amount.minorUnits)) {
return reject('balance-overflow');
}
return accept([{ type: 'MoneyDeposited', amount: command.amount }]);
case 'Withdraw':
if (state.status !== 'open') return reject('not-open');
if (!isPositiveMoney(command.amount)) return reject('invalid-amount');
if (command.amount.currency !== state.balance.currency) return reject('currency-mismatch');
if (command.amount.minorUnits > state.balance.minorUnits) return reject('insufficient-funds');
return accept([{ type: 'MoneyWithdrawn', amount: command.amount }]);
default: { const _: never = command; return _; }
}
};
const corruptHistory = (state: AccountState, event: AccountEvent): never => {
throw new Error(`Corrupt account stream: ${event.type} cannot follow ${state.status}`);
};
const addMinorUnits = (left: number, right: number): number => {
if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right)) {
throw new Error('Corrupt account stream: unsafe minor-unit operand');
}
const minorUnits = left + right;
if (!Number.isSafeInteger(minorUnits) || minorUnits < 0) {
throw new Error('Corrupt account stream: invalid resulting balance');
}
return minorUnits;
};
const applyDelta = (balance: Money, deltaMinorUnits: number): Money =>
({ ...balance, minorUnits: addMinorUnits(balance.minorUnits, deltaMinorUnits) });
// evolve applies facts; an impossible known transition is corrupt history, not a no-op.
const evolve = (state: AccountState, event: AccountEvent): AccountState => {
switch (event.type) {
case 'AccountOpened': {
if (state.status !== 'unopened') return corruptHistory(state, event);
return { status: 'open', balance: { minorUnits: 0, currency: event.currency } };
}
case 'MoneyDeposited':
if (
state.status !== 'open' ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== state.balance.currency
) return corruptHistory(state, event);
return { ...state, balance: applyDelta(state.balance, event.amount.minorUnits) };
case 'MoneyWithdrawn':
if (
state.status !== 'open' ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== state.balance.currency ||
event.amount.minorUnits > state.balance.minorUnits
) return corruptHistory(state, event);
return { ...state, balance: applyDelta(state.balance, -event.amount.minorUnits) };
default: { const _: never = event; return _; }
}
};
金融イベントでは、通貨と整数の最小通貨単位を扱います。コマンド境界では安全な整数でない値(NaN、無限大、小数、オーバーフロー)を拒否します。また、メジャー通貨単位の小数から変換する場合は、コマンドを作成する前に通貨の指数と丸め方針を明示する必要があります。浮動小数点数の金額を永続化してはいけません。
役割分担は厳密です(これを誤ると、モデル全体が損なわれます)。
decideはすべてのビジネスルールを担います。現在の状態を読み取り、コマンドを検証し、イベントを返して承認するか、ビジネス上の理由を返して拒否します。副作用はなく、ストレージにも触れません。evolveはコマンドルールを一切持ちません。有効な保存済みイベントをすべて網羅的に適用します。既知のイベントが現在の状態に続けて適用できない場合、履歴が破損しているため、リプレイでは状態をそのまま保持して黙って処理するのではなく、破損エラーを表面化させる必要があります。寛容なリーダーとアップキャスターによって、古い有効なイベントも読み取れるようになりますが、成立しない遷移が有効になるわけではありません。
decide の結果を明示的な Decision(イベントを伴う承認、または理由を伴う拒否)としてモデル化する方法は、DDD スキルのエラーモデリング規則に沿っています。想定されるビジネス上の結果には例外ではなく、結果型を使います。Decision、isTerminal、および Decider の合成方法については、decider-and-rehydration.md を参照してください。
コマンドハンドラーのループ
アプリケーションサービスは、純粋な Decider と副作用を伴うイベントストアを結び付けます。このループはイベントソーシングにおけるすべての書き込みの要であり、毎回同じ形です。
// use-case: load → decide → append (with optimistic concurrency)
const handleCommand = async (
store: EventStore<AccountEvent>,
streamId: StreamId,
command: AccountCommand,
): Promise<CommandResult> => {
// 1. LOAD the stream's events (and the version we read at)
const { events, version } = await store.readStream(streamId);
// 2. REHYDRATE current state by folding — pure
const state = events.reduce(evolve, initialState);
// 3. DECIDE — pure business logic
const decision = decide(command, state);
if (!decision.accepted) return { success: false, reason: decision.reason };
// 4. APPEND the new events, asserting the stream has not moved since we read it
const outcome = await store.appendToStream(streamId, decision.events, { expectedVersion: version });
if (outcome === 'version-conflict') return { success: false, reason: 'concurrent-modification' };
return { success: true, events: decision.events };
};
純粋な処理(evolve、decide)はモックなしで簡単にテストできます。副作用を伴う処理はすべて EventStore ポートの背後に置かれます。追加時の期待バージョンによって、並行書き込みが安全になります。手順 1 と手順 4 の間に別の書き込み側がストリームへ追加した場合、追加処理は失敗し、呼び出し元は手順 1 から再試行します(最新の状態を読み込み、再度判断します)。このスニペットは基本的な形を示しています。本番環境では、手順 1~4 を、回数に上限のある再読み込み・再判断の再試行ループで囲みます(decider-and-rehydration.md を参照)。期待バージョンを省略してはいけません。省略すると、並行して実行された 2 件の引き出しがどちらも残高チェックを通過し、口座が過剰に引き落とされるおそれがあります。
イベントストアのポート
イベントストアは駆動されるポートです(ヘキサゴナルスキルを参照)。アプリケーションのコマンドハンドラーがこれを利用するため、インターフェースはアプリケーション層でアプリケーションの言葉を使って定義し、アダプターで実装します。ドメインイベント型と純粋な Decider はドメインに置きます。最小限の契約はシンプルです。
// port (application layer) — owned by the command handler that consumes it.
// Typed to one aggregate's event family, like a repository (the underlying
// adapter can hold many streams; it parses stored JSON into E on read).
interface EventStore<E> {
readonly readStream: (streamId: StreamId) => Promise<{ readonly events: readonly E[]; readonly version: number }>;
readonly appendToStream: (
streamId: StreamId,
events: readonly E[],
options: { readonly expectedVersion: number },
) => Promise<'ok' | 'version-conflict'>;
}
本番用のストアには、(非同期プロジェクション用の)サブスクリプションとグローバルなイベント順序の読み取り機能が追加されます。ただし、すべてのイベントストアに必要なのは、追記のみの書き込み、ストリーム内の順序付け、期待バージョンによる楽観的並行性制御です。標準的な Postgres 実装では、イベントの追加と同じトランザクション内でストリームヘッドの行を原子的に比較・更新します。UNIQUE (stream_id, version) は多層防御の制約として引き続き有効です。完全なスキーマ、イベントエンベロープ(相関 ID/原因 ID を含む)、および Emmett、KurrentDB(EventStoreDB)、message-db の位置付けについては、event-store.md を参照してください。
イベントは信頼境界を越えてデータとして保存されるため、ストアから読み出す際は、evolve が受け取る前にスキーマで検証します(寛容なリーダー)。ここでイベントソーシングと typescript-strict が結び付きます。保存された JSON は信頼できない入力であり、読み込み時にパースして、ブランド付きドメインイベントに変換します。また、バージョン管理を行うのもここです。以下を参照してください。
イベントをデータとして扱う
イベントは、あなたが作成するものの中で最も永続性の高いものです。テーブルの行はマイグレーションできますが、イベントはシステムが存続する限り再生される歴史的事実です。慎重に設計してください(詳しくは modelling-events.md を参照してください)。
- 過去形で、ビジネス上の言葉を使って命名します。
UpdateStatusではなくSubscriptionCancelled、RowUpdatedではなくFundsWithdrawnとします。イベントは、ドメインで 起きた ことを表します。 - CRUD イベントは避けます。
AccountCreated/AccountUpdated/AccountDeletedは、イベントソーシングの衣装を着たデータベースの変更履歴です。データが変更された ことは記録しますが、何が起きたのか、なぜ起きたのか は記録しません。意図をモデル化してください。AccountOpened、AddressCorrected、AccountClosedのようにします。 - 後で参照するための参照情報ではなく、事実をイベントに含めます。 イベントは、変更可能なテーブルとの結合なしに、何年も後に単独で解釈できなければなりません。発生した時点で真だった値(請求した価格、適用したレートなど)を記録し、変更されている可能性のある行への外部キーは記録しないでください。
- すべてのイベントにエンベロープを付与します。 一意の ID、型、ストリーム ID、バージョン、タイムスタンプ、メタデータ(コマンドが引き起こしたイベントを追跡するための相関 ID と原因 ID)を含めます。ドメインペイロードはこのエンベロープとは別にします。
- イベントは判別共用体としてモデル化します。
type判別子を設け、コマンドや状態とまったく同じように、網羅的に処理します。DDD スキルで使われているのと同じ、neverでガードする switch を使用します。
プロジェクションと読み取りモデル(CQRS)
ユーザーの質問に答えるためにイベントストアを直接クエリすることは、ほとんどありません。読み取りのたびにストリームを畳み込む方法はスケールせず、集約をまたぐクエリには、ストリームが意図的に分離しているデータが必要になるためです。代わりに、イベントエンベロープを読み取りモデルにプロジェクトします。プロジェクションは、envelopes.reduce(applyToBalanceView, emptyBalanceView) のような別の畳み込みにすぎず、その結果はクエリに適した形のテーブルになります。
// A projection is a fold from events → a read-optimised row
type BalanceView =
| { readonly status: 'unopened' }
| {
readonly status: 'open';
readonly accountId: StreamId;
readonly balanceMinorUnits: number;
readonly currency: Currency;
};
type AccountProjectionEnvelope = {
readonly streamId: StreamId;
readonly globalPosition: bigint;
readonly data: AccountEvent;
};
const emptyBalanceView: BalanceView = { status: 'unopened' };
const corruptBalanceProjection = (view: BalanceView, event: AccountEvent): never => {
throw new Error(`Corrupt balance projection: ${event.type} cannot follow ${view.status}`);
};
const applyToBalanceView = (
view: BalanceView,
envelope: AccountProjectionEnvelope,
): BalanceView => {
const event = envelope.data;
switch (event.type) {
case 'AccountOpened':
if (view.status !== 'unopened') return corruptBalanceProjection(view, event);
return {
status: 'open',
accountId: envelope.streamId,
currency: event.currency,
balanceMinorUnits: 0,
};
case 'MoneyDeposited':
if (
view.status !== 'open' ||
view.accountId !== envelope.streamId ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== view.currency
) return corruptBalanceProjection(view, event);
return { ...view, balanceMinorUnits: addMinorUnits(view.balanceMinorUnits, event.amount.minorUnits) };
case 'MoneyWithdrawn':
if (
view.status !== 'open' ||
view.accountId !== envelope.streamId ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== view.currency ||
event.amount.minorUnits > view.balanceMinorUnits
) return corruptBalanceProjection(view, event);
return { ...view, balanceMinorUnits: addMinorUnits(view.balanceMinorUnits, -event.amount.minorUnits) };
default: { const _: never = event; return _; }
}
};
作成イベントだけが unopened から open への唯一の遷移であり、読み取りモデルの ID は、重複したペイロードデータや呼び出し元が指定したシードではなく、検証済みエンベロープの streamId から取得します。コンシューマーはこの畳み込みの前に、イベント ID またはチェックポイントによって完全に同一の再配信をスキップします。そのため、後から到着した AccountOpened、口座開設前の金銭イベント、別のストリームからのエンベロープは、既知の履歴としてあり得ないため、調査のためにプロジェクションを停止しなければなりません。許容的な読み取りとアップキャストも、この型付きの畳み込みの前に行います。これらは互換性のある古いイベントを保持するものであり、あり得ない遷移を許容するものではありません。
- インライン(同期)プロジェクションは、追記と同じトランザクション内で更新されます。遅延はありませんが、書き込みのスループットがプロジェクションの処理に左右されます。非同期プロジェクションはイベントストリームを購読して個別に更新します。スケールしやすく、障害も分離できますが、結果整合性の代償として、読み取りモデルの反映が書き込みより数ミリ秒遅れることがあります。
- 結果整合性は、主なトレードオフです。 コマンドを実行した直後のユーザーには、非同期読み取りモデルに結果が反映されていないことがあります。遅延がないかのように振る舞うのではなく、UI で対応できるように設計してください(コマンドから書き込み直後の状態を返す、楽観的 UI を表示するなど)。
- プロジェクションは破棄できます。 チェックポイント(処理済みの最後のイベント位置)を追跡します。再構築するには、読み取りモデルとチェックポイントをゼロにリセットして再生します。これにより、何年分もの既存履歴を対象とした新しい読み取りモデルを追加したり、バグのあるプロジェクションを修正したりできます。
- プロジェクションは冪等でなければなりません。 配信は少なくとも 1 回行われるため、同じイベントを 2 回適用しても二重にカウントされてはいけません。イベント ID または グローバル位置(ストア全体で一意)をキーにします。ストリームの
versionだけで十分なのは、読み取りモデルの行が単一のストリームに対応している場合だけです。バージョンはストリーム間で重複するためです。
これは、ヘキサゴナルスキルの CQRS-lite における書き込みと読み取りの分離を完全な形にしたものです。書き込みはデサイダーとイベントストアを経由し、読み取りはプロジェクションされた読み取りモデルを経由します。詳しくは projections-and-read-models.md を参照してください。
イベントソーシングシステムのテスト
イベントソーシングは、最もテストしやすいパターンのひとつです。書き込みモデルが純粋だからです。イベント入力 → 状態 → コマンド → イベント出力という、すべてが単なるデータです。
イベントソーシングの文献では、ほぼ例外なく、ここで given-when-then 形式が採用されています。つまり、過去のイベントが given されたとき、このコマンドが処理されると when、この新しいイベントが期待される then という形式です。多くの場合、流れるような given(...).when(...).then(...) DSL や Gherkin シナリオとして表現されます。これが使われるのには、もっともな理由があります。**この形式はディサイダーの代数そのものだからです。**過去のイベントを畳み込んで状態を作り、コマンドを適用し、結果として生じるイベントをアサーションします。このパターンがなぜこれほどよく当てはまるのか、理解しておく価値があります。
この代数は、リポジトリで確立されているテストスタイルで表現してください。直接的なビヘイビア駆動テスト(testing スキルを参照)では、公開関数を呼び出し、観測された出力をアサーションし、テストにはビジネス上の振る舞いに基づいた名前を付け、ファクトリーでデータを構築します。既存の given/when/then ヘルパーで同じ振る舞いを表現できる場合もあります。イベントソーシングの文献で使われているというだけの理由で、新しい DSL を導入しないでください。直接的な形式なら、新たな仕組みを加えずにきれいに対応づけられます。
- 「過去のイベントが given されたとき」 → ファクトリーで初期状態を構築するか、イベントファクトリーを
evolveで畳み込みます。(過去のイベントは単なるデータです。) - 「コマンドが when されたとき」 → 公開関数(
decideまたはコマンドハンドラー)を直接呼び出します。 - 「イベントを then 期待する」 → 返されたイベントに対して
expect(...)を実行します。イベントは decide の観測可能な出力そのものです。
// ✅ Ordinary behaviour-driven test through the public decider API.
// The returned events are the observable behaviour of the public `decide` function.
const openAccount = (currency: Currency = 'GBP'): AccountEvent =>
({ type: 'AccountOpened', currency });
const money = (minorUnits: number, currency: Currency = 'GBP'): Money => ({ minorUnits, currency });
const deposited = (minorUnits: number): AccountEvent => ({ type: 'MoneyDeposited', amount: money(minorUnits) });
it('should reject a withdrawal that exceeds the balance', () => {
const state = [openAccount(), deposited(5_000)].reduce(evolve, initialState);
const decision = decide({ type: 'Withdraw', amount: money(10_000) }, state);
expect(decision).toEqual({ accepted: false, reason: 'insufficient-funds' });
});
it('should record a deposit as a MoneyDeposited event on an open account', () => {
const state = [openAccount()].reduce(evolve, initialState);
const decision = decide({ type: 'Deposit', amount: money(5_000) }, state);
expect(decision).toEqual({ accepted: true, events: [{ type: 'MoneyDeposited', amount: money(5_000) }] });
});
初期状態はファクトリーで構築したイベントを畳み込んで作り、「アクション」は直接呼び出し、返されたデータをアサーションします。これは testing スキルに沿った、公開 API を通じた振る舞いのテストです。イベントバスもモックも必要ありません。ファクトリーを使うと、繰り返し登場する履歴を読みやすく保てますが、型を明示した単発のインラインイベントも同様に有効です。コンテキストのない配列リテラルでは type 判別子が string に拡大解釈されることがあります。これを防ぐには、AccountEvent[] の注釈を付けるか、satisfies readonly AccountEvent[] を使用してください。プロジェクションとアップキャスターも同じ方法でテストします(これらは畳み込みと純粋関数です)。イベントを渡し、読み取りモデルまたはアップキャストされたイベントをアサーションします。EventStore のインメモリフェイクを使ったコマンドハンドラーのテストや、畳み込みのプロパティテストを含む一連のパターンについては、testing-event-sourced-systems.md を参照してください。
スナップショット(簡単に)
スナップショットは、保存された畳み込み結果({ version: 240, state: {…} })です。イベントゼロからではなく、スナップショットとその後のイベントから状態を復元できます。**スナップショットはキャッシュであり、決して真実の情報源ではありません。**すべてのスナップショットを削除しても、イベントから再構築した後のシステムは同一でなければなりません。ほとんどのストリームは十分に短く、スナップショットは必要ありません。ストリームが十分に大きくなり、リプレイが計測可能なほど遅くなった場合に限ってスナップショットを使い、それ以前には使わないでください。production-concerns.md を参照してください。
アンチパターン
- あらゆる場面でイベントソーシングを使う。 履歴がドメインの一部となる1つか2つのコンテキストに限定せず、システム全体に適用することです。ほとんどのコンテキストは CRUD です。CRUD のままにしてください。
- CRUD イベント。 テーブルへの書き込みをそのまま反映した
Created/Updated/Deletedイベントです。捉えているのはデータが変更されたという事実であり、何が起きたかではありません。意図をモデル化してください。 - バージョニング戦略がない。 イベントを進化させる計画がないまま v1 のイベントをリリースすることです。イベントは不変で永続するため、新しいコードで古い形式を読み取る必要が必ず生じます。最初の日に、寛容なリーダー/アップキャストの戦略を決めてください(
event-versioning.md)。 evolveで再判断する。 コマンドのポリシーをリプレイに持ち込むことです。evolveはビジネス上の拒否を行わずに有効な事実を適用しますが、既知のイベントがあり得ない状態を引き起こす場合は、何もせず黙って通すのではなく、履歴の破損として表面化させる必要があります。- 変更可能なイベント。 過去のイベントを編集または削除してバグを「修正」することです。修正は新しい補償イベントで行います。履歴を編集した時点でリプレイの信頼性は失われ、このパターンの根本的な約束が破られます。
- スナップショットを信頼できる唯一の情報源にする。 再構築可能なキャッシュではなく、スナップショットを権威あるものとして扱うことです。すべてのスナップショットを削除して再構築できないなら、イベントログの保証は失われています。
- イベントストアを統合バスとして使う。 他のサービスが内部ドメインイベントを直接サブスクライブできるようにすると、書き込みモデルの形式に依存させることになります。代わりに、意図を明確にしたバージョン管理済みの統合イベントを公開してください(この違いについては DDD の
domain-events.mdを参照してください)。 - 肥大化したイベント/万能ストリーム。 巨大なデータを抱えたイベントや、集約ごとに1つのストリームを設けず、すべてを1つの巨大なストリームにまとめることです。どちらも整合性境界とリプレイコストのモデルを損ないます。
- 結果整合性を無視する。 非同期の読み取りモデルが即座に最新になると想定した UI を構築することです。遅延を前提に設計してください。
- 期待バージョンを省略する。 楽観的同時実行制御を行わずに追記すると、同時実行されたコマンドが不変条件に違反しても気づけません。
チェックリスト
- 履歴がドメインの一部であるためにイベントソーシングを選択し、デフォルトとしてではなく、特定の境界づけられたコンテキストに限定している
- まず、より低コストな選択肢(明示的な戻り値、監査テーブル、アウトボックス)を検討し、採用しないと判断している
- イベントは過去形で、ビジネス上の名称を持ち、意図が明確になっている(CRUD イベントはない)
- イベントは自己完結したデータであり(変更可能な参照ではなく値を保持している)、識別共用体としてモデル化されている
- すべてのイベントにエンベロープ(id、type、stream id、version、timestamp、correlation/causation メタデータ)がある
-
decideにすべてのビジネスルールが含まれ、イベント付きの受理、または理由付きの拒否を返す -
evolveが保存済みの有効なイベントをすべて網羅的に適用し、既知のあり得ない遷移は何もせず黙って通すのではなく、破損として表面化させる -
evolveを畳み込んで状態を再構築し、現在の状態を信頼できる唯一の情報源として保存するものがない - 追記に楽観的同時実行制御(期待バージョン)を使い、競合は再読み込みして再試行することで処理する
- イベントストアは駆動されるポートであり、ドメインにはインフラストラクチャのインポートが一切ない
- 保存済みイベントは、信頼境界である読み取り時に、スキーマ(寛容なリーダー)を使って検証する
- 最初のイベントをリリースする前に、バージョニング戦略(寛容なリーダー、アップキャスター、またはその両方)が定まっている
- 読み取りモデルはプロジェクション(畳み込み)であり、チェックポイントを介してイベント0から再構築できる
- プロジェクションは冪等で、UI は結果整合性を考慮している
- スナップショット(ある場合)は再構築可能なキャッシュであり、権威あるものではない
- テストでは観測されたイベント/状態/結果を通じて公開動作を検証し、DSL を使う場合は、イベントソーシング専用の仕組みを追加せず、リポジトリで確立されたテストスタイルに従っている
- イベントに個人データを含める場合は、PII 戦略を決定している(
production-concerns.mdのクリプトシュレッディングを参照)