單調狀態機與樂觀更新的回滾契約:前台不得顯示後端沒記錄的狀態
觸發場景:POS App 的品項處理進度(未確認 → 已確認 → 已完成)由員工在前端推進、同步到後端。兩個設計決策被測試逼著說清楚:為什麼狀態只能往前?為什麼後端拒絕時一定要回滾樂觀更新? 疑問來源:狀態遞增的守則寫在模型方法裡、回滾寫在服務層——兩條規則的「為什麼」散在註解裡,直到為它們補測試時才發現各自對應一個業務不變式。 整理目的:把「單調狀態機」與「樂觀更新的回滾契約」整理成判準,並記錄它們與防護鏈的依賴關係。 本文邊界:不變式落點的理論層見 不變式的強制層次。
1. 單調狀態機:不可逆的現實、不可逆的模型
品項處理狀態的變更方法只接受「更高」的狀態值——同值與回退一律拒絕(回傳 false、不改狀態)。理由不是技術潔癖,是狀態對應的現實動作不可逆:餐點端出去就收不回來。模型層的單調守則把「UI 誤觸」「事件亂序抵達」「重複訊息」全部擋在同一個入口。
終態的側分支另有一格:「已取消」不在遞增序列上,而是從中途狀態岔出的終點——已完成的品項不可取消(現實上已交付)、已取消的不可再推進。整個狀態機小到一張表就能窮舉:
| 從 \ 到 | 已確認 | 已完成 | 已取消 |
|---|---|---|---|
| 未確認 | 可 | 可 | 可 |
| 已確認 | 不可(同值) | 可 | 可 |
| 已完成 | 不可(回退) | 不可 | 不可(已交付) |
| 已取消 | 不可 | 不可 | 不可 |
單調+終態側分支,是「進度追蹤」類狀態機的常見形狀;把它寫成模型方法的入口守則,比散在各呼叫端的 if 檢查可靠一個量級——這正是狀態轉換與稽核軌跡講的「領域方法作為唯一變更路徑」。
2. 樂觀更新的回滾契約
推進狀態的流程是樂觀式:先改本地(UI 立即反映)、再同步後端、失敗才回滾。回滾那一步在 code review 裡常被當成「禮貌性的清理」,但這裡它是硬契約,原因在依賴鏈:
這個狀態是其他防護規則的資料來源。「有品項已完成 → 不可刪除單據」「全部完成 → 才可拆分」——這些守衛讀的就是它。若後端拒絕後前台不回滾,會出現「前台顯示已完成、後端沒有記錄」的分裂狀態:守衛在前台的假象上做防護決策,放行了不該放行的操作,或擋下了不該擋的。
由此得出樂觀更新的回滾判準:看這個狀態有沒有下游讀者。
| 狀態的消費者 | 回滾的必要性 |
|---|---|
| 只有畫面顯示 | 失敗提示+下次同步自然修正,可接受 |
| 有防護規則、流程分支讀它 | 必須立即回滾——分裂狀態會讓規則在錯誤前提上運作 |
測試也照這個契約寫:後端拒絕 → 斷言狀態回到原值,reason 直接寫後果——「前台不得顯示後端沒記錄的狀態,否則守衛判錯」。
3. 批次回標與「防禦性空操作」
同一個狀態機還有一個批次入口:某個重建式操作(拆分單據)會把兩邊的處理記錄洗回初始狀態,流程約定「操作前提是全部完成」,所以操作後要把所有記錄批次回標。實測發現後端在該操作後根本不留記錄——回標實際上是空操作。
要不要刪掉這段程式?保留,並把理由寫進方法說明:它防的是後端行為改變的那一天——若未來記錄以未完成狀態重生,缺了回標會讓結帳流程被鎖死。這是「防禦性空操作」的合理場景:成本是幾行程式與一條「呼叫次數為零」的測試斷言,保的是上游行為漂移時的降級路徑。判準:防禦性程式碼要嘛有測試證明它在防的情境(哪怕目前不會發生),要嘛刪除——「留著以防萬一」但沒人指得出防什麼的程式碼才是負債。
4. 可複用的判準
- 狀態對應不可逆的現實動作 → 模型入口強制單調,同值與回退一律拒絕;終態側分支明確畫出來。
- 樂觀更新是否必須回滾,看狀態的下游讀者:有規則消費它 → 分裂狀態不可容忍。
- 回滾測試的 reason 寫依賴鏈的後果,不寫「狀態應該是 X」。
- 防禦性空操作要附帶「它在防什麼」的說明與測試錨點,否則刪除。
下一步
#ddd #state-machine #invariant #optimistic-update #rollback #pos