不變式的強制層次 展開了建構期不變式(物件建構時必須通過的業務規則檢查)——物件出生的那一刻就必須合法。本章接下來的問題是建構路徑本身的設計:工廠的表達力有沒有覆蓋消費者的正當需求、出口有沒有給原始值一個語意明確的位置。「讓違反規則的路徑走不通」在建構路徑有一個常被忽略的前提——「讓遵守規則的路徑走得通」:正當需求在工廠裡找不到出口時、逃生口就成了預設路徑、規則也跟著失效。

工廠的表達力與消費者需求

工廠(建構子、命名工廠、builder)是合法物件的唯一入口。它的職責有兩個:保證出生即合法(不變式檢查)、同時覆蓋消費者的正當需求(給得出消費者想要的合法組合)。兩個職責有張力——保證越嚴、接受的參數越窄;解法是讓工廠的表達力跟上需求、而不是放棄保證。

一個書籍管理 App 的 createForTest 工廠只接受 id / title / author / isbn 四個參數。測試想表達「預設 tags 再追加三個自訂 tag」——這是合法的需求、工廠的表達力沒覆蓋到。需求進不了工廠,就會從別的出口出去——在這個專案,出口是全欄位 copyWith(copyWith 是逃生口,不是設計)。

工廠參數列的設計判準:收的是「有語意的需求」(預設 tags 加自訂、指定初始狀態、帶入驗證過的關聯物件),不是「所有欄位的任意值」。後者退化成另一個全欄位建構器,保證也跟著消失——分寸在「覆蓋高頻的合法組合、不開放任意拼裝」——「高頻」的門檻見下一節:同一組合出現第二次就收進工廠。

逃生口吸收建構路徑的缺陷

逃生口吸收是建構路徑缺陷長期不被修好的核心機制:表達力缺口碰上萬能拼裝工具、上游缺陷被下游繞道消化、工廠永遠不會被迫改進。機制分三步。第一步、工廠表達不了需求:上述 createForTest 不接受 bookTags。第二步、逃生口接住需求:copyWith 總是能拼、它成為唯一出路。第三步、拼裝現場的最短路徑埋下語意錯誤:在用 copyWith 拼的當下、順手建個臨時物件撈預設值(Book.createForTest(id: 'tmp').bookTags)是阻力最小的寫法——而 'tmp' 不滿足 BookId 的長度約束。同型寫法幾天前才在另一個檔案修過一次。

關鍵在第二步的因果方向:逃生口不只讓錯誤寫法變可能、它讓正確的修法變不必要。沒有逃生口時、工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法;有逃生口時、每個被擋的需求都繞出去了、工廠的缺陷永遠不會痛。同族語意錯誤第二次出現就是結構在選擇行為的證據:兩個作者(或同作者在不同時刻)在同一個表達力缺口前面各自獨立走出同一條最短路徑(copyWith 是逃生口,不是設計)。

修法對準上游:讓工廠能表達需求(createForTest 接受 bookTags),拼裝的動機就消失了。修每一個拼裝點——把 'tmp' 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀,下一個測試還是會在同一個表達力缺口前面選擇拼裝。門檻是兩次:第一次拼裝可以當個案修掉——只出現一次的特殊組合用拼裝是合理的、工廠也有參數列膨脹的反向代價。第二次出現就該停止修拼裝點、轉頭找共同面對的表達力缺口。工廠產物的 copyWith 呼叫在覆寫哪些欄位、就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查(#223 逃生口吸收建構路徑的缺陷)。

原始值的官方出口

建構路徑的另一面是出口:value object 封裝了原始值之後、基礎設施層(快取 key、資料庫 column、序列化)要怎麼拿到它需要的表示。穩態是封裝任意操作、而非封裝取值本身——基礎設施邊界的需求是正當的、該給語意明確的出口。這個穩態從擺盪中浮出。

同一個書籍管理 App 的 value object 封裝在三個版本間擺盪。v0.7.6 全移除(裸字串),不變式失去強制點、任何字串冒充任何 ID。隨後 v0.8.10 推向另一極——完全封裝、目標零個 .value 外部存取——基礎設施層的 176 個編譯錯誤暴露這些消費是正當需求,把它們全逼到 toString() 上是語意寄生、格式一改快取 key 靜默換一批。最終 v0.8.13 加回 .value getter、被重新命名成「相容性介面」,實質是理想在依賴現實前退讓(VO 的封裝擺盪)。

穩態的操作化:出口的名字說明用途:toJsonString()(序列化)、toDbValue()(持久化)、displayValue(UI)。「誰在拆封」就變成可 grep 的——一個 POS 專案的 Money 型別用 toDecimal() 作為官方拆封口、搜尋 toDecimal( 就是拆封清單。沒有官方出口的世界裡、下游用 toString() 硬接或把 .value getter 加回來、拆封處完全不可追蹤。

擺盪的根治不在選對某一極、在於把邊界寫成決策記錄:哪些出口存在、各自給誰用、為什麼不多不少。出口增長到大多數消費者都有專屬方法時、封裝的值已不成立——此時直接暴露一個語意明確的拆封方法,比維護多個用途近似的出口便宜。沒有這份記錄、每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。判讀徵兆:重構記錄出現「相容性介面」——檢查它是不是理想撤退的重新命名;決策記錄只記贏面——反方向的代價沒被記、下次擺回去的推力還在;封裝重構在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口不是硬改。

判讀訊號

  • 測試 Arrange 段出現「工廠 + copyWith 拼裝」——盤點拼裝在補什麼欄位、高頻欄位收進工廠參數列。工廠參數列長期不變、而它的產物被 copyWith 環繞——表達力已落後需求,原則見 #223
  • 建一個立刻丟棄的物件、只為了拿它的一個欄位?這是語意錯誤的標記,先找它真正想表達的需求。
  • 同族語意錯誤第二次出現——停止修個案、找兩個案共同面對的建構路徑缺口。
  • toString() 被當成取值 API 用在快取 key / DB 值——語意寄生、格式一改靜默事故。出口要有語意明確的名字,語意封閉判準見 entity 與 value object 的判準
  • VO 封裝重構的編譯錯誤在基礎設施層爆量——基礎設施是正當消費者、該給出口。

下一步