桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦
觸發場景:POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜 疑問來源:直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源? 整理目的:記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺 本文邊界:素材是一個 Flutter POS App 的現行實作與 changelog;推導方式可遷移、具體切法是餐飲 domain 的結果
直覺建模在三個操作前撐不住
「一桌一單」的一對一建模,遇到這個專案實際支援的操作就露出縫:
- 提前結帳:客人中途先結一次帳但不離桌,之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束
- 純佔桌:客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始
- 外賣單:沒有桌的訂單。購物車存在、桌位從頭到尾缺席
三個操作各自證明一件事:桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作,只能塞特例旗標,而特例會隨操作數量增生。
解耦後的模型:獨立資源 + 綁定關係
這個專案的購物車 model 註解直接寫明了關係設計:
- 桌子和購物車是獨立資源,透過綁定關係關聯
- 開桌(occupyTable)同時建立購物車並綁定桌子
- 釋放桌子(releaseTable)只解除綁定,購物車仍保留
- 當購物車名下沒有任何桌子時,變成臨時購物車(外賣單)
- 提前結帳且不釋放桌子時,品項被清空但購物車和桌子綁定仍在,可繼續追加點餐
- 純佔桌(reserveTable)不建立購物車,要下單需另行開桌
每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合,而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」,自然落在模型的表達範圍內。
結帳模式:兩個布林組合出生命週期決策
結帳流程用兩個欄位決定結帳後兩個資源各自的命運:
| 模式 | releaseTable | deleteShoppingCart | 結帳後 |
|---|---|---|---|
| 完整結帳 | true | true | 釋放桌位、刪除掛單、畫面回購物頁 |
| 提前結帳 | false | false | 保留桌位與 cart 殼、同桌直接繼續追加點餐 |
結帳後的兩條路徑也各自獨立:完整結帳走 afterCheckout(回購物頁)、提前結帳走 afterPartialCheckout——後者的註解記了一個容易被清掉的細節:「刻意不回到購物頁、不清本機暫存品項:員工提前結完仍在服務同一位客人,若員工先前已在本機累積了尚未送出的追加品項,會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。
組合空間大於業務空間:非法組合要顯式封鎖
兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄:
fix: 禁止在提前結帳的時候釋放桌子
「提前結帳 + 釋放桌子」在模型上可以表達(兩個獨立欄位、各自可設),在業務上是矛盾的(客人還在桌上、桌卻被釋放給下一組客人)。解耦買到表達力的同時,也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失,要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者,而更早關掉這個縫的做法是讓結帳模式成為一個 enum(fullCheckout / partialCheckout)、由模式推導兩個布林,非法組合從一開始就不可表達。
契約的另一半:details 只含未結帳品項
提前結帳還牽動購物車內容的契約。購物車 model 註明 details 代表「當下未結帳的活動品項」,已結帳品項由後端移除;把 details 轉成結帳清單的方法直接把違約後果寫在註解裡:
依賴 ShoppingCart 的 details 契約:假設 details 不含已結帳品項。若契約被破壞,已結帳的舊單金額會被重複算進下一輪結帳。
這是「重複收錢」等級的後果,靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上,是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。
判準:有沒有操作需要其中一方獨立存活
把推導收束成一句:兩個業務資源該不該共用生命週期,看有沒有業務操作需要其中一方在另一方缺席時存活。有——佔桌不點、提前結帳、外賣——就解耦成獨立資源加綁定;沒有,一對一的簡單模型是正確選擇、解耦反而引入要管理的組合空間。這是「從操作推導領域」的實例:聚合邊界不是從名詞關係(桌子「有」訂單)推出來的,是從操作對生命週期的要求推出來的。
相關閱讀
- 概念地基:DDD 領域驅動設計指南 的「從操作推導領域」章節
- 非法組合封鎖的原則層:#222 約束要讓違反路徑走不通——「提前結帳 + 釋放桌子」正是「不允許任意組合的欄位」判準的另一個實例
- 同專案的品項生命週期:同一個品項、四個 model——桌位與購物車是資源層的生命週期、品項是資料層的生命週期,同一個 domain 的兩個切面
#dart #flutter #ddd #aggregate #domain-model #pos #lifecycle