狀態轉換與稽核軌跡
不變式讓物件出生合法(不變式的強制層次),但物件出生之後還會被變更。DDD 的核心精神——讓違反規則的路徑走不通——在變更路徑上折算成一個問題:狀態欄位有沒有流程約束、變更有沒有需要留痕的伴隨動作?有,變更就要收斂到領域方法,其餘路徑關閉。本章的作用域跟 不變式的強制層次 同在單一物件內:跨物件一致性(aggregate 邊界、事務語意)是另一個層次的主題。
領域方法承擔什麼
領域方法在一次呼叫裡承擔三件事:表達業務意圖(方法名本身是業務事件的動詞)、檢查轉換條件(當前狀態是否允許這次轉換)、寫入稽核紀錄(這次變更的內容、時間、來源)。三件事原子完成——呼叫端只表達「做這件事」,方法自己保證條件成立且紀錄同步寫入。
一個書籍管理 App 的 Book entity 帶一組狀態轉換方法(開始豐富化、完成豐富化、標記可用),每個方法往 modificationHistory 追加一筆變更紀錄。這個專案的方法完成了其中兩件(意圖表達與紀錄寫入),第三件(轉換條件檢查)只停在註解——方法體內 grep 不到任何對應檢查,是 不變式的強制層次 展開的文件層失效案例(copyWith 是逃生口,不是設計)。
三件事如果拆開給不同入口做——一個方法改狀態、另一處補紀錄——一致性就回到文件層,靠每個呼叫端記得兩者都做、且順序正確。不變式的強制層次 展開過同一個模式:被同一條規則綁住的欄位群對外只暴露一個原子的切換方法、必要資訊做成必填參數。把變更路徑合併到單一方法是同一原則在時間軸上的延伸——不只是「欄位一起換」,而是「狀態轉換、條件檢查、稽核紀錄一起完成」。
唯一路徑與建議路徑
對一個受規則約束的欄位,變更只有兩種可能的強度。唯一路徑:領域方法之外沒有 public 介面可以改這個欄位、變更只能走方法。建議路徑:方法之外有其他途徑可以改(逐欄位覆寫工具、public setter)、規則靠慣例說「請走方法」。前者是型別層或執行層的強制(不變式的強制層次 的分層判準),後者停在文件層。兩者之間有一個常見的折衷:保留覆寫工具但從參數列移除受約束的欄位——比完全唯一的改造成本低、比建議路徑的保護強。
判準是一個問題:這個欄位的變更有沒有需要一起完成的伴隨動作?常見的伴隨動作包含稽核紀錄寫入、衍生值重算、狀態流程條件檢查——任何需要隨變更同步完成的動作都算。有任何一種——變更路徑收進領域方法、欄位的 public 寫入介面關閉。沒有——逐欄位覆寫工具是正當的便利、加上領域方法的儀式只會製造沒有伴隨動作可做的 boilerplate。
判準用錯的兩個方向代價相反。把唯一路徑設計成建議路徑:規則退化成慣例、稽核軌跡開始出洞(下一節展開)。反過來、把沒有伴隨動作的欄位硬收進領域方法:每次改值都要穿過一層沒有意義的方法呼叫、而且方法名要替一個純粹的換值操作擠出業務動詞。資料袋與領域模型 判定型別整體是資料袋還是領域模型的判準在這裡有回聲——判定為資料袋的型別、每個欄位都沒有伴隨動作、限縮變更路徑是不必要的。
單向狀態約束與樂觀更新的回滾
領域方法承擔轉換條件檢查——其中一類常見的條件是方向約束:狀態對應的現實動作不可逆時,模型層的入口守則強制單調(同值與回退一律拒絕)。餐點端出去收不回來、貨物已出庫不可反向入庫——這類狀態機的形狀是遞增序列加上從中途岔出的終態側分支,小到一張表就能窮舉(單調狀態機與樂觀更新回滾)。
方向約束的設計責任集中在一個入口方法裡:同值拒絕(防重複訊息)、回退拒絕(防事件亂序與 UI 誤觸)、終態後禁入(防已交付的品項被取消)。把這些守則散在各呼叫端的 if 檢查,就回到文件層——跟上一節的變更路徑判準同一個推導:有伴隨動作(方向檢查)的欄位,變更收進領域方法。
方向約束還有一個延伸場景:樂觀更新。前端先改本地狀態(UI 立即反映)、再同步後端、後端拒絕才回滾。回滾是否必須立即執行,判準在於這個狀態有沒有下游讀者。狀態只供畫面顯示——失敗提示加下次同步自然修正即可;狀態被防護規則或流程分支讀取——回滾是硬契約,分裂狀態(前台顯示已完成、後端沒有記錄)會讓規則在錯誤前提上做決策。回滾測試的斷言要寫依賴鏈的後果(「前台不得顯示後端沒記錄的狀態,否則守衛判錯」),把契約的動機放進測試資產。
稽核軌跡怎麼出洞
兩條變更路徑並存——領域方法有紀錄、工具方法沒紀錄——是稽核軌跡出洞的機制、而出洞是靜默的:沒有任何錯誤、警告或測試失敗會告訴你紀錄缺了一段。
上述書籍管理 App 暴露了完整的失效路徑。Book 同時有領域方法與一個 public 的 copyWith、而 copyWith 的參數列包含 status 和 modificationHistory。工廠層直接用 copyWith(status: BookStatus.available) 改狀態、繞過了 markAsAvailable() 方法——這些狀態轉換沒有進入稽核紀錄。更具體的證據:同專案的一個測試用 copyWith 改 readingStatus、期待 modificationHistory 出現兩條紀錄——實際只有一條。連寫測試的人都把 copyWith 當成了業務入口、以為它會留稽核痕跡(copyWith 是逃生口,不是設計)。
兩條路徑(領域方法有紀錄、工具方法沒紀錄)並存在同一個 public 介面上。每個呼叫端必須自己記得走哪條——而「要記得」是文件層的強度。失效只是時間問題、失效的形式是稽核紀錄上的洞:某段狀態變化沒有留下任何紀錄,而這個事實要在事故回溯、或有人按歷史紀錄做報表的那一天才浮現——通常距離寫入已經很久。稽核軌跡出洞跟型別安全出洞有一個關鍵差異:型別錯誤在編譯期或 runtime 報錯、有明確的發生時刻;稽核缺口在洞產生的當下完全沒有訊號。
收斂的操作化:稽核紀錄或狀態流程欄位出現在任何 public 寫入介面的參數列——從參數列移除、收進領域方法。這也是 約束要讓違反路徑走不通 的一個具體形態:稽核欄位出現在 public 寫入介面就是一致性不受保護的訊號。領域方法成為唯一路徑之後、每一條稽核紀錄都有一個業務動詞作為來源、紀錄的完整性由介面的形狀保證而不是由呼叫端的記憶保證。
凍結作為稽核的端點
稽核不只是記錄「狀態怎麼變的」,還要記錄「變更當時的世界長什麼樣」。歷史事實持有 live 參照會漂移——稽核紀錄寫的是「下單時買了商品 A」、而商品後續改了名、歷史訂單顯示的就不再是事實。
一個 POS 專案的品項模型在結帳完成後凍結商品與價格 snapshot——商品後續改名、下架、調價,訂單仍顯示當時購買的內容(同一個品項、四個 model)。身份語意的完整凍結判準在 entity 與 value object 的判準 展開;本節從稽核面到達同一個結論:歷史記錄反映事件發生當下的世界、不是現在的世界。凍結時機由「這筆資料何時成為事實」決定——進行中的購物車品項持 live 參照是正確的(會員身分改變、價格即時跟著變);成為歷史訂單的那一刻凍結。業務流程有多個確認階段(冷靜期、取消窗口)時,凍結時機取決於哪個階段之後的漂移對下游不可接受。
判讀訊號
- 型別同時有 public 領域方法與 public 的逐欄位覆寫工具、且覆寫範圍涵蓋狀態或稽核欄位——兩條路徑並存、稽核軌跡正在累積洞。收斂方向:從覆寫工具的參數列移除這些欄位、現有繞過呼叫點改走領域方法。
- 如果同一個變更操作有時有稽核紀錄有時沒有,先追工廠層或測試 Arrange 段有沒有繞過領域方法的呼叫點。
- 狀態轉換方法的註解宣稱轉換條件、方法內 grep 不到對應檢查——文件層約束正在靜默失效,判讀見 不變式的強制層次。
- 多個領域方法各自重複相同的前置檢查或紀錄寫入邏輯——共用的橫切面該抽出來,遺漏一處就是新的洞。
- 狀態對應不可逆的現實動作、但變更方法沒有方向檢查——同值與回退可以走通、單調約束停在文件層。
- 樂觀更新後端拒絕後、前台狀態沒有回滾——如果該狀態有下游規則消費它,分裂狀態會讓規則在錯誤前提上運作。
- 歷史記錄的顯示內容跟著現行資料變動?參照凍結時機漏掉了,身份判準見 entity 與 value object 的判準。
下一步
- 變更路徑收斂之後、建構路徑本身的設計:建構路徑設計
- 型別類別的入口判準:資料袋與領域模型
- 規則落點的三層選擇:不變式的強制層次
- 領域狀態機投影到畫面入口可見性:組裝層的可達性
- 單調狀態機與樂觀更新回滾的完整 case:單調狀態機與樂觀更新回滾
- 跨物件一致性(aggregate 邊界):模組 backlog
- Dart 的語言細節(copyWith 參數列收窄、private copyWith、哨兵物件):copyWith 是逃生口,不是設計
- 原則層:#222 約束要讓違反路徑走不通