🦉

CQRSとEvent Sourcingをゼロから理解する

に公開

CQRS(Command Query Responsibility Segregation)は、更新系(Command)と参照系(Query)の責務をそれぞれ独立したサービスに分離するアーキテクチャパターンです。

本記事では、ドメイン駆動設計(DDD)を実践するときに直面する課題を出発点として、なぜCQRSが必要になるのか、Event Sourcingパターンがどのように組み合わされるのかを解説します。

多くの解説記事があるテーマなので二番煎じにはなりますが、なるべく前提知識がなくても体系的に理解できるような記事にしました。

ドメインモデルの操作

DDDでは、業務ルールや制約をドメインモデルとしてコードで表現します。複数のエンティティや値オブジェクトのまとまりを集約(Aggregate)として定義し、次の役割を担います。

集約の役割

  • 整合性維持の単位
    集約は業務ルールの不変条件を守る境界です。集約内部で完結する操作により一貫性を保ちます。

  • トランザクション境界の単位
    集約は基本的に1回のトランザクションで処理されます。複数集約をまたぐ更新は避けてドメインイベントで連携します[1]

リポジトリパターン

集約をDBで永続化するときは、集約単位でリポジトリを定義します。リポジトリは集約の取得と保存を担当し、データアクセスの詳細を隠蔽します。

// 注文集約
type Order struct {
  ID         uuid.UUID
  CustomerID uuid.UUID
  Items      []OrderItem
  Status     OrderStatus
}

// 注文リポジトリ
type OrderRepository interface {
  GetByID(ctx context.Context, id uuid.UUID) (*Order, error)
  Save(ctx context.Context, o *Order) error
}

CRUDパターンの課題

一般的なCRUD中心の設計では、データ操作(Create/Read/Update/Delete)を軸にモデルを構築します。しかし、この設計をDDDのドメインモデルにそのまま適用すると、ドメインモデルの本来の責務と実際の利用方法が乖離し、次のような課題がでてきます。

  1. モデル設計の肥大化
  2. DBのスケーリング問題

1.モデル設計の肥大化

UI・レポート要件がドメインに入り込む

集約はビジネスルールの一貫性や制約を保証する責務があります。しかし、CRUD中心の設計では画面表示用の計算や検索条件が集約に入り込みやすくなります。

下記の例では、注文集約のキャンセル処理や注文明細管理はドメインロジックですが、画面表示やレポート用のプロパティ・メソッドはドメインの関心事ではありません。

// 注文集約
type Order struct { ... }

// ✅ ドメインルール: 注文をキャンセルする
func (o *Order) Cancel() error { ... }

// ✅ ドメインルール: 注文明細を追加する
func (o *Order) AddItem(productID uuid.UUID, quantity int, unitPrice int64) error { ... }

// ❌ 画面表示要件: 注文の合計金額に税を加算して返す
func (o *Order) TotalWithTax(taxRate float64) int64 { ... }

// ❌ レポート要件: 注文が指定期間内かを判定する
func (o *Order) IsRecent(days int) bool { ... }
// 注文リポジトリ
type OrderRepository interface {
    GetByID(ctx context.Context, id uuid.UUID) (*Order, error)
    Save(ctx context.Context, o *Order) error
    // ❌ 画面表示要件: 注文の顧客名検索
    FindByCustomerName(ctx context.Context, name string) ([]*Order, error)
    // ❌ レポート要件: 注文の期間検索
    FindByDateRange(ctx context.Context, start, end time.Time) ([]*Order, error)
}

オーバーフェッチとN+1問題

リポジトリは集約単位でデータを復元するため、画面で必要な情報と集約の構造が一致しない場合、不要なデータ読み取りが発生します。特に一覧表示など、読み取りが多い機能ではパフォーマンスに影響します。

// 注文取得APIのレスポンス
type OrderResponse struct {
  OrderID      string    `json:"orderId"`
  Status       string    `json:"status"`
  CreatedAt    time.Time `json:"createdAt"`
  // ❌ 顧客名取得のためだけにCustomer集約を読み込む必要がある
  CustomerName string    `json:"customerName"`
}
// 注文一覧を取得(1回のクエリ)
orders := orderRepository.FindAll()

// 各注文の顧客名を表示
for _, order := range orders {
    // ❌ 各注文ごとにCustomerを取得(N回のクエリ)
    customer := customerRepository.FindByID(order.CustomerID)
    fmt.Printf("注文ID: %s, 顧客名: %s\n", order.ID, customer.Name)
}

2.DBのスケーリング問題

DBへのアクセスには「書き込み(Write)」と「読み取り(Read)」があり、それぞれで重視すべき要件が異なります。

書き込み(Write)

書き込み操作では、整合性の維持が優先です。注文キャンセルのような処理では、トランザクションやロックにより状態の一貫性を保証する必要があります。

読み取り(Read)

読み取りでは、高速な応答と負荷分散が重要です。複雑な検索条件や集計を効率よく処理するため、最適化されたアクセスが求められます。

書き込みと読み取りの特性の違い

特性 書き込み(Write) 読み取り(Read)
目的 整合性維持 高速応答
処理内容 状態変更・ドメインロジック 集計・検索
頻度 低〜中
ボトルネック ロック・トランザクション I/O・集計負荷

DBを一台で運用する構成では、2種類の操作が同時に実行されると処理が互いに影響し、パフォーマンスの低下やスケーラビリティの制約を受けてしまいます。

  • 重い集計クエリにより更新処理が遅延する
  • 書き込み時のロックで読み取りが遅くなる
  • 書き込みが集中すると、読み取りスループットが低下する

CQRSが採用される理由

上記の課題では、更新系と参照系を同一のモデル・ストアで扱うことが原因となっていました。CQRS(Command Query Responsibility Segregation)は、この問題を根本から解消するアーキテクチャパターンです。

CQRSは更新系(Command)と参照系(Query)の責務を明確に分離し、必要に応じてデータストアそのものを分離できる点が特徴です[2]

  • 更新系(Command)
    • ビジネスルールに沿った状態変更を担当する
    • 書き込み専用のモデル(Write Model)、ストア(Write Store)を使用
    • Event Sourcingパターンを用いた永続化(後述)
  • 参照系(Query)
    • 検索・表示・レポート生成などの参照処理を担当
    • 読み取り専用のモデル(Read Model)、ストア(Read Store)を使用

図1: CQRSの基本構成

CQRSはDDDと相性が良く、モデルの責務を明確にします。

Command側のモデルは、ビジネスロジックの整合性や状態遷移の保証することだけに専念でき、業務上の不変条件や振る舞いを純粋に表現することが可能になります。Query側のモデルは、画面やレポート用途に合わせてデータ構造を最適化でき、非正規化や事前計算などで、読み取り処理を軽量化できます。

コードによる分離の例

前述の注文集約は、Command用の集約(Order)とQuery用の読み取り専用モデル(OrderReadModel)に分割できます。

// Command: 注文集約
type Order struct {
    ID         uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItem
    Status     OrderStatus
}

// Command: 注文リポジトリ
type OrderRepository interface {
    GetByID(ctx context.Context, id uuid.UUID) (*Order, error)
    Save(ctx context.Context, o *Order) error
}

// Query: 読み取り専用 注文DTO
type OrderReadModel struct {
    OrderID       string
    Status        string
    CreatedAt     time.Time
    CustomerName  string // ❗ JOIN済み
    TotalWithTax  int64  // ❗ 事前計算済み(Query側で計算)
    IsRecent      bool   // ❗ 期間判定済み
}

// Query: 注文 読み取り用リポジトリ
type OrderReadRepository interface {
    FindByCustomerName(ctx context.Context, name string) ([]*OrderReadModel, error)
    FindByDateRange(ctx context.Context, start, end time.Time)([]*OrderReadModel, error)
    Search(ctx context.Context, keyword string) ([]*OrderReadModel, error)
}

Command側のOrder集約はビジネスロジックの状態のみを持ち、余計な情報を取り除くことができました。Query側のOrderReadModelTotalWithTaxIsRecentなどクライアントが欲しがる値を事前に計算・整形して持ち、1回の問い合わせで必要な情報がすべて揃う形になっています。

Event Sourcingが採用される理由

データストア分離で起こる整合性の問題

CQRSでは、Command側とQuery側のデータストアに分けると、両方を一度のトランザクションで更新できなくなります。そのため、Command側のストアが更新されたとき、両者のデータをどのように同期するかが課題となります。

2相コミットを用いた分散トランザクションで整合性を保つことは理論上は可能ですが、実装の複雑さ、障害耐性の低下などの理由から実務上は避けられる傾向にあるようです。

つまり分散トランザクションを使わずに結果整合性を保つ仕組みが求められます。

※ 結果整合性(Eventual Consistency): データの更新が即座にすべての場所に反映されなくても、最終的には整合性が保たれる状態

アウトボックスパターン

結果整合性の確保でよく用いられる方法がアウトボックスパターン(Outbox Pattern)です。Command側のトランザクション内で、ドメインモデルの状態の更新と、変更イベント(Outboxレコード)の書き込みを同時に行います。

その後、CDCがOutboxの変更を検知し、メッセージキューへイベントを送信します。Query側はイベントを購読し、受け取った内容に基づいてデータを更新します。

※ CDC(Change Data Capture): データベースの変更を検知し、他のシステムに通知・反映する仕組み

図2: アウトボックスパターンによるイベント同期

この仕組みにより、DB更新とイベント送信は同じトランザクション内で処理されるため、どちらか一方だけが反映されることはなく、必ず両方が成功するか両方が失敗します。その結果、最終的にCommand側とQuery側の状態が食い違うことなく、整合性を保つことができます。

State SourcingからEvent Sourcingへ

アウトボックスパターンでも結果整合性を保つことはできますが、状態(State)と変更イベント(Outbox)の管理が分かれるため、データ管理が二重になります。

これを簡素化して全てイベントで管理しようとするアプローチがEvent Sourcingです。イベント履歴が情報源となり、古いイベントから順に再生することでドメインモデルの最新情報を復元します。状態変更に関する全ての操作はドメインイベントとして表現されるので、更新や削除などの操作もイベントとしてDBへ書き込まれることになります。

イベントストアが新しいイベントを配信する機能を持っていれば、コマンド側で複雑なトランザクション管理やアウトボックス処理を実装する必要がなくなり、全体の構成を大幅にシンプルにできます。

図3: Event Sourcingの基本概念

Event Sourcingの実装

Event Sourcingにおけるイベントは、ドメイン上で実際に発生した事実であるため、イベント名は過去形で表現するのが一般的です。

イベントストアには以下のようにアプリケーションの状態変更が時系列で保存されます。これらのイベントを順に再生(Replay)することで、最終的な状態(在庫数 120)を算出できます。

Added(100) - Reduced(30) + Added(50) = 120

[
  {
    "id": "Inventory-001",
    "event": "InventoryAdded",
    "quantity": 100,
    "version": 1,
    "timestamp": "2025-11-18T00:00:00Z"
  },
  {
    "id": "Inventory-001",
    "event": "InventoryReduced",
    "quantity": 30,
    "version": 2,
    "timestamp": "2025-11-18T01:00:00Z"
  },
  {
    "id": "Inventory-001",
    "event": "InventoryAdded",
    "quantity": 50,
    "version": 3,
    "timestamp": "2025-11-18T02:00:00Z"
  }
]

処理フロー

Event Sourcingのフローは次のようになります。

  1. Commandを受け取る
  2. 過去イベントを順に適用して集約を再生する
  3. 集約がビジネスルールを検証してイベントを発行する
  4. Event Storeにイベントを追記
  5. Event Busでイベントを配信
  6. Read 側がイベントを購読してRead Modelを更新
  7. Queryを受け取ってデータを取得する

図4: Event Sourcingの処理フロー

※ 図は上記の3つ目のイベントが追加される際のフローを示しています。

スナップショットによる効率化

イベント数が増えると再生コストが高くなるため、定期的にスナップショットを保存し、そこからイベントを再適用する戦略が一般的です。これにより任意の時点の状態再構築が効率的に行えます。

実装例:CQRS + Event Sourcing構成

以下は、注文ドメイン(Order)を題材にした実装例です。

Command側:ドメインとイベントの定義

  • Createコマンド実行時にOrderCreatedイベントを生成
  • 状態更新はapplyメソッドを通して一貫して行う
order_event.go
// MARK: ドメインイベント
type DomainEvent interface {
    EventType() string
    OccurredAt() time.Time
}

// MARK: 注文作成イベント
type OrderCreated struct {
    OrderID      string
    CustomerID   string
    CustomerName string
    SubTotal     int64
    Occurred     time.Time
}

func (e OrderCreated) EventType() string      { return "OrderCreated" }
func (e OrderCreated) OccurredAt() time.Time  { return e.Occurred }
order_aggregate.go
// MARK: 注文集約
type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    SubTotal   int64
    Status     string
    Events     []DomainEvent // 発生したイベントの蓄積
}

func (o *Order) Create(c Customer) {
    o.ID = uuid.New().String()
    event := OrderCreated{
        OrderID:      o.ID,
        CustomerID:   c.ID,
        CustomerName: c.Name,
        SubTotal:     o.SubTotal,
        Occurred:     time.Now(),
    }
    o.apply(event)
    o.Events = append(o.Events, event)
}

// イベントを適用して状態を更新
func (o *Order) apply(event DomainEvent) {
    switch e := event.(type) {
    case OrderCreated:
        o.ID = e.OrderID
        o.CustomerID = e.CustomerID
        o.SubTotal = e.SubTotal
        o.Status = "Created"
    }
}

永続化層:Event Store

  • EventStoreは集約ごとにイベントの履歴を分けて保存します
  • イベントの追記(Append)や取得(Load)、スナップショットの管理など、シンプルなインターフェースで構成されます。
  • 実運用ではKafka、DynamoDB、EventStoreDBなどがイベントストアとして利用されることが多いようです。
event_store.go
// MARK: イベントストア
type EventStore interface {
    Append(ctx context.Context, aggregateID string, events []DomainEvent) error
    Load(ctx context.Context, aggregateID string) ([]DomainEvent, error)
}

type InMemoryEventStore struct {
    // 1つの集約ごとにのイベント履歴をもつ
    streams map[string][]DomainEvent
}

func NewInMemoryEventStore() *InMemoryEventStore {
    return &InMemoryEventStore{streams: make(map[string][]DomainEvent)}
}

func (s *InMemoryEventStore) Append(ctx context.Context, aggregateID string, events []DomainEvent) error {
    s.streams[aggregateID] = append(s.streams[aggregateID], events...)
    return nil
}

func (s *InMemoryEventStore) Load(ctx context.Context, aggregateID string) ([]DomainEvent, error) {
    return s.streams[aggregateID], nil
}

イベントレコードの構造はこのようになります。

{
    "id": "order-1234",
    "event_id": "uuid",
    "event_type": "OrderCreated",
    "version": 0,
    "payload": {
        "orderId": "order-1234",
        "customerId": "customer-999",
        "customerName": "山田太郎",
        "subTotal": 10000,
        "occurred": "2025-11-18T10:00:00Z"
    },
    "created_at": "2025-11-18T00:00:00Z"
}

Query側:Read Modelと購読

  • メッセージキューからイベントが届くと、OrderReadModelApplyメソッドを通じてイベントの内容を反映します。
order_read_model.go
// MARK: 読み取りモデル
type OrderReadModel struct {
    OrderID      string
    CustomerID   string
    Status       string
    CreatedAt    time.Time
    CustomerName string
    TotalWithTax int64
}

// MARK: イベント購読による更新
func (r *OrderReadModel) Apply(event DomainEvent) {
    switch e := event.(type) {
    case OrderCreated:
        r.OrderID = e.OrderID
        r.CustomerID = e.CustomerID
        r.Status = "Created"
        r.CreatedAt = e.Occurred
        r.CustomerName = e.CustomerName
        r.TotalWithTax = calculateTotalWithTax(e.SubTotal, 0.1)
    }
}

CQRSの採用について

CQRSはあくまでアーキテクチャパターンであり、原則ではありません。採用にはトレードオフが伴うため、慎重な検討が必要です。

特に以下の技術的課題があります。

  • 結果整合性への対応
  • Event Storeの運用管理
  • 分散トランザクション管理
  • 実装コストと学習コスト
  • etc.

システム要件が単純で複雑な処理が少ない場合は、従来の設計のまま運用する方がいい場合もあります。

※これらの課題については、また別記事で詳しく紹介する予定です。

まとめ

本記事では、DDDにおけるCRUD設計の課題から、CQRS + Event Sourcingパターンを解説しました。

CRUDパターンの課題
従来のCRUD中心の設計では、UI要件とビジネスロジックが混在し、ドメインモデルが肥大化します。また、1つのデータストアで書き込みと読み取りを最適化することは困難でした。

CQRS
CommandとQueryを独立したモデルとストアに分離することで、それぞれの責務を明確にし、最適な技術選択とスケーリングが可能になります。

Event Sourcing
状態ではなくイベント履歴を保存することで、分離されたストア間の結果整合性を保ちつつ、実装をシンプルに保つことができます。

参考資料

脚注
  1. 一般的には、複数の集約にまたがる更新を1回のトランザクションで扱うことは推奨されませんが、状況によってはユースケース層で複数集約を同時に更新する設計も許容されます。
    こちらの記事が参考になります。
    https://little-hands.hatenablog.com/entry/2021/03/08/aggregation
    https://blog.j5ik2o.me/entry/2021/03/22/102520 ↩︎

  2. 物理的なデータストア分離だけでなく、1つのデータストアで論理的なモデル分離をする方法もCQRSと呼ばれることがあります。
    こちらの記事に論理的に分割されたパターンが記載されています。
    https://enterprisecraftsmanship.com/posts/types-of-cqrs/
    https://learn.microsoft.com/azure/architecture/patterns/cqrs ↩︎

Discussion