数十年にわたり、データを永続化する主要なアプローチは状態指向であった。我々はデータベース内のレコードを作成、読み取り、更新、削除(CRUD)する。ユーザーの詳細が変更されれば、UPDATE
文を実行して古いデータを上書きする。このアプローチはシンプルでよく理解されているが、根本的な欠点がある。それは歴史を消去してしまうことだ。データの現在の状態はわかるが、そこに至るまでの背景や意図といった豊かな文脈は失われる。「なぜこの注文のステータスが『返品済み』に変更されたのか?」「この製品の価格が最後に調整されたのはいつか?」こうした問いに状態指向モデルで答えるのは、しばしば困難か、あるいは不可能である。
イベントソーシングは、強力な代替案を提示する。現在の状態だけを保存するのではなく、これまでに発生した不変のイベントの完全なシーケンスを保存するのだ。アプリケーションの状態は、このイベント履歴から派生したものとなる。銀行口座で考えてみるとわかりやすい。従来のデータベースは現在の残高(例:150 ドル)のみを保存するかもしれないが、イベントソーシングのシステムは取引の全台帳(+200 ドルの入金、-50 ドルの出金)を保存する。台帳から現在の残高を計算することは常に可能だが、残高だけから台帳を再構築することは決してできない。
この記事では、イベントソーシングの原則、複雑なドメインにおけるその大きな利点、そして Go でその中心的な概念を実装する方法を探求する。
イベントソーシングとは、ビジネスドメインで発生したイベントのログを唯一の信頼できる情報源(Single Source of Truth)とするアーキテクチャパターンである。「イベント」とは、ビジネスドメインで起こった出来事の記録を指す。アグリゲートと呼ばれるエンティティの状態は、これらのイベントを順番に再生することによって導き出される。
OrderPlaced
(注文が行われた)、ItemAddedToCart
(アイテムがカートに追加された)、PaymentProcessed
(支払いが処理された)。イベントには、何が起こったかを理解するために必要な全てのデータが含まれる。PlaceOrder
、AddItemToCart
。コマンドはアグリゲートによって処理され、一つ以上のイベントが生成されるか、あるいは拒否されることがある。Order
やShoppingCart
など)。その状態は直接永続化されず、常にイベントの履歴から再構築される。典型的なフローは以下の通りだ。
クライアント
-> コマンド
-> アグリゲート
-> イベント群
-> イベントストア
イベントソーシングを採用することはパラダイムシフトを伴うが、従来の CRUD システムでは達成が困難な、いくつかの強力な能力を解き放つ。
全ての状態変更が不変のイベントとして記録されるため、システムでこれまでに起こった全ての出来事に関する完璧で変更不可能なログが手に入る。これはデバッグ(「どの様な一連のアクションがこのバグを引き起こしたか?」)、監査(「このユーザーに関連する全ての変更を表示せよ」)、ビジネスインテリジェンス(「カート放棄が最も多い時間帯はいつか?」)にとって計り知れない価値がある。
状態はイベントログの派生物であるため、任意の時点における任意のアグリゲートの状態を再構築できる。特定のタイムスタンプやイベント番号でイベントの再生を停止するだけだ。この「タイムトラベル」能力は、履歴分析やデータがどのように変化してきたかを理解する上で非常に強力である。
これは間違いなく最大の利点だろう。イベントストリームは書き込みの信頼できる情報源だが、読み取りには必ずしも最も効率的な形式ではない。イベントソーシングでは、イベントストリームをリッスンすることで、複数の高度に最適化されたリードモデル(またはプロジェクション)を作成できる。
例えば、単一のOrderPlaced
イベントは、以下の目的で使用されうる。
このように読み書きのモデルを分離する考え方は、**コマンド・クエリ責務分離(CQRS)**の核となるアイデアであり、イベントソーシングと自然に組み合わさるパターンである。
これらの概念が Go のコードでどのように表現されるかを見るために、簡単なショッピングカートをモデル化してみよう。完全なイベントストアは構築しないが、アグリゲートとイベントのロジックに焦点を当てる。
まず、イベントを構造体として定義する。多態的に扱うためにインターフェースを使用する。
package main
// Eventは全てのドメインイベントのインターフェースである。
type Event interface {
isEvent() // マーカーメソッド
}
// ItemAddedはアイテムがカートに追加された時のイベントを表す。
type ItemAdded struct {
ItemID string
Price float64
Quantity int
}
func (e ItemAdded) isEvent() {}
// CartCheckedOutはカートが精算された時のイベントを表す。
type CartCheckedOut struct {
CheckoutID string
}
func (e CartCheckedOut) isEvent() {}
ShoppingCart
アグリゲートは状態を保持し、その状態はイベントを適用することで構築される。フィールドがエクスポートされていない点に注目してほしい。状態変更は内部で制御される。
package main
import "fmt"
// ShoppingCartは我々のアグリゲートである。
type ShoppingCart struct {
ID string
items map[string]int
totalCost float64
checkedOut bool
}
// NewShoppingCartは空のカートを作成する。
func NewShoppingCart(id string) *ShoppingCart {
return &ShoppingCart{
ID: id,
items: make(map[string]int),
}
}
// AddItemはコマンドを処理し、結果のイベントを返す。
// 実際のアプリでは、AddItemやCheckoutなどの複数のメソッドに分割されるだろう。
func (c *ShoppingCart) AddItem(itemID string, price float64, quantity int) (Event, error) {
if c.checkedOut {
return nil, fmt.Errorf("精算済みのカートにアイテムは追加できない")
}
if quantity <= 0 {
return nil, fmt.Errorf("数量は正でなければならない")
}
// コマンドは有効なので、イベントを生成する
return ItemAdded{ItemID: itemID, Price: price, Quantity: quantity}, nil
}
// Applyはイベントに基づいてアグリゲートの状態を変更する。
// 状態が変更されるべき唯一の場所がここである。
func (c *ShoppingCart) Apply(event Event) {
switch e := event.(type) {
case ItemAdded:
c.items[e.ItemID] += e.Quantity
c.totalCost += e.Price * float64(e..Quantity)
case CartCheckedOut:
c.checkedOut = true
}
}
カートの現在の状態を取得するために、データベースの行からフェッチするのではない。イベント履歴を取得し、それらを再生する。このプロセスは**再水和(Rehydration)**と呼ばれる。
package main
// RehydrateShoppingCartはイベント履歴からカートの状態を再構築する。
func RehydrateShoppingCart(id string, events []Event) *ShoppingCart {
cart := NewShoppingCart(id)
for _, event := range events {
cart.Apply(event)
}
return cart
}
// --- 概念的なイベントストア ---
// type EventStore interface {
// GetEvents(aggregateID string) []Event
// SaveEvents(aggregateID string, events []Event) error
// }
実際のアプリケーションでは、コマンドハンドラはまずアグリゲートをRehydrate
し、次にコマンドメソッド(AddItem
)を呼び出し、イベントが生成されればそれをEventStore
に保存する。
イベントソーシングは万能薬ではない。それ自体が持つ課題も存在する。
イベントソーシングは、データの完全な履歴記録を提供し、堅牢な監査と時間的分析を可能にし、プロジェクションを通じて高度に分離されスケーラブルなシステムを促進する強力なアーキテクチャパターンである。複雑さを伴うものの、複雑なビジネスロジックと強力な監査証跡を必要とするアプリケーションにとっては、データをモデル化し永続化するための、より根本的に堅牢で柔軟な方法を提供する。状態ではなくイベントで考えることにより、データが何であるかだけでなく、それがどのようにしてそうなったかを知るシステムを構築できるのだ。