觸發場景:Flutter 書籍管理 App 的查詢輸入層——BookQueryInput value object 加 BookInputValidator 驗證器。實作過程撞了兩個問題:測試想建一個全空的輸入來測 validator、被 VO 的建構驗證擋住建不出來;ISBN 填 978ABC 被拒、錯誤訊息卻說「長度必須是 10 或 13 位」 疑問來源:驗證邏輯到底該放建構子還是 validator?以及那個張冠李戴的錯誤訊息是怎麼來的? 整理目的:記下驗證的兩層分工判準、以及「先標準化再檢查」的證據銷毀陷阱 本文邊界:素材是該專案 v0.11.3 的實作記錄(41 個單元測試的 TDD 過程、含三個實作期問題的解法)


兩層分工:存在條件 vs 輸入品質

這一層的設計把驗證拆在兩個位置,各守一種性質的規則:

BookQueryInput 的建構期不變式:至少一個查詢參數非空。這是存在條件——四個欄位全空的「查詢輸入」在語意上不是一個查詢,這種物件不該存在於系統的任何角落。違反它的處置是拒絕建構:拿到 BookQueryInput 實例的任何下游、都可以信任它至少有一個參數。

BookInputValidator 的格式驗證:ISBN 格式(10 或 13 位、允許連字符與空格)、標題與作者長度(1-255 字元)、附帶標準化(去連字符、收斂空白)。這是輸入品質——使用者打錯很正常,處置不是拒絕存在、是回一個 ValidationResult:錯誤碼清單、本地化訊息、以及標準化後的值。

判準收成一句:違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator。前者失敗是程式錯誤(哪段程式碼試圖建一個不合法的物件?)、後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向都會現形:格式驗證塞進建構子,UI 層要 try-catch 例外再翻譯成欄位錯誤、錯誤碼與訊息的結構化全部丟失;存在條件放進 validator,全空的物件能在系統裡流通、每個消費者都要自己防。

有趣的是這個分工是被測試出來的:測試想建全空實例去測 validator 的「至少一個參數」規則、被建構不變式擋住。這個衝突不是誰錯——它暴露了「至少一個參數」同時被兩層宣告。釐清後規則歸建構期(存在條件)、validator 的對應測試改測空白字串等輸入品質情境。測試建不出 fixture、經常就是層次劃分待釐清的訊號。

順序陷阱:先標準化、等於先銷毀證據

第二個問題是條精緻的小 bug。ISBN 驗證的原始順序是「先標準化、再檢查」:標準化移除所有非數字字元、然後檢查位數。輸入 978ABC 走完這條管線:字母被移除、剩 978、三位數、被拒——錯誤訊息是「長度必須是 10 或 13 位」。

拒絕是對的、理由是錯的。使用者的實際問題是「ISBN 含字母」,訊息卻叫他去檢查長度——他數了數自己輸入的六個字元、更困惑了。機制上這是證據銷毀:標準化是有損操作,把「含字母」這個診斷所需的證據刪掉了,後面的檢查只能對殘骸做判斷、自然歸錯類。修法是把順序反過來:

1// 先對原始輸入檢查格式——證據還在
2if (!RegExp(r'^[\d\-\s]+$').hasMatch(isbn)) {
3  return 'invalid_isbn_format';   // 正確的病名
4}
5// 通過格式檢查的才標準化、再驗位數
6final normalized = normalizeIsbn(isbn);

一般化的規則:診斷在證據被破壞之前做。管線裡任何有損轉換(去除字元、截斷、大小寫合併、去重)之後的檢查,都只能回報轉換後世界的錯誤——想給使用者他輸入層面的錯誤訊息、檢查就得在轉換前。這條規則在錯誤處理鏈上反覆適用:wrap 例外時保留原始例外、log 時保留原始輸入,同一個「別讓下游只看到殘骸」。

另一個小設計也值得帶走:ValidationResult 直接攜帶 normalizedIsbn / normalizedTitle——驗證跟標準化一次完成、下游拿標準化值繼續用,不會出現「驗證器驗一個版本、查詢用另一個版本」的分裂。

判讀徵兆

  • 測試建不出想要的 fixture、被建構驗證擋住——存在條件與輸入品質可能混在同一層、先釐清歸屬
  • 錯誤訊息與使用者的實際輸入對不上(說長度、其實是字元;說格式、其實是空值)——檢查點在有損轉換之後、往管線上游搬
  • UI 層用 try-catch 接建構例外再翻譯成表單錯誤——格式驗證放錯層了、它該回結構化結果不該拋
  • 驗證器回布林——錯誤碼、訊息、標準化值都沒有位置放,遲早長出第二套平行邏輯

相關閱讀