Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

イベントソーシング(Event Sourcing)

エンティティの状態そのものを保存するのではなく、状態を変化させたイベントの並びを追記専用(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)

BankAccountbalance は保存されるデータではなく、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"]

メリット

  1. 完全な監査証跡:何が・いつ・なぜ起きたかがすべて残る

  2. 任意時点の状態を復元できる:デバッグや障害分析で「あの時点の状態」を再現できる

  3. イベントの再解釈が可能:新しいプロジェクションを後から追加し、過去のイベントに対して適用できる

  4. CQRSドメインイベント と自然に統合できる

デメリット・課題

  1. 学習コストとモデリングの難しさ:CRUDに慣れたチームには馴染みにくい

  2. イベントスキーマの進化:過去のイベント形式を変更できないため、バージョニング戦略が必須

  3. 結果整合性:プロジェクションの更新は非同期になることが多く、書き込み直後の読み取りに一貫性がない場合がある

  4. 再生コスト:スナップショットを使わないと、イベント数の増加に伴い状態復元が遅くなる

適用場面

  • 監査ログや変更履歴そのものがビジネス上重要なドメイン(会計、在庫、注文)

  • 複雑な状態遷移や「なぜ今の状態になったか」の説明責任が求められるドメイン

  • 単純なCRUDで十分なドメインには過剰(モデリング・運用コストに見合わない)