觸發場景:POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜 疑問來源:直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源? 整理目的:記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺 本文邊界:素材是一個 Flutter POS App 的現行實作與 changelog;推導方式可遷移、具體切法是餐飲 domain 的結果


直覺建模在三個操作前撐不住

「一桌一單」的一對一建模,遇到這個專案實際支援的操作就露出縫:

  • 提前結帳:客人中途先結一次帳但不離桌,之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束
  • 純佔桌:客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始
  • 外賣單:沒有桌的訂單。購物車存在、桌位從頭到尾缺席

三個操作各自證明一件事:桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作,只能塞特例旗標,而特例會隨操作數量增生。

解耦後的模型:獨立資源 + 綁定關係

這個專案的購物車 model 註解直接寫明了關係設計:

  • 桌子和購物車是獨立資源,透過綁定關係關聯
  • 開桌(occupyTable)同時建立購物車並綁定桌子
  • 釋放桌子(releaseTable)只解除綁定,購物車仍保留
  • 當購物車名下沒有任何桌子時,變成臨時購物車(外賣單)
  • 提前結帳且不釋放桌子時,品項被清空但購物車和桌子綁定仍在,可繼續追加點餐
  • 純佔桌(reserveTable)不建立購物車,要下單需另行開桌

每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合,而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」,自然落在模型的表達範圍內。

結帳模式:兩個布林組合出生命週期決策

結帳流程用兩個欄位決定結帳後兩個資源各自的命運:

模式releaseTabledeleteShoppingCart結帳後
完整結帳truetrue釋放桌位、刪除掛單、畫面回購物頁
提前結帳falsefalse保留桌位與 cart 殼、同桌直接繼續追加點餐

結帳後的兩條路徑也各自獨立:完整結帳走 afterCheckout(回購物頁)、提前結帳走 afterPartialCheckout——後者的註解記了一個容易被清掉的細節:「刻意不回到購物頁、不清本機暫存品項:員工提前結完仍在服務同一位客人,若員工先前已在本機累積了尚未送出的追加品項,會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。

組合空間大於業務空間:非法組合要顯式封鎖

兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄:

fix: 禁止在提前結帳的時候釋放桌子

「提前結帳 + 釋放桌子」在模型上可以表達(兩個獨立欄位、各自可設),在業務上是矛盾的(客人還在桌上、桌卻被釋放給下一組客人)。解耦買到表達力的同時,也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失,要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者,而更早關掉這個縫的做法是讓結帳模式成為一個 enum(fullCheckout / partialCheckout)、由模式推導兩個布林,非法組合從一開始就不可表達。

契約的另一半:details 只含未結帳品項

提前結帳還牽動購物車內容的契約。購物車 model 註明 details 代表「當下未結帳的活動品項」,已結帳品項由後端移除;把 details 轉成結帳清單的方法直接把違約後果寫在註解裡:

依賴 ShoppingCart 的 details 契約:假設 details 不含已結帳品項。若契約被破壞,已結帳的舊單金額會被重複算進下一輪結帳。

這是「重複收錢」等級的後果,靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上,是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。

判準:有沒有操作需要其中一方獨立存活

把推導收束成一句:兩個業務資源該不該共用生命週期,看有沒有業務操作需要其中一方在另一方缺席時存活。有——佔桌不點、提前結帳、外賣——就解耦成獨立資源加綁定;沒有,一對一的簡單模型是正確選擇、解耦反而引入要管理的組合空間。這是「從操作推導領域」的實例:聚合邊界不是從名詞關係(桌子「有」訂單)推出來的,是從操作對生命週期的要求推出來的。

相關閱讀