状態を超えて:Go によるイベントソーシング実践入門


数十年にわたり、データを永続化する主要なアプローチは状態指向であった。我々はデータベース内のレコードを作成、読み取り、更新、削除(CRUD)する。ユーザーの詳細が変更されれば、UPDATE文を実行して古いデータを上書きする。このアプローチはシンプルでよく理解されているが、根本的な欠点がある。それは歴史を消去してしまうことだ。データの現在の状態はわかるが、そこに至るまでの背景や意図といった豊かな文脈は失われる。「なぜこの注文のステータスが『返品済み』に変更されたのか?」「この製品の価格が最後に調整されたのはいつか?」こうした問いに状態指向モデルで答えるのは、しばしば困難か、あるいは不可能である。

イベントソーシングは、強力な代替案を提示する。現在の状態だけを保存するのではなく、これまでに発生した不変のイベントの完全なシーケンスを保存するのだ。アプリケーションの状態は、このイベント履歴から派生したものとなる。銀行口座で考えてみるとわかりやすい。従来のデータベースは現在の残高(例:150 ドル)のみを保存するかもしれないが、イベントソーシングのシステムは取引の全台帳(+200 ドルの入金、-50 ドルの出金)を保存する。台帳から現在の残高を計算することは常に可能だが、残高だけから台帳を再構築することは決してできない。

この記事では、イベントソーシングの原則、複雑なドメインにおけるその大きな利点、そして Go でその中心的な概念を実装する方法を探求する。


イベントソーシングとは何か 📖

イベントソーシングとは、ビジネスドメインで発生したイベントのログを唯一の信頼できる情報源(Single Source of Truth)とするアーキテクチャパターンである。「イベント」とは、ビジネスドメインで起こった出来事の記録を指す。アグリゲートと呼ばれるエンティティの状態は、これらのイベントを順番に再生することによって導き出される。

主要な構成要素:

  • イベント (Events): 過去に起こった不変の事実であり、過去形で命名される。例:OrderPlaced(注文が行われた)、ItemAddedToCart(アイテムがカートに追加された)、PaymentProcessed(支払いが処理された)。イベントには、何が起こったかを理解するために必要な全てのデータが含まれる。
  • コマンド (Commands): アクションの実行を要求するもの。例:PlaceOrderAddItemToCart。コマンドはアグリゲートによって処理され、一つ以上のイベントが生成されるか、あるいは拒否されることがある。
  • アグリゲート (Aggregate): コマンドを処理し、イベントを生成する一貫性の境界。状態をカプセル化し、ビジネスルールを強制するビジネスオブジェクト(OrderShoppingCartなど)。その状態は直接永続化されず、常にイベントの履歴から再構築される。
  • イベントストア (Event Store): イベントを保存するデータベース。追記専用のログであり、新しいイベントの書き込みと特定のアグリゲートの全イベントストリームの読み取りに最適化されている。

典型的なフローは以下の通りだ。 クライアント -> コマンド -> アグリゲート -> イベント群 -> イベントストア


イベントで考えることの利点 📈

イベントソーシングを採用することはパラダイムシフトを伴うが、従来の CRUD システムでは達成が困難な、いくつかの強力な能力を解き放つ。

1. 完全な監査証跡

全ての状態変更が不変のイベントとして記録されるため、システムでこれまでに起こった全ての出来事に関する完璧で変更不可能なログが手に入る。これはデバッグ(「どの様な一連のアクションがこのバグを引き起こしたか?」)、監査(「このユーザーに関連する全ての変更を表示せよ」)、ビジネスインテリジェンス(「カート放棄が最も多い時間帯はいつか?」)にとって計り知れない価値がある。

2. 時間的クエリとタイムトラベル

状態はイベントログの派生物であるため、任意の時点における任意のアグリゲートの状態を再構築できる。特定のタイムスタンプやイベント番号でイベントの再生を停止するだけだ。この「タイムトラベル」能力は、履歴分析やデータがどのように変化してきたかを理解する上で非常に強力である。

3. 分離とプロジェクション (CQRS)

これは間違いなく最大の利点だろう。イベントストリームは書き込みの信頼できる情報源だが、読み取りには必ずしも最も効率的な形式ではない。イベントソーシングでは、イベントストリームをリッスンすることで、複数の高度に最適化されたリードモデル(またはプロジェクション)を作成できる。 例えば、単一のOrderPlacedイベントは、以下の目的で使用されうる。

  • 顧客の注文履歴ページを支えるリレーショナルデータベースのテーブルを更新する。
  • 注文を検索可能にするために Elasticsearch のような検索インデックスを更新する。
  • レポート用ダッシュボードにデータを供給する。

このように読み書きのモデルを分離する考え方は、**コマンド・クエリ責務分離(CQRS)**の核となるアイデアであり、イベントソーシングと自然に組み合わさるパターンである。


Go による実践的な例

これらの概念が Go のコードでどのように表現されるかを見るために、簡単なショッピングカートをモデル化してみよう。完全なイベントストアは構築しないが、アグリゲートとイベントのロジックに焦点を当てる。

1. イベントの定義

まず、イベントを構造体として定義する。多態的に扱うためにインターフェースを使用する。

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() {}

2. アグリゲートの定義

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
    }
}

3. アグリゲートの再水和(Rehydration)

カートの現在の状態を取得するために、データベースの行からフェッチするのではない。イベント履歴を取得し、それらを再生する。このプロセスは**再水和(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に保存する。


課題と考慮事項 🤔

イベントソーシングは万能薬ではない。それ自体が持つ課題も存在する。

  • 学習曲線: 使い慣れた CRUD モデルからの思考の転換が必要である。
  • 結果整合性: リードモデル(プロジェクション)は非同期に更新されることが多いため、システムは結果整合性を持つことになる。これを慎重に扱わないとユーザー体験に影響を与える可能性がある。
  • イベントのバージョニング: 時間の経過とともに、イベントの構造を変更する必要が出てくるかもしれない。これには、アップキャスティング(古いイベントを新しい形式にその場で変換する)など、イベントのバージョニングとマイグレーションのための戦略が必要となる。

結論

イベントソーシングは、データの完全な履歴記録を提供し、堅牢な監査と時間的分析を可能にし、プロジェクションを通じて高度に分離されスケーラブルなシステムを促進する強力なアーキテクチャパターンである。複雑さを伴うものの、複雑なビジネスロジックと強力な監査証跡を必要とするアプリケーションにとっては、データをモデル化し永続化するための、より根本的に堅牢で柔軟な方法を提供する。状態ではなくイベントで考えることにより、データが何であるかだけでなく、それがどのようにしてそうなったかを知るシステムを構築できるのだ。


執筆Marko Leinikka

2025年7月26日 03:00

文字数:4400
10分で読めます