【アーキテクチャ】CQRS(コマンドクエリ責任分離) - j-komatsu/myCheatSheet GitHub Wiki
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
| 側面 | Command(書き込み) | Query(読み取り) |
|---|---|---|
| 目的 | データの変更 | データの取得 |
| 戻り値 | void or 成功/失敗 | データ |
| 副作用 | あり | なし |
| 最適化 | 整合性、ビジネスロジック | パフォーマンス、検索性 |
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
| メリット | 詳細 |
|---|---|
| パフォーマンス最適化 | 読み取り/書き込みで異なる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);
}
}// 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);
}
}// 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");
}
}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
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
// コマンドは不変オブジェクト
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) {}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
) {}@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()));
}
}@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())
);
}
}@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");
}
}// 同じDBだが、ServiceとRepositoryを分離
@Service
public class OrderCommandService { /* 書き込み */ }
@Service
public class OrderQueryService { /* 読み取り */ }✅ メリット: 実装がシンプル、結果整合性の問題なし
// Write: PostgreSQL
// Read: MongoDB/Elasticsearch(検索最適化)✅ メリット: パフォーマンス向上、独立スケール可能
// すべての状態変化をイベントとして記録
// 読み取りモデルはイベントから構築✅ メリット: 完全な監査ログ、時間軸のクエリ可能
❌ 問題
- コマンド実行直後のクエリで最新データが取得できない
✅ 解決策
// パターン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);
}✅ 解決策
// リトライ機構付きイベントハンドラ
@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);
}
}✅ 解決策
// 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);
}✅ 適用推奨
- 読み取りと書き込みの要件が大きく異なる
- 複雑な検索・レポート機能が必要
- 読み取りのスケーラビリティが重要
- イベントソーシングと組み合わせたい
❌ 過剰設計の可能性
- シンプルなCRUDアプリケーション
- 小規模チーム・小規模システム
- 結果整合性が許容できない
Step 1: 論理的分離(CommandService / QueryService)
↓
Step 2: 読み取り専用DBの追加(レプリケーション)
↓
Step 3: イベント駆動での同期
↓
Step 4: (必要なら)イベントソーシング統合
-
コマンドは意図を表現
-
UpdateOrderCommand❌ -
ConfirmOrderCommand✅
-
-
クエリに副作用を持たせない
- クエリは常にべき等
-
Read Modelは使い捨て
- イベントから再構築可能
- Command(コマンド): システムの状態を変更する意図
- Query(クエリ): データを取得する要求(副作用なし)
- Projection(プロジェクション): イベントから読み取りモデルを構築する処理
- Eventual Consistency(結果整合性): 一時的に不整合でも最終的に整合する
- Materialized View(実体化ビュー): 事前計算された読み取り専用ビュー
- 📖 Greg Young - CQRS Documents
- 📖 Martin Fowler - CQRS
- 📖 Vaughn Vernon著『実践ドメイン駆動設計』第4章
- 🌐 Microsoft - CQRS Pattern
課題1: シンプルなTODOアプリで、CommandServiceとQueryServiceを分離してみましょう
課題2: Spring BootでCQRSを実装し、書き込みはPostgreSQL、読み取りはMongoDBを使ってみましょう
課題3: イベントソーシングとCQRSを組み合わせて、銀行口座の入出金履歴システムを実装してみましょう
- 【アーキテクチャ】イベント駆動アーキテクチャ - CQRSと組み合わせるパターン
- 【アーキテクチャ】ドメイン駆動設計(DDD) - コマンドとドメインモデルの関係
- 【アーキテクチャ】マイクロサービスアーキテクチャ - CQRS適用の主要シーン
- 【データベース】データベースの概要と主要概念 - 読み取り/書き込みDB選定
- 【アーキテクチャ】クリーンアーキテクチャ - CQRS実装の内部構造
📝 最終更新: 2025-10-25