イベントソーシング
実装:
messaging/eventsourcing// 実行:go test ./messaging/eventsourcing/
普通のアプリは現在の状態を保存する。口座なら残高を上書きする。だがこれは過去を捨てている。いつ、なぜその残高になったかは残らない。イベントソーシングは発想を逆にする。保存するのは起きた出来事の連なりで、追記だけして書き換えない。現在の残高は並びを畳み込めばいつでも導ける。履歴が丸ごと残るので監査も、過去の時点への遡りもできる。イベント追記・リプレイ・タイムトラベル・スナップショットを実装する。
この章で作るもの
メッセージキューや Pub/Subで、システム間をイベントで繋ぐ話をした。イベントソーシングは、その発想をデータの保存そのものに持ち込む。普通のアプリケーションは「現在の状態」をデータベースに保存する。口座なら残高の欄に数字があり、入金・出金のたびにその数字を上書きする。この方式は素直だが、大きなものを捨てている。過去だ。残高が今 1200 円だと分かっても、それがどんな入出金の積み重ねでそうなったのかは、どこにも残っていない。
イベントソーシングは、保存するものを逆にする。現在の状態でなく、起きた出来事(イベント)の連なりを保存する。「1000 円入金」「500 円入金」「300 円出金」という追記専用の並びが、唯一の真実だ。一度記録したイベントは決して書き換えない。では現在の残高はどうやって知るのか。イベントを頭から順に畳み込む(リプレイする)。1000 足して、500 足して、300 引いて、1200。状態は保存せず、いつでもイベントから導く。履歴が丸ごと残るので、監査ができ、過去のどの時点にも遡れる。この章では、口座を例にこの仕組みを実装する。
状態保存: 残高 [1000] → [1500] → [1200] 過去は上書きで消える
↑ 前の値はもう無い
イベント: +1000 → +500 → −300 追記だけ。消えない
└──── 畳み込む ────┘ = 残高 1200 (いつでも導ける)順に見ていく。
- イベントが真実、状態は導出: 保存するのは追記専用のイベント列。現在状態はそれを畳み込んで作る射影に過ぎない
- 履歴が丸ごと残る: 出来事は書き換えない。監査ログそのものになり、過去のどの時点にも遡れる(タイムトラベル)
- コマンドとイベントは別物: コマンド(意図)は検証を通ってはじめてイベント(事実)になる。不正な意図は記録しない
① イベントの追記とリプレイ
まずイベントと、それを溜める追記専用のストアを作る。ストアは追記だけ、上書きも削除もしない。そして現在状態は、イベントを頭から畳み込んで導く:
// 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 を指定して、そこまでのイベントだけを畳めばいい:
// 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 円出金した」という起きてしまった事実だ。コマンドは検証を通って、はじめてイベントになる。残高を超える出金の意図は、検証で弾かれ、イベントとしては記録されない:
// 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 の出金が拒否され、イベントが増えず、残高が変わらないことを固定した。
もう一つ現実的な問題がある。イベントが何万件も溜まると、毎回すべてを畳み直すのは重い。そこでスナップショットを使う。ある時点の状態を写し取っておき、以降はそのイベントだけを畳んで追いつく:
// 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 へ遡ったり、残高を超える出金が拒否される様子も確かめられる。
スライダーで過去の時点へ遡れる(タイムトラベル)。状態は保存せずイベントから導く
保存しているのは残高でなくイベント列(追記専用)。現在残高はイベントを頭から畳み込んで導く。 履歴が丸ごと残るので、任意の過去の時点に遡れる。出金コマンドは現在残高で検証され、超過なら 拒否してイベントを残さない(意図は事実にしない)。長い履歴はスナップショットで畳み込みを省ける。
設計の観点
- イベントは不変・追記専用: 一度記録したイベントは書き換えない。訂正も「取り消しイベント」を追記する。これが監査可能性と再現性の源
- コマンドとイベントを分ける: コマンドは拒否されうる意図、イベントは確定した事実。検証はコマンド時に行い、イベントは常に正しい前提で畳める
- 射影(projection)は使い捨て: 現在状態はイベントから導く射影。別の見方が欲しければ、同じイベントを別の畳み方で射影すればいい(これが次章 CQRS の読みモデルに繋がる)
- スナップショットは最適化: 真実はイベント列。スナップショットは畳み込みを速くするだけで、失っても再生成できる
- イベントスキーマの進化: 長く運用するとイベントの形が変わる。古いイベントを新しい形に変換(アップキャスト)する仕組みが要る
対照と実例
| 状態保存(CRUD) | イベントソーシング | |
|---|---|---|
| 保存するもの | 現在の状態(上書き) | 出来事の列(追記) |
| 過去 | 失われる | 完全に残る |
| 監査 | 別途ログが必要 | イベント列がログ |
| 現在状態 | そのまま読む | 畳み込んで導く |
| 複雑さ | 低い | 高い(リプレイ・射影) |
裏どり:
- Martin Fowler: Event Sourcing: 概念の定番の解説。イベント・リプレイ・スナップショットの整理
- Kafka / EventStoreDB: イベントを追記専用ログとして永続化する実基盤。リプレイ前提の設計
- 銀行の元帳(ledger): 残高を上書きせず取引を追記し続ける会計の考え方そのもの。イベントソーシングの原型
- CQRS との組み合わせ: 書き込みをイベントで、読み取りを射影で分ける。イベントソーシングと相性がよい
簡略化したこと
- 単一集約: 口座 1 つ分。実物は集約 ID ごとにイベントストリームを分ける
- メモリ内ストア: 追記専用ログを配列で保持。実物は追記専用 DB や Kafka
- 並行制御なし: 同時追記時のバージョン競合(楽観ロック)は扱わない
- スキーマ進化なし: イベント形式の変更(アップキャスト)は省略
参考資料
- Martin Fowler: Event Sourcing — 概念の定番解説
- EventStoreDB Documentation — イベントソーシング専用 DB の設計
- Designing Data-Intensive Applications, 11章 — イベントログとストリーム処理
- 実装: messaging/eventsourcing