前後端最常見的阻抗,不是技術問題,而是詞彙問題:後端叫 OrderLine,前端叫 CartItem;後端叫 fulfillmentStatus,前端叫 deliveryState——兩邊各自造假設,API 變成翻譯層,每次修改都需要雙邊協調。這個問題的根源是前後端從不同的領域模型出發。

這篇說的是相反方向:讓前端從同一份領域模型推導出來——用 CQRS 的 Read Models 驅動 UI 組件,用 Commands 對應用戶行為,用領域事件驅動 UI 狀態機。

Read Models 驅動 UI 組件

CQRS 的 Read Side 輸出 Read Models(也叫 Projections)——這些是為查詢優化的非正規化視圖。每個 Read Model 天然對應一個前端視圖或組件的資料需求

設計 Read Model 時,最直接的問法是:「這個頁面需要顯示什麼?」——然後把答案就做成 Read Model 的 schema。這個方向和前端習慣的 API 設計相反(傳統是先設計 REST endpoint,前端再適應),但它消除了「API 回傳一堆前端用不到的欄位」或「前端需要的欄位 API 沒有」這兩個常見問題。

範例:訂單系統

Read Model: OrderListItem(用於訂單清單頁)
{
  orderId: string
  placedAt: datetime
  customerName: string      ← 非正規化:直接放進來,不用前端再查
  totalAmount: number
  currency: string
  status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
  itemCount: number         ← 非正規化:item 數量,不用前端計算
}

Read Model: OrderDetail(用於訂單詳情頁)
{
  orderId: string
  placedAt: datetime
  customer: { id, name, email, shippingAddress }
  items: [{ productName, sku, quantity, unitPrice, subtotal }]
  totalAmount: number
  payment: { method, status, paidAt }
  shipping: { carrier, trackingNumber, estimatedDelivery }
  statusHistory: [{ status, occurredAt, note }]  ← 來自 event replay
}

OrderListItemOrderDetail 是同一份領域資料的兩個不同投影——後端用 Projection 各自維護,前端組件直接消費,不需要拼接或轉換。

Commands 對應用戶行為

Event Storming 裡的 Commands(藍色便利貼)對應的是「用戶或系統想改變狀態的意圖」。在前端,這些 Commands 直接映射到用戶互動:

Domain Command前端觸發方式
PlaceOrder結帳頁「確認下單」按鈕
CancelOrder訂單詳情頁「取消訂單」按鈕
ApplyPromoCode優惠碼輸入框 submit
UpdateShippingAddress地址編輯表單 submit

Command 不是 API endpoint——它是業務意圖的表達。一個 Command 可能對應一個 POST /commands/place-order endpoint,也可能對應 GraphQL mutation,具體協議是實作細節。重要的是前端代碼裡能清楚讀到「這個按鈕是在執行 PlaceOrder」。

Validation 的歸屬:Command 本身帶有格式驗證(必填欄位、型別正確);業務規則驗證(庫存是否足夠、用戶是否有權限)在 Aggregate 裡做,不在前端做。前端驗證是 UX 優化,不是業務規則的守門人。

領域詞彙共享

Ubiquitous Language(統一語言)是 DDD 的核心概念(Eric Evans, 2003):領域專家、後端、前端、測試、文件——全部使用同一套術語,不翻譯、不重新命名。

實踐方式:

  1. 型別共享:如果前後端都用 TypeScript,把 Domain Event 和 Read Model 的型別定義放在 shared package(或 monorepo 的 shared/types 目錄)
  2. OpenAPI/GraphQL Schema:API schema 的命名就用 Domain Events 的命名,不用 REST 慣例的 getOrderById——用 OrderDetailQuery
  3. UI 組件命名:組件叫 OrderSummaryCard 而不是 OrderListRow——用領域術語,不用視覺術語

方法論主張(非量化):共享詞彙減少「翻譯」損耗的效果業界普遍認可,但具體節省多少溝通成本沒有通用數字。

事件驅動 UI 狀態

前端 UI 的狀態往往是 Aggregate 狀態機的鏡像。如果 Order 有這個狀態機:

OrderCreated → PaymentPending → PaymentReceived → Confirmed → Shipped → Delivered
                                                         ↓
                                                     Cancelled

前端 UI 的邏輯也應該從這個狀態機推導,而不是自己另外定義一套 isLoading / isSuccess / isError 的狀態:

// 不好:前端自己造狀態
type UIState = 'idle' | 'loading' | 'success' | 'error'
 
// 好:從領域狀態機推導
type OrderStatus = 'pending' | 'payment_pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'

每個狀態對應不同的 UI 渲染邏輯(哪些按鈕可用、顯示哪些資訊、哪些操作被禁止)直接從領域語意讀出,不需要額外的業務判斷邏輯。

即時更新:訂閱 Domain Events

如果系統有 WebSocket 或 Server-Sent Events,前端可以直接訂閱 Domain Events 並更新本地的 Read Model 快取:

Server → WebSocket → Client
         事件:OrderShipped { orderId, trackingNumber }
         
前端:
  1. 收到 OrderShipped 事件
  2. 找到 local cache 裡的 OrderListItem(orderId)
  3. 更新 status = 'shipped'
  4. 觸發重新渲染

這個模式的好處是前端的更新邏輯和後端 Projection 的邏輯是同構的——都是「接收事件、更新狀態」。Bug 容易被找到,因為前後端的邏輯可以用同樣的語言描述。

前後端邊界的設計原則

前端從 Read Models 讀,不直接讀 Aggregate 狀態:Aggregate 的狀態是為寫入設計的,可能結構複雜、包含內部細節。Read Model 是為查詢設計的,是 Aggregate 狀態的投影,已經去除了前端不需要的部分。

前端透過 Commands 寫,不直接操作 Aggregate:前端永遠不會說「把 order.status 設成 shipped」——它只說「執行 ShipOrder command」。業務規則(只有 confirmed 的訂單才能 ship)在後端 Aggregate 裡保護,前端不複製這份規則。

前端可以有本地投影(Local Projection):在複雜的前端應用裡(如購物車邏輯),可以在前端維護一個本地的微型 Event Sourcing——記錄用戶的操作作為事件,從事件 derive UI 狀態,最終在 checkout 時把積累的 Commands 批次送出。

取捨

優點:前後端詞彙統一後,需求變更時的溝通成本降低;UI 組件的資料依賴清晰(哪個組件對應哪個 Read Model);狀態管理邏輯從業務語意推導,不憑感覺設計。

代價:需要前端工程師理解 DDD 術語和領域模型,學習曲線比「直接打 REST API」高;Read Model 設計需要前後端事先對齊,不能像傳統 REST 那樣隨時新增欄位。

延伸