論述基礎與限制

本卡的論述基於一個 Dart 專案(書籍管理 App)的 entity 設計檢視:Book entity 帶一組會寫入稽核紀錄的領域方法,同時暴露一個 public 的、參數列包含 statusmodificationHistory 的全欄位 copyWith。grep 實證找到兩處直接改 status 繞過領域方法的呼叫點,以及一個文件註解宣稱「只能從 enriching 狀態轉換」但函式體內 if / assert / throw 計數為零的方法。完整 case 見 copyWith 是逃生口,不是設計、教學層展開見 不變式的強制層次狀態轉換與稽核軌跡(變更路徑收斂)、建構路徑設計(工廠表達力缺陷轉移)。具體限制:

  • 單一專案觀察。逃生口的具體形態(全欄位 copyWith)是 Dart / freezed 生態的產物;其他語言的逃生口長成 public setter、反射、Object.assign 等形態,機制相同、證據來自這一種。
  • 本卡談意圖的落點層次,不談意圖本身對不對。一個錯的約束被完美強制,是另一類問題。

核心原則

設計意圖有三個落點:文件層(註解、命名、慣例)、型別層(讓非法狀態無法表達)、執行層(檢查後拒絕)。 只落在文件層的意圖,對違反它的路徑沒有任何阻力。

「狀態轉換請走領域方法」是慣例,copyWith 不擋你;「只能從 enriching 轉換」是註解,實作不查你;「測試該用工廠建物件」是期望,工廠表達力不夠你就繞。每一個「請、應該、建議」都是一個沒關上的逃生口——而逃生口的使用者不是壞人,他們只是走了阻力最小的路。要讓意圖成立,就得讓違反意圖的路徑走不通,而不是寫文件請大家不要走。

文件層約束有一個比「沒有約束」更糟的形態:註解宣稱了約束、實作從未強制。讀者(包含 reviewer)看到「約束:只能從 enriching 狀態轉換」會以為有防護,於是不再檢查——宣稱本身消滅了發現缺口的機會。而且強制若只加在方法內部仍不完整:只要逃生口還開著(copyWith(status: ...) 繞得過去),方法內的檢查形同虛設。約束要成立,逃生口得先關上。

判斷一個型別需不需要關逃生口,濃縮成一個問題:這個型別有沒有「不允許任意組合的欄位」? 有——狀態欄位必須經由領域方法變更、歷史欄位必須隨狀態同步追加——逃生口就不該讓那些欄位 public 可寫。沒有——DTO、UI state、value object 就是一袋欄位——全欄位複製工具是正當的便利。


具體 case

case 1:領域方法從「唯一路徑」降級成「建議路徑」

Book 的狀態轉換方法(markAsAvailable()completeEnrichment() 等)每次呼叫都往 modificationHistory 追加一筆稽核紀錄——這是領域模型的核心價值:狀態怎麼變的,有跡可循。但 public copyWith 的參數列包含 status,於是工廠層出現 copyWith(status: BookStatus.available) 這類直接改值的呼叫。這些狀態轉換沒有進入稽核紀錄,而且是靜默的:沒有錯誤、沒有警告、沒有測試失敗。

case 2:註解宣稱的約束,grep 計數為零

completeEnrichment() 的文件註解寫「約束:只能從enriching狀態轉換,確保狀態流程正確」。函式體內沒有任何檢查。這個註解存在的每一天,都在讓讀者以為狀態機有防護。

case 3:修法是分層收窄、不是消滅

該專案 copyWith 呼叫點有四百餘處,多數落在 value object 與 UI state 上——那裡它是正確工具。收窄只針對有領域方法的 entity:copyWith 改 private 供領域方法內部使用,或至少從參數列移除「必須經由領域方法變更」的欄位。型別層能承接哪些文件層內容、承接不了的剩餘部分(業務動機、時序、性能),見 型別取代 doc 的收益曲線


沒這樣做的麻煩

稽核軌跡的洞是靜默的、以使用速度累積

每一個繞過領域方法的呼叫點都在稽核紀錄上留一個洞。洞不報錯、不影響功能,最終浮現在需要稽核紀錄的下游——事故回溯時發現某段狀態變化沒有記錄,而那已經是資料寫入很久以後。

宣稱的約束讓 review 放行

Reviewer 看到註解裡的「約束」字樣會核對呼叫端有沒有遵守,很少回頭驗證約束本身有沒有被實作。文件層的宣稱披著執行層的外衣通過審查——這比什麼都不寫更能保護違規。

慣例跟預設打架時、預設會贏

Dart 生態把 copyWith 做成預設路徑:freezed 自動生成、IDE 補全第一個跳出。規範說「請走領域方法」、工具預設給全欄位 copyWith,長期下來團隊的實際行為跟著預設走(同見 工具的預設行為決定使用者習慣)。文件層意圖對抗的不是個別工程師的紀律,是整個生態的重力。


跟其他抽象層原則的關係

  • #221 檢查規則的作用域要顯式列舉:同構。#221 的「規則存在」與「規則涵蓋」是兩個獨立 fact、只有前者被討論;本卡的「意圖被宣稱」與「意圖被強制」也是兩個獨立 fact、註解只交付前者。兩者的失效同樣靜默——違規不產生任何訊號、症狀在遠離根因的下游浮現(列表排序 / 稽核回溯)。
  • #124 Emergence-class 違規規則化不了、要 stage 內抽樣:#124 把寫作違規按類型對應到 enforcement 形式(字面用 hook、結構用 lint、emergence 抽樣),本卡是同一個「約束類型決定強制手段」原則在程式設計層的形態:可列舉的非法組合落型別層、需要 runtime 資訊的轉換條件落執行層、表達不了的(業務動機、時序)才留文件層——留下時要誠實標示它沒有強制力。
  • #100 False sense of security 是資安寫作的主要失敗模式:case 2 是同一失敗模式在程式碼註解的形態。#100 的教學讓讀者「以為做了 X 就安全」,本卡的註解讓讀者「以為有約束就不會發生」;silent gap 都比 noisy gap 貴——發現時已累積。
  • #110 設計檢討用當下三軸論證、不依賴 hindsight:本卡判定「public copyWith 掛在 entity 上是設計缺陷」用的是當下三軸、不是結局:當下就存在成本對稱的替代(private copyWith / 參數列排除狀態欄位)、可逆性低(呼叫點隨時間擴散)、領域先驗明確(DDD 對 entity 變更路徑有既有共識)。歸因落在工具預設與結構,不落在寫下繞過呼叫的個人。
  • #67 寫作便利度跟意圖對齊反相關:逃生口正是「便利贏過意圖」的程式結構形態。#67 描述便利驅動如何系統性偏離意圖,本卡給出結構性對策——把意圖做進阻力結構裡,讓正確的路徑同時是省力的路徑。
  • #253 寫註解的動機是怕被改壞時,要處理的是那個約束:本卡的上游加一類手段。本卡從「這個意圖確定要強制」起步、排的是落點;#253 補前面兩步——先辨識這是不是防護需求(判準是動機而非文字),再問這個約束能不能被消除(約束多半是某個結構選擇的產物,消除之後三個落點都不必看)。它補的是本卡三層排不進去的那個落點。本卡的三層(註解、介面簽名、建構子檢查)都寫在被約束的產物裡,沿「違反時發生什麼」排成一條刻度;#253 指出這條刻度把「規則寫在哪」與「何時發聲」壓成了一條,而產物外那一側只被看到 CI 一格、裡面又只有讀程式文本的檢查。跨函式的讀寫順序這類約束在三層裡每一格都塞不進去,沿刻度找的人會被送回文件層——它真正的落點是一條觀測執行行為的測試。
  • #277 通過關卡不等於通過的是同一個程式:下游的限制。執行層裝上之後,關卡仍然只在它問過的維度上出聲;本卡的「有沒有會發聲的層」要再加一問——那一層問了哪些維度、哪些維度之間有交叉。
  • #278 機械約束買到被量測的那個數字:下游的限制。會發聲的約束會被最便宜的達成路徑滿足,而繞道的產物在指標上合規;挑執行層時要一併問「達成路徑有幾條」。

判讀徵兆

徵兆該做的行動
註解 / 文件出現「約束」「只能」「必須」但函式體沒有對應檢查補執行層檢查、或改寫註解誠實標示這是未強制的慣例
entity 有 public 全欄位 copyWith 且參數列包含狀態類欄位收窄:copyWith 改 private、或從參數列移除必須走領域方法的欄位
規範說「請走 X」而繞過 X 的路徑沒有任何阻力評估把意圖上移到型別層或執行層、把「請」變成「走不通」
code review 中出現「這裡建議走領域方法」的重複留言重複留言是文件層失效的訊號、該修結構不是修每個呼叫點
稽核 / 歷史欄位出現在任何 public 寫入介面的參數列該欄位的一致性已不受保護、從參數列移除
說得出「寫這行註解是怕有人改壞」這是防護需求走錯窗口、依 #253 先問約束能不能消除、再挑會發聲的層(含測試)

第一項成本最低:grep 註解裡的「約束」「只能從」再對照函式體,一次掃描就能盤出所有「宣稱了但沒強制」的位置。第四項最容易被當成溝通問題處理——留言教育呼叫者——但同一提醒出現第二次就是結構訊號,修法在型別不在留言。


適用範圍與邊界

  • 適用:有領域不變式的 entity / aggregate、狀態機、任何「某些欄位組合不合法」的型別;也適用於流程慣例(「部署前請跑 X」)——同樣的問題是「違反路徑有沒有阻力」。
  • 邊界
    • 純資料載體不適用:DTO、API model、UI state、小 value object 沒有不變式,全欄位複製工具是正確選擇、不是逃生口。
    • 型別層表達力有限:業務動機、性能特性、時序約束多數型別系統表達不了,這些留在文件層是誠實而非缺陷——差別在不要用「約束」的口吻宣稱強制力。
    • 原型期慣例可接受:探索階段用慣例換速度是合理交易,但要有升級 trigger(例如第一個繞過實例出現時),不是永遠停在慣例。