CQS原則 — すべての出発点
CQS(Command-Query Separation)は、Bertrand Meyerが1988年に著書『Object-Oriented Software Construction』で提唱した原則です。 あらゆるメソッドをコマンド(状態を変更する)かクエリ(情報を返す)の どちらか一方に分類し、1つのメソッドが両方を兼ねることを禁止します。
| コマンド(Command) | クエリ(Query) | |
|---|---|---|
| 目的 | 状態を変更する | 情報を返す |
| 戻り値 | void(なし) | 値を返す |
| 副作用 | あり | なし |
| べき等性 | 保証しない(場合による) | 常にべき等 |
| 例 | order.confirm() | order.getTotal() |
// CQS原則に従った設計
class ShoppingCart {
// コマンド: 状態を変更、戻り値なし
addItem(item: CartItem): void {
this.items.push(item);
}
// クエリ: 状態を変更しない、値を返す
getTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price),
Money.zero('JPY')
);
}
// CQS違反: 状態変更と値返却を同時に行う
// removeAndReturnItem(id: string): CartItem { ... }
} CQRS — アーキテクチャレベルの読み書き分離
CQRS(Command Query Responsibility Segregation)は、 Greg Youngが2010年に提唱した、CQS原則をアーキテクチャレベルに拡張したパターンです。 メソッドレベルの分離を超え、読み取り(Query)と書き込み(Command)でモデル自体を分離します。
graph TB
Client[クライアント] --> CMD[コマンド側]
Client --> QRY[クエリ側]
subgraph write[書き込みモデル Write Model]
CMD --> CH[Command Handler]
CH --> AGG[集約\nDomain Model]
AGG --> WS[(Write Store)]
end
subgraph read[読み取りモデル Read Model]
QRY --> QH[Query Handler]
QH --> RM[Read Model\n非正規化ビュー]
RM --> RS[(Read Store)]
end
WS -.->|同期/非同期| RS
style write fill:#1e1e2e,stroke:#f97316,color:#94a3b8
style read fill:#1e1e2e,stroke:#3b82f6,color:#94a3b8
style CMD fill:#f97316,stroke:#ea580c,color:#fff
style CH fill:#f97316,stroke:#ea580c,color:#fff
style AGG fill:#f97316,stroke:#ea580c,color:#fff
style WS fill:#f97316,stroke:#ea580c,color:#fff
style QRY fill:#3b82f6,stroke:#1d4ed8,color:#fff
style QH fill:#3b82f6,stroke:#1d4ed8,color:#fff
style RM fill:#3b82f6,stroke:#1d4ed8,color:#fff
style RS fill:#3b82f6,stroke:#1d4ed8,color:#fff
style Client fill:#8b5cf6,stroke:#6d28d9,color:#fffCQRSの2つの実装レベル
CQRSの実装には2つのレベルがあります。プロジェクトの要件に応じて適切なレベルを選択します。
| 単一データストア | 別データストア | |
|---|---|---|
| データベース | 1つのDB、読み書きで異なるモデル | 読み取り用と書き込み用で別DB |
| 整合性 | 強い整合性 | 結果整合性(Eventual Consistency) |
| 複雑度 | 低〜中 | 高 |
| スケーラビリティ | 中程度 | 読み書き独立にスケール可能 |
| 適用場面 | 読み書きのモデルが異なるが整合性が重要 | 読み取り負荷が高い、独立スケールが必要 |
イベントソーシング(Event Sourcing)
イベントソーシングは、エンティティの現在の状態ではなく、 状態に至るまでのイベント(出来事)の履歴を永続化するパターンです。 現在の状態は、イベントを最初から順番にリプレイ(再生)することで再構築します。
銀行口座を例にすると、従来のCRUDは「残高: 10,000円」という現在の状態だけを保存します。 イベントソーシングでは「入金 30,000円 → 出金 15,000円 → 入金 5,000円 → 出金 10,000円」という すべての変更履歴を保存し、残高はこれらをリプレイして算出します。
sequenceDiagram participant Client as クライアント participant Agg as 銀行口座集約 participant ES as Event Store participant Proj as Projection participant ReadDB as Read Model Note over ES: イベント履歴 Note over ES: 1. AccountOpened(初期残高: 0) Note over ES: 2. MoneyDeposited(30,000) Note over ES: 3. MoneyWithdrawn(15,000) Client->>Agg: 出金(10,000) Agg->>Agg: ビジネスルール検証 Agg->>ES: MoneyWithdrawn(10,000) 追記 ES->>Proj: 新イベント通知 Proj->>ReadDB: 残高ビュー更新(5,000)
Event Store — イベントの永続化
Event Storeはイベントを追記専用(append-only)で保存するストレージです。 イベントは不変であり、一度書き込まれたら変更・削除されません。 各集約インスタンスのイベントはストリームとして管理され、バージョン番号で順序が保証されます。
// イベントの定義
type BankAccountEvent =
| { type: 'AccountOpened'; accountId: string; ownerId: string }
| { type: 'MoneyDeposited'; amount: number }
| { type: 'MoneyWithdrawn'; amount: number };
// 集約の状態をイベントから再構築
class BankAccount {
private balance = 0;
// イベントのリプレイで状態を復元
static fromEvents(events: BankAccountEvent[]): BankAccount {
const account = new BankAccount();
for (const event of events) {
account.apply(event);
}
return account;
}
private apply(event: BankAccountEvent): void {
switch (event.type) {
case 'MoneyDeposited':
this.balance += event.amount; break;
case 'MoneyWithdrawn':
this.balance -= event.amount; break;
}
}
// コマンド: 新しいイベントを生成
withdraw(amount: number): BankAccountEvent {
if (this.balance < amount) {
throw new Error('残高不足');
}
return { type: 'MoneyWithdrawn', amount };
}
} CQRS + Event Sourcing の組み合わせ
CQRSとイベントソーシングは独立したパターンですが、組み合わせると非常に強力です。 書き込み側はイベントソーシングで集約の状態変更を記録し、 読み取り側はイベントをProjection(射影)して最適化されたビューを構築します。
| 側面 | 書き込み側(Command) | 読み取り側(Query) |
|---|---|---|
| モデル | ドメインモデル(集約) | 非正規化ビュー(DTO) |
| ストレージ | Event Store(追記専用) | Read DB(RDB、NoSQL、検索エンジン等) |
| 最適化の方向 | 書き込みパフォーマンス、整合性 | 読み取りパフォーマンス、表示用途 |
| スキーマ | イベントスキーマ(不変) | 画面やAPIに最適化されたスキーマ |
| スケーリング | 書き込み負荷に応じて | 読み取り負荷に応じて独立にスケール |
適用すべきケースと避けるべきケース
適しているケース
- 監査証跡が必須: 金融、医療、法務など変更履歴の完全な記録が法規制で求められる
- 読み取りと書き込みの負荷が大幅に異なる: 独立スケーリングが必要
- 複雑なドメインロジック: 集約のビジネスルールが豊富で、読み取りモデルとの乖離が大きい
- 時間軸での分析が必要: 「ある時点での状態」を再構築したい(テンポラルクエリ)
- イベント駆動のマイクロサービス: サービス間の非同期連携が中心
避けるべきケース
- 単純なCRUDアプリケーション: 複雑性のコストが利点を上回る
- 強い整合性が必須: 結果整合性が許容できないビジネス要件
- チームの経験が浅い: イベントソーシングの運用(スキーマ進化、スナップショット等)には習熟が必要
- 小規模プロジェクト: オーバーエンジニアリングになりやすい
メリットとデメリットの整理
| メリット | デメリット | |
|---|---|---|
| CQRS | 読み書き独立にスケール、モデルの最適化 | 複雑性の増加、結果整合性の考慮 |
| Event Sourcing | 完全な監査証跡、テンポラルクエリ、デバッグ容易 | イベントスキーマの進化、スナップショット管理、学習コスト |
| CQRS + ES | 上記両方のメリットを最大化 | 運用・開発の複雑性が最も高い |
理解度チェック
CQS(Command-Query Separation)原則を提唱したのは誰ですか?
キーボード: 1〜4 で選択、Enter で回答