不變式的強制層次
不變式是在物件整個生命週期都必須為真的業務規則:狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換、錯誤代碼必須屬於對應分類。本章的作用域是單一物件的不變式——跨物件的一致性(aggregate 邊界、「交易完成時必須成立、執行中間允許暫時違反」的時點語意)是另一個層次的主題,等 case 累積後另章展開。模組源頭句「讓違反規則的路徑走不通」在本章落到最直接的決策:同一條規則在應用程式碼內可以落在文件層、型別層或執行層,層次決定違反規則時發生什麼——靜默通過、編譯失敗、還是當場拒絕。型別的類別(資料袋與領域模型)與身份語意(entity 與 value object 的判準)判定之後,本章決定規則本身的落點。
三個層次的差異
文件層把規則寫成註解、命名、慣例與規範文件,依靠讀者記得並自律。型別層把規則做進介面簽名與型別系統,違反的程式碼無法通過編譯——規則錯的程式根本產不出來。執行層把規則做成建構子與領域方法內的檢查,違反在 runtime 的當下被拒絕,錯誤有明確的發生點與訊息。
| 層次 | 載體 | 違反時發生什麼 | 成本 |
|---|---|---|---|
| 文件層 | 註解、命名、慣例 | 靜默通過、事後在別處浮現 | 寫下來最便宜、失效最昂貴 |
| 型別層 | 介面簽名、型別系統 | 編譯失敗 | 設計介面要花心思、編譯期攔截無心誤用 |
| 執行層 | 建構子、方法內檢查 | runtime 當場拒絕 | 要寫檢查與測試、失效點集中 |
三層的選擇是「這條規則的違反代價」對「這一層的建置成本」的折算、層次高低本身沒有優劣排序。折算的變數包含團隊規模、人員流動率與專案壽命:小而穩定的團隊靠 code review 攔截誤用是可承受的選擇;人一多、流動一快,同樣的慣例就守不住——違反代價沒變、失效機率變了。狀態轉換與稽核這類違反後靜默出洞的規則,值得推到型別層或執行層;一次性的輸入格式問題留在執行層的驗證流程就足夠;真正只能靠慣例的(命名風格、檔案組織)才留在文件層——文件層是最後的選擇、而不是預設的起點。
這三層涵蓋的是應用程式碼內的落點,實務上還有兩個常見的層。資料庫約束(NOT NULL、外鍵、unique index)攔得住所有寫入者——含手工 SQL 與其他服務;「email 不得重複」這類跨物件的唯一性規則,任何建構子或簽名都表達不了、併發下的可靠落點只有它,資料庫層的能力屬 Backend 模組的範圍。CI 檢查(architecture test、lint)把慣例類規則升級成「合併前擋下」、強度介於文件層與型別層之間。本章的三層判準作用在單物件規則上;規則跨出單一物件時,先想這兩層。跨到應用程式的組裝層時——「use case 的每個入口在 production 可達」這類不變式——強制層選擇見 組裝層的可達性。
這五個位置沿強度排列,而強度其實是兩件事的合成。第一件是規則寫在哪:註解、介面簽名、建構子檢查、schema 約束都寫進被約束的產物裡,lint 設定與 CI 規則寫在產物外面。第二件是違反時何時發聲:文件層永不發聲,型別層在編譯當下,執行層與資料庫在寫入當下,CI 在合併之前。這兩件事獨立變化——文件層與型別層同樣寫在產物內,發聲能力卻是零與編譯期的差距。
把兩條軸壓成一條的代價是產物外那一側只剩一格。CI 那格裡的 lint 與 architecture test 都是讀程式文本的檢查:它們掃原始碼長什麼樣,不掃程式跑起來會怎樣。觀測執行行為的那一種在這份清單裡沒有位置,而跨函式的讀寫順序、某個值必須活過某次操作這類約束,在多數主流型別系統裡產物內沒有位置寫得下(Rust 的 lifetime、typestate 生態是例外)。沿刻度往上找會發現每格都塞不進去,於是被送回起點寫一行註解——而它真正的落點是一條普通的行為測試(#253 寫註解的動機是怕被改壞時要處理的是那個約束)。
文件層約束的失效模式
文件層約束的失效是靜默的,而且失效證據會累積在遠離規則文字的地方。一個書籍管理 App 的兩條文件層約束都失效了:entity 的狀態轉換方法註解宣稱只能從特定狀態轉換、以確保狀態流程正確,實作裡沒有任何檢查——grep 計數是零;「狀態轉換請走領域方法」是團隊慣例,public copyWith 的參數列卻包含狀態與稽核欄位,工廠層直接用它改狀態、對應的變更沒有進入稽核紀錄(copyWith 是逃生口,不是設計)。
這個案例暴露文件層的兩個結構性弱點。第一、註解宣稱約束會製造假防護感——讀者以為有防護、於是信任了實際上無人看守的路徑。第二、文件層約束跟便利工具並存時,勝出的是工具:規範說走領域方法、生態的預設路徑給出全欄位覆寫、IDE 補全第一個跳出來的就是它。規範與預設衝突時、預設會贏。通用推論:一條規則若違反時靜默、事後才以資料異常浮現,它停在文件層的每一天都在累積無法回溯的洞。
型別層:把約束做進介面
型別層強制的形式是讓介面簽名替規則說話:正確的用法寫得出來、錯誤的用法寫不出來。一個 POS 專案的結帳模型把這件事做進了簽名。業務規則要求會員身分、計價、支付方式三者一起換(會員用會員價且限會員資產支付、非會員相反)。模型把切換收成單一方法、把「新的支付方式」設計成必填參數——呼叫端無法「只換會員、支付方式以後再說」,簽名本身就把「兩者要一起決定」寫死了。會員與支付方式在同一次狀態更新內寫入(實收金額的重設接在其後),響應式 UI 的訂閱者永遠看不到只換了一半的組合(會員身分、計價、支付方式必須一起換)。
對照組是分開的 setter:規則變成「每個呼叫端自己記得兩個都呼叫、而且順序對」——回到文件層。這條對照給出型別層的可操作模式:被同一條規則綁住的欄位群、對外只暴露一個原子的切換方法;「必須一起提供」的資訊做成必填參數;欄位群裡有衍生值時、重算收在同一個方法尾端(來源先、衍生後)、順序就無法在呼叫點被顛倒。同族的另一個型別層手法是 exhaustive switch:分類完整性交給編譯器、新增成員時遺漏歸類是編譯錯誤(見 entity 與 value object 的判準 的枚舉分層段);語意封閉的 domain type(合法運算之外的介面根本沒有)也屬這一層。
型別層的邊界要誠實標明:它防止的是無心誤用。反射、dynamic、顯式拆封都繞得過去——威脅模型是「讓正確的寫法比錯誤的寫法省力」,防刻意繞過要靠 review 與執行層。
執行層:建構期不變式
執行層強制的標準形態是建構期不變式:物件在出生的那一刻就必須合法、違反的建構當場失敗。上述書籍管理 App 把錯誤分類建成這個形態:每個錯誤代碼隸屬一個技術分類(business / network / storage / platform / validation)、exception 型別的建構要求代碼屬於對應分類。這層不變式工作的證據是一批測試失敗——六個失敗全部指向真實的分類錯誤:storage 例外用了 platform 分類的代碼、業務例外家族混進了 network 分類的代碼(Exception 型別綁 ErrorCategory 的建構不變式)。
對照沒有不變式的世界:分類錯亂靜默流通、要等某天有人按分類統計錯誤或決定重試策略時、才以錯誤行為浮現。建構期不變式把「錯亂發生的時刻」跟「錯亂被發現的時刻」壓成同一刻,這是執行層的核心價值:失效點集中在建構處、錯誤訊息直接指向規則本身。下游拿到實例的任何程式碼、都可以信任不變式已成立——防禦性檢查的需求隨之消失。
建構期不變式有一條要預先想好的邊界:物件的建構有兩條路徑——新建(走工廠與建構子、驗全部不變式)與持久化回讀(從資料庫或事件流重建已經存在的物件)。不變式收緊之後,存量資料是用舊規則寫入的,回讀路徑套新規則會讓歷史物件建不出來;處置要嘛跑資料遷移、要嘛讓回讀路徑信任已持久化的狀態、跳過新建路徑的驗證。新建路徑的工廠設計在 建構路徑設計 展開;持久化回讀路徑的完整邊界屬 entity 持久化與遷移的主題(模組 backlog)。
不變式被撞:需求違規與約束錯形
不變式開始工作之後、遲早會被撞,撞上時第一件事是分辨兩種病因:需求違規、還是約束錯形。判準看繞過方的動機——繞過方在找便利(省掉領域方法、直接改狀態),是需求違規、修繞過方;繞過方有現有約束無法表達的正當語意,是約束錯形、修約束。動機不可考時(繞過者已離開、變更沒有留下說明),改看約束的表達力:現有約束內有沒有語意等價的合法選項——有、多半是找便利;沒有、是約束錯形。
上述錯誤分類案例把兩種病因擺在同一批修復裡:一部分失敗是選錯代碼、正確分類裡本來就有語意等價的代碼、換過去就修好——以本章的分辨來看、這是需求違規裡最輕的形態(病因是選碼時沒查分類表、修繞過方的成本極低);但匯入流程的 exception 遇到的限制不同——匯入錯誤的來源橫跨多種技術分類(解析壞是 validation、來源伺服器錯是 network、寫檔失敗是 storage),它綁定的單一分類裡根本沒有它需要的代碼。這是約束錯形:分類軸(技術來源)跟 exception 階層軸(業務流程)互相獨立,把業務流程的 exception 綁死在單一技術分類上、約束跟現實的形狀不合。當下合比例的處置是讓該 exception 改掛不綁分類的基類、並把分類學的不合寫成決策記錄。
分辨錯了、兩邊都要付出代價。把約束錯形當需求違規最傷:正當需求被迫用越來越彆扭的方式繞行、每次繞行再被當成新的違規;反向的誤判則讓約束被逐次放寬到失去意義。被撞是不變式的正常生命週期事件——約束會工作、也會被合法需求撞,設計時就要預留「這條約束錯了怎麼改」的路徑。上述案例的處置就是這條路徑的現成形態:一個不綁分類的基類作為合法的逃生位置、加一份決策記錄讓下一個遇到同樣限制的人知道分類學的缺口在哪。
強制的邊界:存在條件與輸入品質
執行層的建構不變式有一條精確的邊界:它守「這個物件能不能存在」、而使用者輸入的品質問題屬於另一層。同一個 App 的查詢輸入實作把這條邊界暴露了出來:value object 的建構不變式要求至少一個查詢參數非空(四個欄位全空的「查詢」在語意上根本不是查詢、這種物件不該存在);ISBN 格式、欄位長度這類規則放在無狀態的 validator、回傳結構化的驗證結果——錯誤碼、本地化訊息、標準化後的值(驗證的兩層分工與順序陷阱)。
分工判準收成一句:違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator。前者失敗是程式錯誤——哪段程式碼試圖建一個不合法的物件;後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向現形:格式驗證塞進建構子、UI 層要 try-catch 例外再翻譯成欄位錯誤、結構化的錯誤資訊全部丟失;存在條件放進 validator、全空的物件能在系統裡流通、每個消費者都要自己防。實作上的訊號明確:測試建不出想要的 fixture、被建構驗證擋住——通常就是兩層規則混在同一層的時刻。
這條邊界補完三層選擇的最後一塊:把約束推向型別層與執行層的原則、作用對象是領域規則;面向使用者的輸入品質要的是好的錯誤回報、而不是走不通的路徑——對它套用建構期不變式反而毀掉回報能力。
判讀訊號
- 註解或規範宣稱一條約束、實作裡 grep 得到零個對應檢查——文件層約束正在靜默失效,按違反代價決定上移到哪一層。
- 寫下那條註解的動機是「怕有人改壞它」——防護需求送錯了窗口,先問這個約束能不能被消除、再問誰來守(#253)。
- 當一條規則的正確執行依賴「每個呼叫端記得做兩件事、而且順序對」,它實際上停在文件層:收成單一原子方法、必要資訊做成必填參數。
- 分類、狀態這類規則只存在於命名慣例——錯亂正在靜默累積,第一個按分類做統計或分支處置的功能會揭開它。
- 「改繼承(或改型別、放寬約束)來讓建構通過」的修法出現——先分辨需求違規還是約束錯形、再決定修哪一方,是後者就把約束的錯形寫成決策記錄。
- 測試建不出想測的 fixture、被建構驗證擋住:先釐清是存在條件與輸入品質混在同一層、還是工廠表達力不足逼測試繞道(後者的機制見 #223 逃生口吸收建構路徑的缺陷)。
- 不變式收緊後、持久化回讀開始拋建構錯誤——存量資料與新規則的落差沒被處理,先分資料遷移還是回讀路徑放行。
下一步
- 規則落點之前的兩個判定:資料袋與領域模型、entity 與 value object 的判準
- 變更路徑的收斂:狀態轉換與稽核軌跡
- 建構路徑的設計:建構路徑設計
- 規則跨出單一物件、抬到應用程式的組裝層:組裝層的可達性
- 原則層:#222 約束要讓違反路徑走不通
- Dart / Flutter 的實作細節(required 參數與 Rx 狀態流、exception 階層、validator 結構):會員身分、計價、支付方式必須一起換、Exception 型別綁 ErrorCategory 的建構不變式、驗證的兩層分工與順序陷阱