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:#fff
CQRSの基本構造: 書き込み側はドメインモデル(集約)を通じて状態を変更し、読み取り側は非正規化されたビューから直接データを取得する

CQRSの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に追記。Projectionが読み取りモデルを非同期更新

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 上記両方のメリットを最大化 運用・開発の複雑性が最も高い

理解度チェック

問題 0 / 50%
Q1

CQS(Command-Query Separation)原則を提唱したのは誰ですか?

キーボード: 1〜4 で選択、Enter で回答