CQRSはなぜEvent Sourcingになるのか
CQRSを実装すると、C側からQ側への同期問題に直面し、イベントソーシングに至る。これはオプションではなく、実装上の必然である。
よくある誤解: 「CQRSはモデルを分ける必要がない」
この解釈は危険な誤読である。
正しい意味は「システムのうち、CQRS領域と非CQRS領域に分けることができ、CQRSを部分導入できる」ということ。モデルを分けなくてよいのは非CQRS領域であり、CQRS領域内ではコマンドモデルとクエリモデルの分割は必須。
システム全体
├── CQRS領域 → モデル分割は必須
└── 非CQRS領域 → 分割不要(従来のCRUDで十分)
「CQRSはモデルを分割しなくてもいい」という解釈は、もはやCQRSではない。
C側とQ側のデータの違い
CQRSにおいて、C側(コマンド)とQ側(クエリ)のデータは根本的に異なる。
具体例: カートシステム
C側テーブル(ドメインモデルの永続化に必要な最小データ):
| カートテーブル | カートアイテムテーブル |
|---|---|
| カートID (PK) | カートアイテムID (PK) |
| 顧客アカウントID | カートID (FK) |
| 上限予算金額 | 商品ID |
| 作成日時 | 数量 |
| 作成日時 |
Q側テーブル(表示・検索に必要なデータ):
| カートテーブル | カートアイテムテーブル |
|---|---|
| カートID (PK) | カートアイテムID (PK) |
| 顧客アカウントID | 商品ID |
| 顧客アカウント名 ★ | 商品名 ★ |
| 上限予算金額 | 数量 |
| 合計金額 ★ | 単価 ★ |
| 価格 ★ |
★の値はC側のデータベースに存在しない。ドメインオブジェクトの振る舞いによって計算される派生値である。
case class Cart(id: CartId, items: CartItems, ...) {
// 合計金額はドメインオブジェクトの計算結果であり、DBに保存されない
def totalPrice(priceResolver: ItemId => Price): Price =
items.fold(Price.zero){ (t, item) => t + item.price(priceResolver) }
}
case class CartItem(id: CartItemId, itemId: ItemId, quantity: Quantity, ...) {
// 単価は外部から提供され、価格は計算される
def price(priceResolver: ItemId => Price): Price =
priceResolver(itemId) * quantity
}
同期方法の段階的検討と限界
方法1: トリガーによる同期
C側のテーブル更新時にSQLでQ側を書き込む。
限界:
- 静的データ(顧客名等)の転送には有効
- 計算された値(★)は同期できない - ドメインロジックの計算結果はDBに存在しない
- 同じ計算ロジックのSQLを書くのは困難であり、ビジネスロジックの重複を生む
- 苦肉の策として計算結果もC側に保存すると、リポジトリのインターフェースが歪む
// ❌ リポジトリに計算ロジックの責務が漏れる
trait CartRepository {
def store(cart: Cart, priceResolver: ItemId => Price): Unit
// priceResolverはリポジトリの責務ではない
}
方法2: ポーリングによる同期
プログラムでC側テーブルを読み込み、ドメインオブジェクトで計算後、Q側に書き込む。
限界:
- 計算結果のQ側転送は可能
- 「いつ変更されたか」を検知できない - 変更トリガーがない
- 全集約をポーリングする必要があり、スケーラビリティがない
- 大量の集約が存在する場合、実用的ではない
結論: 最新状態を手に入れるにしても、更新イベントが必要。
方法3: イベント通知キューの導入
変更時にイベントをキューに発行し、Q側更新プログラムがイベントを受信して同期する。
C側リポジトリ → RDB(テーブルA) + メッセージキュー(更新イベント)
↓
リードモデル更新プロセス
↓
C側テーブルから集約再現 → Q側テーブル書き込み
限界: ダブルコミット問題が発生する。
- RDBとメッセージキューは異なるストレージ
- 同一トランザクションに統合できない
- RDBへの書き込み成功 + キューへの書き込み失敗(またはその逆)が起こりうる
- 高いコストを払う可能性がある
必然的な到達点: イベントソーシング
ダブルコミット問題を回避するには、イベントを真のデータソースにする。
設計の転換
従来:
C側DB(状態を保存) + メッセージキュー(通知用)
→ 2つのストレージへの書き込み = ダブルコミット問題
イベントソーシング:
イベントストア(イベントが真のデータソース)
→ 1つのストレージへの書き込みのみ
→ C側の状態はイベントから導出
→ Q側の状態もイベントから導出
結果
- C側のDBはRDBである必要がなくなる(イベントの追記とIDごとの読み込みが可能なシステムであれば何でもよい)
- NoSQL(KVS)が適している場合が多い
- DynamoDB Streamsなどでスケーラブルにイベント読み込みが可能
- これがEvent Sourcing
イベントストア(真のデータソース)
│
├──→ C側: イベントからドメイン状態を再構築
│
└──→ Q側: イベントからリードモデルを構築
(計算された値も含めて自由に構築可能)
CQRSシステムのほとんどがEvent Sourcingを採用する理由
Lightbend社の調査によると、CQRSシステムのほとんどがEvent Sourcingを採用している。
| 段階 | 方法 | 問題 |
|---|---|---|
| 1 | トリガー | 計算された値を同期できない |
| 2 | ポーリング | 変更検知できない、スケールしない |
| 3 | イベントキュー | ダブルコミット問題 |
| 4 | Event Sourcing | 上記すべてを解決 |
Event Sourcingはオプションではなく、CQRSを真剣に実装すると必然的に到達する設計パターンである。
判断フロー
CQRSの導入を検討している
↓
C側とQ側のデータは同一か?
├─ YES → CQRSは不要。従来のCRUDで十分
└─ NO(Q側に計算値・結合データがある) ↓
C→Qの同期をどうするか?
├─ トリガー → 計算値は同期できない
├─ ポーリング → 変更検知・スケーラビリティの問題
├─ イベントキュー → ダブルコミット問題
└─ Event Sourcing → すべて解決
関連スキルとの関係
| スキル | 関係 |
|---|---|
cqrs-tradeoffs | CQRSの一貫性・可用性・スケーラビリティ。本スキルはESが必要な理由 |
aggregate-transaction-boundary | トランザクション境界。本スキルはC→Q同期の問題 |
cross-aggregate-constraints | 集約間制約。本スキルとは別次元の問題 |
レビューチェックリスト
CQRS設計
- CQRS領域と非CQRS領域を明確に分離しているか
- CQRS領域内でコマンドモデルとクエリモデルを分割しているか
- 「CQRSだがモデルは分けない」という矛盾した設計になっていないか
C→Q同期
- Q側に必要なデータの中に、C側DBに存在しない計算値があるか確認したか
- 計算値がある場合、トリガーでは解決できないことを理解しているか
- C→Q同期の仕組み(イベント駆動)が設計されているか
- ダブルコミット問題を認識し、対処しているか
Event Sourcing
- イベントを真のデータソースとする設計か
- C側の状態がイベントから導出可能か
- Q側のリードモデルがイベントから構築可能か
関連スキル(併読推奨)
このスキルを使用する際は、以下のスキルも併せて参照すること:
cqrs-tradeoffs: CQRS採用判断のトレードオフ分析cqrs-aggregate-modeling: イベントソーシング下での集約モデリングaggregate-transaction-boundary: イベントストアによるダブルコミット問題の解消