LESSON 30分

ストーリー

ECサイトの商品検索が遅いと苦情が入りました。調べると、複雑なJOINを含む検索クエリと頻繁な注文更新が同じDBを共有し、互いに足を引っ張り合っていました。

高橋アーキテクト
読み取りと書き込みで、まったく異なるデータモデルを使おう。それがCQRS — Command Query Responsibility Segregation だ

CQRSとは

コマンド(書き込み)とクエリ(読み取り)の責務を分離するアーキテクチャパターンです。

graph LR
    subgraph "従来のCRUD"
        C1["Client"] --> SM["Single Model<br/>Read + Write"] --> SDB[("Single DB")]
    end

    subgraph "CQRS"
        C2["Client"] -->|"Write"| CM["Command Model"]
        CM --> WDB[("Write DB")]
        WDB -->|"Event/Sync"| RDB[("Read DB")]
        C3["Client"] -->|"Read"| QM["Query Model"]
        QM --> RDB
    end

なぜ読み書きを分離するのか

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

// 書き込み(Command): ビジネスルールの検証が中心
async function createOrder(command: CreateOrderCommand): Promise<void> {
  // バリデーション
  const user = await userRepo.findById(command.userId);
  if (!user.isActive) throw new Error("無効なユーザー");

  const product = await productRepo.findById(command.productId);
  if (product.stock < command.quantity) throw new Error("在庫不足");

  // 正規化されたデータモデルに書き込み
  await orderRepo.save({
    userId: command.userId,
    productId: command.productId,
    quantity: command.quantity,
    status: "PENDING",
  });
}

// 読み取り(Query): 表示に最適化されたデータ取得
async function getOrderDashboard(userId: string): Promise<Dashboard> {
  // 非正規化された読み取り用モデルからデータ取得
  // JOINなしで1回のクエリで必要な情報をすべて取得
  return await readDB.query(`
    SELECT * FROM order_dashboard_view
    WHERE user_id = $1
    ORDER BY created_at DESC
    LIMIT 20
  `, [userId]);
}
観点書き込み(Command)読み取り(Query)
目的ビジネスルール適用、状態変更データ表示、検索
データモデル正規化(整合性重視)非正規化(性能重視)
スケーリング比較的少量大量(読み取りは書き込みの10〜100倍)
最適化トランザクション整合性クエリ性能、キャッシュ
技術選択RDB(ACID)Elasticsearch, Redis, DynamoDB

CQRSの基本構造

// Command側(書き込み)
interface CommandHandler<TCommand> {
  execute(command: TCommand): Promise<void>;
}

class CreateOrderHandler implements CommandHandler<CreateOrderCommand> {
  constructor(
    private orderRepo: OrderRepository,
    private eventBus: EventBus
  ) {}

  async execute(command: CreateOrderCommand): Promise<void> {
    // 1. ビジネスルールを検証
    await this.validateOrder(command);

    // 2. 書き込みモデルに保存
    const order = await this.orderRepo.save(command);

    // 3. イベントを発行(読み取りモデルの更新トリガー)
    await this.eventBus.publish("order.created", {
      orderId: order.id,
      userId: command.userId,
      items: command.items,
      totalAmount: command.totalAmount,
    });
  }
}

// Query側(読み取り)
interface QueryHandler<TQuery, TResult> {
  execute(query: TQuery): Promise<TResult>;
}

class GetOrderDashboardHandler implements QueryHandler<GetDashboardQuery, Dashboard> {
  constructor(private readStore: ReadStore) {}

  async execute(query: GetDashboardQuery): Promise<Dashboard> {
    // 読み取り専用モデルから取得(JOINなし、高速)
    return await this.readStore.getDashboard(query.userId);
  }
}

CQRSの適用場面

適している場面

const goodUseCases = [
  // 1. 読み書きの負荷差が大きい
  { reads: "100,000 req/sec", writes: "1,000 req/sec" },

  // 2. 読み取りと書き込みで異なるデータモデルが最適
  { write: "正規化RDB", read: "Elasticsearch(全文検索)" },

  // 3. ドメインが複雑で、書き込みのバリデーションが多い
  { domain: "金融取引、注文管理" },

  // 4. イベントソーシングと組み合わせる
  { pattern: "Event Sourcing + CQRS" },
];

適していない場面

const badUseCases = [
  // 1. シンプルなCRUD
  { example: "ユーザープロフィール管理(読み書き同程度)" },

  // 2. 強い一貫性が必須
  { example: "銀行の残高照会(最新値が絶対に必要)" },

  // 3. 小規模なシステム
  { example: "チーム5人以下の小さなアプリ" },

  // 4. ドメインが単純
  { example: "ブログ記事の投稿・表示" },
];

CQRSの段階的導入

CQRSは段階的に導入できます。

graph TD
    subgraph L0["Level 0: 単一モデル(CRUD)"]
        A0["App"] --> DB0[("Single DB")]
    end

    subgraph L1["Level 1: コード上の分離(同じDB)"]
        CH1["Command Handler"] --> DB1[("DB")]
        QH1["Query Handler"] --> DB1
    end

    subgraph L2["Level 2: 読み取りモデルの分離"]
        CH2["Command Handler"] --> WDB2[("Write DB")]
        WDB2 -->|"Event"| RDB2[("Read DB")]
        QH2["Query Handler"] --> RDB2
    end

    subgraph L3["Level 3: Event Sourcing + CQRS"]
        CH3["Command Handler"] --> ES3[("Event Store")]
        ES3 -->|"Projection"| RDB3[("Read DB")]
        QH3["Query Handler"] --> RDB3
    end

    L0 --> L1 --> L2 --> L3

まとめ

ポイント内容
CQRSとはコマンド(書き込み)とクエリ(読み取り)の責務分離
分離の理由読み書きの性質・負荷・最適化が異なる
適用場面読み書きの負荷差大、複雑なドメイン
段階的導入コード分離 → DB分離 → Event Sourcing

チェックリスト

  • CQRSの基本概念を説明できる
  • 読み取りと書き込みの性質の違いを理解した
  • CQRSが適している場面と適していない場面を判断できる
  • 段階的導入のレベルを理解した

次のステップへ

次はRead ModelとWrite Modelの具体的な分離方法を学びます。それぞれのモデルをどう設計し、どう同期するかを深掘りします。


推定読了時間: 30分