【アーキテクチャ】CQRS(コマンドクエリ責任分離) - j-komatsu/myCheatSheet GitHub Wiki

【アーキテクチャ】CQRS(コマンドクエリ責任分離)

📋 概要(What)

CQRS(Command Query Responsibility Segregation) は、データの読み取り(Query)と書き込み(Command)の責任を明確に分離するアーキテクチャパターンです。

基本原則

graph LR
    A[Client] -->|Command| B[Write Model<br/>Command Handler]
    A -->|Query| C[Read Model<br/>Query Handler]
    B -->|Update| D[(Write DB)]
    C -->|Read| E[(Read DB)]
    B -.->|Sync| E
Loading
側面 Command(書き込み) Query(読み取り)
目的 データの変更 データの取得
戻り値 void or 成功/失敗 データ
副作用 あり なし
最適化 整合性、ビジネスロジック パフォーマンス、検索性

伝統的なCRUD vs CQRS

graph TD
    subgraph "伝統的なCRUD"
        A1[Controller] --> B1[Service]
        B1 --> C1[Repository]
        C1 --> D1[(Database)]
    end

    subgraph "CQRS"
        A2[Controller] --> B2[Command Handler]
        A2 --> B3[Query Handler]
        B2 --> C2[(Write DB)]
        B3 --> C3[(Read DB)]
        B2 -.->|Event| C3
    end
Loading

⚖️ メリット・デメリット(Why)

メリット

メリット 詳細
パフォーマンス最適化 読み取り/書き込みで異なるDBや技術を選択可能
スケーラビリティ 読み取りと書き込みを独立してスケール
複雑なクエリの簡素化 読み取り専用モデルで非正規化・集計可能
セキュリティ向上 読み取り/書き込み権限を明確に分離
ビジネスロジックの明確化 コマンドがユースケースを直接表現

デメリット・課題

課題 対策
実装の複雑性 小規模システムでは過剰設計に注意
結果整合性 最終的整合性の許容が必要
データ同期 イベント駆動で読み取りモデルを更新
学習コスト チーム全体の理解が必要

🏗️ 実装パターン(How)

パターン1: 単一DB、論理的分離(最もシンプル)

// Command側(書き込み)
@Service
public class OrderCommandService {
    private final OrderRepository orderRepository;

    @Transactional
    public OrderId createOrder(CreateOrderCommand command) {
        Order order = new Order(command.getCustomerId(), command.getItems());
        orderRepository.save(order);
        return order.getId();
    }

    @Transactional
    public void confirmOrder(ConfirmOrderCommand command) {
        Order order = orderRepository.findById(command.getOrderId())
            .orElseThrow(() -> new OrderNotFoundException());
        order.confirm();
        orderRepository.save(order);
    }
}

// Query側(読み取り)
@Service
public class OrderQueryService {
    private final OrderReadRepository orderReadRepository;

    public OrderDetailView getOrderDetail(Long orderId) {
        return orderReadRepository.findOrderDetailById(orderId)
            .orElseThrow(() -> new OrderNotFoundException());
    }

    public List<OrderSummaryView> getCustomerOrders(String customerId) {
        return orderReadRepository.findOrderSummariesByCustomerId(customerId);
    }
}

パターン2: 物理的に分離したDB

// Write Model(正規化されたドメインモデル)
@Entity
@Table(name = "orders")
public class Order {
    @Id
    private Long id;
    private String customerId;
    private OrderStatus status;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;

    public void confirm() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("Cannot confirm order");
        }
        this.status = OrderStatus.CONFIRMED;
        // ドメインイベント発行
        DomainEventPublisher.publish(new OrderConfirmedEvent(this.id));
    }
}

// Read Model(非正規化、検索最適化)
@Document(collection = "order_views")
public class OrderView {
    @Id
    private String id;
    private String customerName; // JOINなしで取得可能
    private String customerEmail;
    private BigDecimal totalAmount;
    private String status;
    private List<OrderItemView> items; // 埋め込み
    private Instant createdAt;
}

// イベントでRead Modelを更新
@Component
public class OrderViewUpdater {
    private final OrderViewRepository orderViewRepository;

    @EventListener
    public void handleOrderConfirmed(OrderConfirmedEvent event) {
        OrderView view = orderViewRepository.findById(event.getOrderId())
            .orElseThrow();
        view.setStatus("CONFIRMED");
        orderViewRepository.save(view);
    }
}

パターン3: イベントソーシング + CQRS

// Command Handler
@Component
public class OrderCommandHandler {
    private final EventStore eventStore;

    public void handle(CreateOrderCommand command) {
        // イベント生成
        OrderCreatedEvent event = new OrderCreatedEvent(
            UUID.randomUUID(),
            command.getCustomerId(),
            command.getItems()
        );

        // イベントストアに永続化
        eventStore.save(event);
    }

    public void handle(ConfirmOrderCommand command) {
        // イベントから現在状態を復元
        List<DomainEvent> events = eventStore.getEvents(command.getOrderId());
        Order order = Order.fromEvents(events);

        // ビジネスロジック実行
        order.confirm();

        // 新しいイベントを保存
        eventStore.save(order.getUncommittedEvents());
    }
}

// Query Handler(プロジェクション)
@Component
public class OrderProjection {
    private final OrderReadModelRepository readRepo;

    @EventListener
    public void on(OrderCreatedEvent event) {
        OrderReadModel readModel = new OrderReadModel();
        readModel.setOrderId(event.getOrderId());
        readModel.setCustomerId(event.getCustomerId());
        readModel.setStatus("CREATED");
        readRepo.save(readModel);
    }

    @EventListener
    public void on(OrderConfirmedEvent event) {
        readRepo.updateStatus(event.getOrderId(), "CONFIRMED");
    }
}

📊 アーキテクチャ図

CQRS + イベント駆動の全体像

graph TD
    A[User Interface] -->|Command| B[Command API]
    A -->|Query| C[Query API]

    B --> D[Command Handler]
    D --> E[(Write DB<br/>PostgreSQL)]
    D -->|Event| F[Event Bus]

    F -->|Subscribe| G[Projection Handler]
    G --> H[(Read DB<br/>MongoDB/Elasticsearch)]

    C --> I[Query Handler]
    I --> H

    style E fill:#ffcccc
    style H fill:#ccffcc
    style F fill:#f9f
Loading

データフロー

sequenceDiagram
    participant UI as User
    participant CMD as Command Handler
    participant WDB as Write DB
    participant EB as Event Bus
    participant PROJ as Projection
    participant RDB as Read DB
    participant QRY as Query Handler

    UI->>CMD: CreateOrderCommand
    CMD->>WDB: Save Order
    CMD->>EB: OrderCreatedEvent
    EB->>PROJ: Handle Event
    PROJ->>RDB: Update Read Model

    Note over UI,QRY: 別のリクエスト(読み取り)

    UI->>QRY: GetOrderQuery
    QRY->>RDB: Fetch Read Model
    RDB-->>QRY: OrderView
    QRY-->>UI: Return Data
Loading

💡 実装例(Spring Boot)

Command定義

// コマンドは不変オブジェクト
public record CreateOrderCommand(
    String customerId,
    List<OrderItemDto> items,
    String deliveryAddress
) {
    // バリデーション
    public CreateOrderCommand {
        if (customerId == null || customerId.isBlank()) {
            throw new IllegalArgumentException("Customer ID is required");
        }
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order items are required");
        }
    }
}

public record ConfirmOrderCommand(Long orderId) {}
public record CancelOrderCommand(Long orderId, String reason) {}

Query定義

public record GetOrderDetailQuery(Long orderId) {}
public record GetCustomerOrdersQuery(String customerId, int page, int size) {}

// クエリ結果のDTO
public record OrderDetailView(
    Long orderId,
    String customerName,
    String status,
    BigDecimal totalAmount,
    List<OrderItemView> items,
    Instant createdAt
) {}

Command Handler

@Component
public class OrderCommandHandler {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public OrderId handle(CreateOrderCommand command) {
        // ビジネスロジック実行
        Order order = Order.create(
            command.customerId(),
            command.items(),
            command.deliveryAddress()
        );

        orderRepository.save(order);

        // イベント発行
        eventPublisher.publishEvent(
            new OrderCreatedEvent(order.getId(), order.getCustomerId())
        );

        return order.getId();
    }

    @Transactional
    public void handle(ConfirmOrderCommand command) {
        Order order = orderRepository.findById(command.orderId())
            .orElseThrow(() -> new OrderNotFoundException(command.orderId()));

        order.confirm();
        orderRepository.save(order);

        eventPublisher.publishEvent(new OrderConfirmedEvent(command.orderId()));
    }
}

Query Handler

@Component
public class OrderQueryHandler {
    private final JdbcTemplate jdbcTemplate;

    public OrderDetailView handle(GetOrderDetailQuery query) {
        // 非正規化されたViewテーブルから取得(高速)
        String sql = """
            SELECT
                o.order_id,
                o.customer_name,
                o.status,
                o.total_amount,
                o.created_at,
                json_agg(oi.*) as items
            FROM order_view o
            LEFT JOIN order_item_view oi ON o.order_id = oi.order_id
            WHERE o.order_id = ?
            GROUP BY o.order_id
        """;

        return jdbcTemplate.queryForObject(
            sql,
            new OrderDetailViewRowMapper(),
            query.orderId()
        );
    }

    public Page<OrderSummaryView> handle(GetCustomerOrdersQuery query) {
        // ElasticsearchやMongoDBなど検索に最適化したDBを使用可能
        return orderViewRepository.findByCustomerId(
            query.customerId(),
            PageRequest.of(query.page(), query.size())
        );
    }
}

Read Model更新(Projection)

@Component
public class OrderViewProjection {
    private final OrderViewRepository orderViewRepository;
    private final CustomerClient customerClient;

    @EventListener
    @Async
    public void on(OrderCreatedEvent event) {
        // 非同期で読み取りモデルを更新
        CustomerDto customer = customerClient.getCustomer(event.getCustomerId());

        OrderView view = new OrderView();
        view.setOrderId(event.getOrderId());
        view.setCustomerName(customer.getName());
        view.setCustomerEmail(customer.getEmail());
        view.setStatus("CREATED");
        view.setCreatedAt(Instant.now());

        orderViewRepository.save(view);
    }

    @EventListener
    @Async
    public void on(OrderConfirmedEvent event) {
        orderViewRepository.updateStatus(event.getOrderId(), "CONFIRMED");
    }
}

🔄 CQRS適用レベル

レベル1: 論理分離のみ(推奨開始点)

// 同じDBだが、ServiceとRepositoryを分離
@Service
public class OrderCommandService { /* 書き込み */ }

@Service
public class OrderQueryService { /* 読み取り */ }

✅ メリット: 実装がシンプル、結果整合性の問題なし ⚠️ 制限: スケール性や技術選択の柔軟性は低い

レベル2: 物理的分離(読み取りDB追加)

// Write: PostgreSQL
// Read: MongoDB/Elasticsearch(検索最適化)

✅ メリット: パフォーマンス向上、独立スケール可能 ⚠️ 制限: 結果整合性の考慮が必要

レベル3: イベントソーシング統合

// すべての状態変化をイベントとして記録
// 読み取りモデルはイベントから構築

✅ メリット: 完全な監査ログ、時間軸のクエリ可能 ⚠️ 制限: 実装が最も複雑


⚠️ よくある課題と対策

1. 結果整合性の扱い

問題

  • コマンド実行直後のクエリで最新データが取得できない

解決策

// パターンA: コマンドからIDを返し、UIでポーリング
public OrderId createOrder(CreateOrderCommand cmd) {
    // ...
    return orderId;
}

// UI側
const orderId = await createOrder(command);
// 少し待ってからクエリ
await sleep(100);
const order = await getOrder(orderId);

// パターンB: WebSocketでプッシュ通知
@EventListener
public void on(OrderCreatedEvent event) {
    websocketService.notifyUser(event.getCustomerId(), event);
}

2. データ同期の遅延・失敗

解決策

// リトライ機構付きイベントハンドラ
@Component
public class ResilientProjection {

    @Retryable(
        value = {DataAccessException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    @EventListener
    public void on(OrderCreatedEvent event) {
        updateReadModel(event);
    }

    @Recover
    public void recover(DataAccessException e, OrderCreatedEvent event) {
        // リトライ失敗時はDead Letter Queueへ
        deadLetterQueue.send(event);
        alertService.notify("Projection failed for: " + event);
    }
}

3. 複雑なクエリの実装

解決策

// Read Model側で事前に非正規化・集計
@Document
public class CustomerOrderSummary {
    private String customerId;
    private String customerName;
    private Integer totalOrders;
    private BigDecimal totalSpent;
    private List<RecentOrderView> recentOrders; // 埋め込み
    private Instant lastUpdated;
}

// プロジェクションで更新
@EventListener
public void on(OrderCreatedEvent event) {
    CustomerOrderSummary summary = repository.findByCustomerId(event.getCustomerId());
    summary.setTotalOrders(summary.getTotalOrders() + 1);
    summary.setTotalSpent(summary.getTotalSpent().add(event.getAmount()));
    summary.getRecentOrders().add(0, new RecentOrderView(event));
    repository.save(summary);
}

✅ まとめとベストプラクティス

CQRSを適用すべきケース

適用推奨

  • 読み取りと書き込みの要件が大きく異なる
  • 複雑な検索・レポート機能が必要
  • 読み取りのスケーラビリティが重要
  • イベントソーシングと組み合わせたい

過剰設計の可能性

  • シンプルなCRUDアプリケーション
  • 小規模チーム・小規模システム
  • 結果整合性が許容できない

段階的導入のススメ

Step 1: 論理的分離(CommandService / QueryService)
  ↓
Step 2: 読み取り専用DBの追加(レプリケーション)
  ↓
Step 3: イベント駆動での同期
  ↓
Step 4: (必要なら)イベントソーシング統合

重要な原則

  1. コマンドは意図を表現

    • UpdateOrderCommand
    • ConfirmOrderCommand
  2. クエリに副作用を持たせない

    • クエリは常にべき等
  3. Read Modelは使い捨て

    • イベントから再構築可能

📚 付録

用語集

  • Command(コマンド): システムの状態を変更する意図
  • Query(クエリ): データを取得する要求(副作用なし)
  • Projection(プロジェクション): イベントから読み取りモデルを構築する処理
  • Eventual Consistency(結果整合性): 一時的に不整合でも最終的に整合する
  • Materialized View(実体化ビュー): 事前計算された読み取り専用ビュー

推奨参考資料

練習課題

課題1: シンプルなTODOアプリで、CommandServiceとQueryServiceを分離してみましょう

課題2: Spring BootでCQRSを実装し、書き込みはPostgreSQL、読み取りはMongoDBを使ってみましょう

課題3: イベントソーシングとCQRSを組み合わせて、銀行口座の入出金履歴システムを実装してみましょう


🔗 次に読むべき関連トピック

  • 【アーキテクチャ】イベント駆動アーキテクチャ - CQRSと組み合わせるパターン
  • 【アーキテクチャ】ドメイン駆動設計(DDD) - コマンドとドメインモデルの関係
  • 【アーキテクチャ】マイクロサービスアーキテクチャ - CQRS適用の主要シーン
  • 【データベース】データベースの概要と主要概念 - 読み取り/書き込みDB選定
  • 【アーキテクチャ】クリーンアーキテクチャ - CQRS実装の内部構造

📝 最終更新: 2025-10-25

⚠️ **GitHub.com Fallback** ⚠️