エンティティの状態そのものを保存するのではなく、状態を変化させたイベントの並びを追記専用(append-only)で保存し、 現在の状態はイベントを最初から再生(replay)することで導出するパターン。イベント駆動アーキテクチャ や CQRS と組み合わせて使われることが多い。
基本的な考え方¶
通常のCRUDでは「今の状態」だけをテーブルに保存し、過去の状態は上書きされて失われる。イベントソーシングでは代わりに 「何が起きたか」を時系列のイベントとして記録し、現在の状態はイベントの畳み込み(fold)で計算する。
class BankAccount:
def __init__(self, account_id):
self.account_id = account_id
self.balance = 0
self.events = []
def deposit(self, amount):
event = {
"type": "MoneyDeposited",
"amount": amount,
"timestamp": datetime.now()
}
self._apply_event(event)
self.events.append(event)
def withdraw(self, amount):
event = {
"type": "MoneyWithdrawn",
"amount": amount,
"timestamp": datetime.now()
}
self._apply_event(event)
self.events.append(event)
def _apply_event(self, event):
if event["type"] == "MoneyDeposited":
self.balance += event["amount"]
elif event["type"] == "MoneyWithdrawn":
self.balance -= event["amount"]
def rebuild_from_events(self, events):
for event in events:
self._apply_event(event)BankAccount の balance は保存されるデータではなく、events を先頭から適用した結果として導出される値になる。
スナップショット¶
イベント数が多くなると、毎回全イベントを再生するコストが無視できなくなる。そこで一定間隔(例:100イベントごと)で 現在の状態をスナップショットとして保存し、復元時は「直近のスナップショット+それ以降のイベント」だけを再生する。
class BankAccountWithSnapshot(BankAccount):
def to_snapshot(self):
return {"account_id": self.account_id, "balance": self.balance, "version": len(self.events)}
@classmethod
def from_snapshot(cls, snapshot, events_after_snapshot):
account = cls(snapshot["account_id"])
account.balance = snapshot["balance"]
for event in events_after_snapshot:
account._apply_event(event)
return accountプロジェクション(Projection)¶
イベントストアは書き込みには適しているが、「残高が1万円以上の口座を検索する」のような読み取りには不向き。 そこでイベントを購読して、検索やレポートに最適化された読み取り専用のビュー(プロジェクション)を別途構築する。 これは CQRS におけるクエリ側モデルそのものであり、イベントソーシングとCQRSが組み合わされる理由になっている。
# CQRSのクエリ側:残高一覧をイベントから作る読み取り専用ビュー
class AccountBalanceProjection:
def __init__(self):
self.balances = {}
def apply(self, event):
acc_id = event["account_id"]
if event["type"] == "MoneyDeposited":
self.balances[acc_id] = self.balances.get(acc_id, 0) + event["amount"]
elif event["type"] == "MoneyWithdrawn":
self.balances[acc_id] = self.balances.get(acc_id, 0) - event["amount"]メリット¶
完全な監査証跡:何が・いつ・なぜ起きたかがすべて残る
任意時点の状態を復元できる:デバッグや障害分析で「あの時点の状態」を再現できる
イベントの再解釈が可能:新しいプロジェクションを後から追加し、過去のイベントに対して適用できる
デメリット・課題¶
学習コストとモデリングの難しさ:CRUDに慣れたチームには馴染みにくい
イベントスキーマの進化:過去のイベント形式を変更できないため、バージョニング戦略が必須
結果整合性:プロジェクションの更新は非同期になることが多く、書き込み直後の読み取りに一貫性がない場合がある
再生コスト:スナップショットを使わないと、イベント数の増加に伴い状態復元が遅くなる
適用場面¶
監査ログや変更履歴そのものがビジネス上重要なドメイン(会計、在庫、注文)
複雑な状態遷移や「なぜ今の状態になったか」の説明責任が求められるドメイン
単純なCRUDで十分なドメインには過剰(モデリング・運用コストに見合わない)