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