Skip to content

イベントソーシング

実装: messaging/eventsourcing/ / 実行: go test ./messaging/eventsourcing/

普通のアプリは現在の状態を保存する。口座なら残高を上書きする。だがこれは過去を捨てている。いつ、なぜその残高になったかは残らない。イベントソーシングは発想を逆にする。保存するのは起きた出来事の連なりで、追記だけして書き換えない。現在の残高は並びを畳み込めばいつでも導ける。履歴が丸ごと残るので監査も、過去の時点への遡りもできる。イベント追記・リプレイ・タイムトラベル・スナップショットを実装する。

この章で作るもの

メッセージキューPub/Subで、システム間をイベントで繋ぐ話をした。イベントソーシングは、その発想をデータの保存そのものに持ち込む。普通のアプリケーションは「現在の状態」をデータベースに保存する。口座なら残高の欄に数字があり、入金・出金のたびにその数字を上書きする。この方式は素直だが、大きなものを捨てている。過去だ。残高が今 1200 円だと分かっても、それがどんな入出金の積み重ねでそうなったのかは、どこにも残っていない。

イベントソーシングは、保存するものを逆にする。現在の状態でなく、起きた出来事(イベント)の連なりを保存する。「1000 円入金」「500 円入金」「300 円出金」という追記専用の並びが、唯一の真実だ。一度記録したイベントは決して書き換えない。では現在の残高はどうやって知るのか。イベントを頭から順に畳み込む(リプレイする)。1000 足して、500 足して、300 引いて、1200。状態は保存せず、いつでもイベントから導く。履歴が丸ごと残るので、監査ができ、過去のどの時点にも遡れる。この章では、口座を例にこの仕組みを実装する。

状態保存:   残高 [1000] → [1500] → [1200]   過去は上書きで消える
                            ↑ 前の値はもう無い

イベント:   +1000 → +500 → −300              追記だけ。消えない
            └──── 畳み込む ────┘ = 残高 1200  (いつでも導ける)
状態を上書きする方式は過去を捨てる。イベントソーシングは出来事を追記し続け、現在状態はそれを畳み込んで導く

順に見ていく。

  1. イベントが真実、状態は導出: 保存するのは追記専用のイベント列。現在状態はそれを畳み込んで作る射影に過ぎない
  2. 履歴が丸ごと残る: 出来事は書き換えない。監査ログそのものになり、過去のどの時点にも遡れる(タイムトラベル)
  3. コマンドとイベントは別物: コマンド(意図)は検証を通ってはじめてイベント(事実)になる。不正な意図は記録しない

① イベントの追記とリプレイ

まずイベントと、それを溜める追記専用のストアを作る。ストアは追記だけ、上書きも削除もしない。そして現在状態は、イベントを頭から畳み込んで導く:

go

// Account は口座の状態。これは保存されるものではなく、イベントから導かれる射影。
type Account struct {
	Balance int
	Version int // どのイベントまで反映したか
}

// Apply は 1 つのイベントを状態に適用し、新しい状態を返す(不変。元は変えない)。
func (a Account) Apply(e Event) Account {
	switch e.Type {
	case Deposited:
		a.Balance += e.Amount
	case Withdrawn:
		a.Balance -= e.Amount
	}
	a.Version = e.Version
	return a
}

// Replay はイベント列を頭から畳み込んで現在状態を作る。
// これがイベントソーシングの核心。状態は保存せず、常にイベントから導く。
func Replay(events []Event) Account {
	var a Account
	for _, e := range events {
		a = a.Apply(e)
	}
	return a
}

// StateAt は version の時点までを畳んだ、過去のある時点の状態を返す(タイムトラベル)。
func StateAt(events []Event, version int) Account {
	var a Account
	for _, e := range events {
		if e.Version > version {
			break
		}
		a = a.Apply(e)
	}
	return a
}

Apply は 1 つのイベントを状態に適用する純粋な関数だ(元の状態を変えず、新しい状態を返す)。Replay はそれをイベント列に畳み込む。ここが核心で、Account(残高)はどこにも保存されない。必要になるたびにイベントから導く。テストで、入金・入金・出金の 3 イベントを畳むと残高 1200 になることを固定した。この「状態を持たず、出来事から毎回導く」構えが、あとの性質すべての土台になる。状態を上書きしないので、情報が失われる箇所が一つもない。

② タイムトラベルと監査

イベントを畳み込む範囲を変えれば、過去のある時点の状態が出る。version を指定して、そこまでのイベントだけを畳めばいい:

go

// EventType は出来事の種類。
type EventType int

const (
	Deposited EventType = iota // 入金
	Withdrawn                  // 出金
)

func (t EventType) String() string {
	if t == Deposited {
		return "Deposited"
	}
	return "Withdrawn"
}

// Event は起きた 1 つの出来事。追記されたら二度と書き換えない(不変)。
type Event struct {
	Type    EventType
	Amount  int
	Version int // 何番目の出来事か(1 始まり)
}

// Store はイベントの追記専用ログ。上書き・削除はしない。
type Store struct{ events []Event }

// Append はイベントを末尾に追記し、通し番号(Version)を振る。
func (s *Store) Append(t EventType, amount int) Event {
	e := Event{Type: t, Amount: amount, Version: len(s.events) + 1}
	s.events = append(s.events, e)
	return e
}

// Events は全イベントを返す(監査ログそのもの)。
func (s *Store) Events() []Event { return s.events }

// EventsAfter は version より後のイベントだけを返す(スナップショット併用)。
func (s *Store) EventsAfter(version int) []Event {
	var out []Event
	for _, e := range s.events {
		if e.Version > version {
			out = append(out, e)
		}
	}
	return out
}

StateAt は指定した version までを畳んで、過去の残高を返す。「先月末の残高は」「この取引の直前はいくらだったか」に、状態を戻すことなく答えられる。普通の上書き方式では、過去の値はもう存在しないので、こうは問えない。イベントの並びそのものが監査ログでもある。Events() を見れば、いつ何が起きたかの全記録がそこにある。金融や医療のように「なぜこうなったか」の説明責任が要る領域で、イベントソーシングが好まれるのはこのためだ。テストで、version 0・1・2・3 のそれぞれで過去の残高が正しく再現されることを固定した。

③ コマンドとイベント、そしてスナップショット

一つ区別が要る。コマンドとイベントは違う。コマンドは「300 円出金したい」という意図で、まだ起きていない。イベントは「300 円出金した」という起きてしまった事実だ。コマンドは検証を通って、はじめてイベントになる。残高を超える出金の意図は、検証で弾かれ、イベントとしては記録されない:

go

// ErrInsufficient は残高不足で出金できないとき。
var ErrInsufficient = errors.New("eventsourcing: insufficient balance")

// ErrInvalidAmount は金額が正でないとき。
var ErrInvalidAmount = errors.New("eventsourcing: amount must be positive")

// Deposit は入金コマンドを検証し、正しければイベントを Store に追記する。
// コマンド(意図)は検証を通ってはじめてイベント(事実)になる。
func Deposit(store *Store, amount int) (Event, error) {
	if amount <= 0 {
		return Event{}, ErrInvalidAmount
	}
	return store.Append(Deposited, amount), nil
}

// Withdraw は出金コマンドを検証する。現在の残高(イベントを畳んで導く)を超える
// 出金は拒否し、イベントを残さない。不正な意図はイベントにしない。
func Withdraw(store *Store, amount int) (Event, error) {
	if amount <= 0 {
		return Event{}, ErrInvalidAmount
	}
	current := Replay(store.Events())
	if amount > current.Balance {
		return Event{}, ErrInsufficient
	}
	return store.Append(Withdrawn, amount), nil
}

Withdraw は、現在残高(イベントを畳んで導く)を超える出金を ErrInsufficient で拒否し、イベントを残さない。不正な意図を事実にしないのが、この区別の意味だ。テストで、残高 100 に対する 500 の出金が拒否され、イベントが増えず、残高が変わらないことを固定した。

もう一つ現実的な問題がある。イベントが何万件も溜まると、毎回すべてを畳み直すのは重い。そこでスナップショットを使う。ある時点の状態を写し取っておき、以降はそのイベントだけを畳んで追いつく:

go

// Snapshot はある時点の状態の写し。これ以降のイベントだけ畳めば現在に追いつける。
type Snapshot struct {
	Balance int
	Version int
}

// TakeSnapshot は現在状態からスナップショットを作る。
func TakeSnapshot(a Account) Snapshot {
	return Snapshot{Balance: a.Balance, Version: a.Version}
}

// RestoreFrom はスナップショットに、それ以降のイベントだけを畳んで現在状態を復元する。
// 全イベントを畳み直す必要がなく、長い履歴でも速い。
func RestoreFrom(snap Snapshot, laterEvents []Event) Account {
	a := Account{Balance: snap.Balance, Version: snap.Version}
	for _, e := range laterEvents {
		if e.Version <= snap.Version {
			continue // スナップショットに既に含まれる
		}
		a = a.Apply(e)
	}
	return a
}

RestoreFrom は、スナップショットの残高から始めて、それ以降のイベントだけを適用する。全履歴を畳み直す必要がない。テストで、スナップショット + 以降のイベントが、全イベントのリプレイと同じ状態になることを固定した。スナップショットはあくまで速度のための補助で、真実は依然としてイベント列にある。スナップショットを捨てても、イベントから作り直せる。

動かす

下のデモは口座に入金・出金しながら、イベントログが伸びていく様子と、それを畳んで現在残高が導かれる様子を見る。過去の version へ遡ったり、残高を超える出金が拒否される様子も確かめられる。

デモイベントソーシング(口座)残高 1200
イベントログ(追記専用)
v1Deposited+1000
v2Deposited+500
v3Withdrawn−300
畳み込んで導く状態
残高1200
表示時点: v3(現在)

スライダーで過去の時点へ遡れる(タイムトラベル)。状態は保存せずイベントから導く

保存しているのは残高でなくイベント列(追記専用)。現在残高はイベントを頭から畳み込んで導く。 履歴が丸ごと残るので、任意の過去の時点に遡れる。出金コマンドは現在残高で検証され、超過なら 拒否してイベントを残さない(意図は事実にしない)。長い履歴はスナップショットで畳み込みを省ける。

設計の観点

  • イベントは不変・追記専用: 一度記録したイベントは書き換えない。訂正も「取り消しイベント」を追記する。これが監査可能性と再現性の源
  • コマンドとイベントを分ける: コマンドは拒否されうる意図、イベントは確定した事実。検証はコマンド時に行い、イベントは常に正しい前提で畳める
  • 射影(projection)は使い捨て: 現在状態はイベントから導く射影。別の見方が欲しければ、同じイベントを別の畳み方で射影すればいい(これが次章 CQRS の読みモデルに繋がる)
  • スナップショットは最適化: 真実はイベント列。スナップショットは畳み込みを速くするだけで、失っても再生成できる
  • イベントスキーマの進化: 長く運用するとイベントの形が変わる。古いイベントを新しい形に変換(アップキャスト)する仕組みが要る

対照と実例

状態保存(CRUD)イベントソーシング
保存するもの現在の状態(上書き)出来事の列(追記)
過去失われる完全に残る
監査別途ログが必要イベント列がログ
現在状態そのまま読む畳み込んで導く
複雑さ低い高い(リプレイ・射影)

裏どり:

  • Martin Fowler: Event Sourcing: 概念の定番の解説。イベント・リプレイ・スナップショットの整理
  • Kafka / EventStoreDB: イベントを追記専用ログとして永続化する実基盤。リプレイ前提の設計
  • 銀行の元帳(ledger): 残高を上書きせず取引を追記し続ける会計の考え方そのもの。イベントソーシングの原型
  • CQRS との組み合わせ: 書き込みをイベントで、読み取りを射影で分ける。イベントソーシングと相性がよい

簡略化したこと

  • 単一集約: 口座 1 つ分。実物は集約 ID ごとにイベントストリームを分ける
  • メモリ内ストア: 追記専用ログを配列で保持。実物は追記専用 DB や Kafka
  • 並行制御なし: 同時追記時のバージョン競合(楽観ロック)は扱わない
  • スキーマ進化なし: イベント形式の変更(アップキャスト)は省略

参考資料