CQRS(Command Query Responsibility Segregation)
是什麼?
CQRS 把系統的操作分成兩類,並為它們設計獨立的模型:
- Command(命令):改變系統狀態的操作(新增、修改、刪除)
- Query(查詢):讀取資料但不改變任何狀態
傳統做法是讀寫共用一個模型(例如同一個 Entity + Repository)。CQRS 主張:讀和寫的需求天生不同,分開設計能讓每一端都達到最佳效能與可維護性。
ℹ️DDD 戰術模式
CQRS 經常搭配 Domain-Driven Design 使用。在 DDD 中,Command 端承載核心業務邏輯(Aggregate、Domain Event),Query 端則可以用最適合呈現需求的扁平化 DTO 直接回傳。
核心觀念
Command vs Query 分離
| 面向 | Command(命令) | Query(查詢) | |------|-----------------|---------------| | 目的 | 改變狀態 | 讀取資料 | | 回傳值 | 無(或只回傳 ID) | 資料 DTO | | 驗證 | 完整的業務規則驗證 | 無需驗證 | | 模型複雜度 | 高(Aggregate、Domain Event) | 低(扁平化 DTO) |
獨立的讀寫模型
- Write Model:豐富的領域模型,保護業務不變條件(invariant),產生 Domain Event
- Read Model:針對查詢場景優化的反正規化檢視(Denormalized View),可以是不同的資料庫、快取、甚至搜尋引擎
常見誤區
⚠️不是每個系統都需要 CQRS
簡單 CRUD 應用套上 CQRS 只會增加複雜度。明確的導入判斷標準:(1) 讀寫比例嚴重失衡(如讀 100 次才寫 1 次);(2) 讀取模型和寫入模型差異大(如寫入用正規化的 Aggregate,讀取需要跨多表 JOIN 的報表);(3) 需要獨立擴展讀寫端的效能;(4) 需要不同的資料存儲(寫入用 PostgreSQL,讀取用 Elasticsearch)。四個條件至少符合一個,再考慮導入 CQRS。
實作流程
識別 Command 與 Query
盤點所有 API,將操作分為「改變狀態」與「讀取資料」兩類
分離模型
為 Command 端設計 Aggregate,為 Query 端設計扁平化 Read DTO
實作 Command Handler
處理命令、執行業務邏輯、寫入 Write Store
實作 Query Handler
從 Read Store 直接查詢,回傳最適合前端的 DTO
同步讀寫模型
透過 Domain Event 或 Change Data Capture 將寫入端的變更投影到讀取端
程式碼範例
C# 版本
// Command:改變狀態
public record CreateOrderCommand(string CustomerId, List<OrderItem> Items);
public class CreateOrderHandler
{
private readonly IOrderRepository _repo;
private readonly IEventBus _eventBus;
public CreateOrderHandler(IOrderRepository repo, IEventBus eventBus)
{
_repo = repo;
_eventBus = eventBus;
}
public async Task<Guid> Handle(CreateOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
await _repo.Save(order);
await _eventBus.Publish(new OrderCreatedEvent(order.Id));
return order.Id;
}
}
// Query:讀取資料
public record GetOrderQuery(Guid OrderId);
public record OrderDto(Guid Id, string CustomerName, decimal Total, string Status);
public class GetOrderHandler
{
private readonly IDbConnection _readDb;
public GetOrderHandler(IDbConnection readDb)
{
_readDb = readDb;
}
public async Task<OrderDto> Handle(GetOrderQuery query)
{
return await _readDb.QuerySingleAsync<OrderDto>(
"SELECT Id, CustomerName, Total, Status FROM OrderReadView WHERE Id = @Id",
new { Id = query.OrderId });
}
}TypeScript 版本
// Command:改變狀態
interface CreateOrderCommand {
customerId: string;
items: OrderItem[];
}
class CreateOrderHandler {
constructor(
private repo: OrderRepository,
private eventBus: EventBus
) {}
async handle(cmd: CreateOrderCommand): Promise<string> {
const order = Order.create(cmd.customerId, cmd.items);
await this.repo.save(order);
await this.eventBus.publish({ type: "OrderCreated", orderId: order.id });
return order.id;
}
}
// Query:讀取資料
interface GetOrderQuery {
orderId: string;
}
interface OrderDto {
id: string;
customerName: string;
total: number;
status: string;
}
class GetOrderHandler {
constructor(private readDb: ReadDatabase) {}
async handle(query: GetOrderQuery): Promise<OrderDto> {
return this.readDb.queryOne<OrderDto>(
"SELECT id, customer_name, total, status FROM order_read_view WHERE id = $1",
[query.orderId]
);
}
}Python 版本
from dataclasses import dataclass
from uuid import UUID, uuid4
# Command:改變狀態
@dataclass
class CreateOrderCommand:
customer_id: str
items: list
class CreateOrderHandler:
def __init__(self, repo, event_bus):
self._repo = repo
self._event_bus = event_bus
async def handle(self, cmd: CreateOrderCommand) -> UUID:
order = Order.create(cmd.customer_id, cmd.items)
await self._repo.save(order)
await self._event_bus.publish(OrderCreatedEvent(order.id))
return order.id
# Query:讀取資料
@dataclass
class GetOrderQuery:
order_id: UUID
@dataclass
class OrderDto:
id: UUID
customer_name: str
total: float
status: str
class GetOrderHandler:
def __init__(self, read_db):
self._read_db = read_db
async def handle(self, query: GetOrderQuery) -> OrderDto:
row = await self._read_db.fetch_one(
"SELECT id, customer_name, total, status FROM order_read_view WHERE id = $1",
query.order_id,
)
return OrderDto(**row)架構圖
此圖展示 CQRS 的完整資料流:左邊是 Command 路徑(Client → Command Bus → Write Model → Event Store),右邊是 Query 路徑(Client → Query Bus → Read Model)。中間的 Event Projection 負責把 Write Model 的變更同步到 Read Model。兩條路徑完全獨立,可以各自優化和擴展。
CQRS 的三種實作層級
| 層級 | 做法 | 複雜度 | 適用場景 | |------|------|--------|----------| | Level 1:分離 Handler | Command 和 Query 用不同的 Handler 類別處理,但共用同一個資料庫和模型 | 低 | 大多數專案的起點,幾乎沒有額外成本 | | Level 2:分離模型 | Write Model 用 Aggregate,Read Model 用扁平化的 DTO View。共用資料庫但用不同的查詢路徑(如寫入用 EF Core,讀取用 Dapper + Raw SQL) | 中 | 讀取效能有壓力、讀寫模型差異大 | | Level 3:分離資料庫 | Write DB 和 Read DB 分開,透過 Event Projection 同步。可以搭配 Event Sourcing | 高 | 超高讀取流量、需要不同的儲存引擎 |
建議:從 Level 1 開始,遇到效能瓶頸再升級到 Level 2,真正需要才用 Level 3。不要一開始就上 Level 3。
實戰補充
💡資深開發者筆記
搭配 Event Sourcing:CQRS 的 Write Model 可以只存 Domain Event,Read Model 透過 Projection 重建。這讓系統擁有完整的歷史紀錄,但也大幅增加了複雜度——先從「分開讀寫模型但共用資料庫」的簡單 CQRS 開始。
MediatR / Wolverine:在 .NET 生態中,MediatR 是最常用的 Command/Query 分發框架。定義 IRequest<T> + IRequestHandler<TRequest, TResponse>,自動路由到對應 Handler。Wolverine 則提供更完整的訊息處理能力。
最終一致性:讀寫模型分開後,Read Model 的更新存在延遲。前端需要處理「剛下單但訂單列表還沒出現」的情境——可以用樂觀更新(Optimistic UI)或輪詢機制解決。
理解測驗
🤔 CQRS 中的 Command 和 Query 最大的區別是什麼?
🤔 什麼情境下最不適合使用 CQRS?
🤔 CQRS 中 Read Model 的資料是如何更新的?
重點整理
💡一句話記住
口訣:「寫入走 Domain、讀取走捷徑」—— Command 保護業務規則,Query 追求讀取效能。
| 概念 | 說明 | |------|------| | Command | 改變狀態的操作,經過完整業務驗證 | | Query | 讀取資料的操作,回傳扁平化 DTO | | Write Model | 豐富的領域模型,保護業務不變條件 | | Read Model | 反正規化的查詢檢視,針對讀取場景優化 | | 核心好處 | 讀寫獨立擴展、模型各自優化、關注點分離 | | 代價 | 架構複雜度上升、最終一致性處理、維運成本增加 |