觸發場景:Flutter 書籍管理 App 的 work-log 裡,Value Object 的封裝政策在短時間內擺盪了兩輪:先把整套 VO 系統移除改直接字串、之後 VO 重新出現並推「完全封裝」(目標 0 個 .value 外部存取)、撞牆後又把 .value getter 加回來 疑問來源:每一次轉向的理由單獨看都成立,為什麼會來回擺?穩態在哪裡? 整理目的:記下擺盪的機制、兩個極端各自的撞牆點、以及 VO 封裝邊界的可操作判準 本文邊界:素材是該專案 v0.7.6 / v0.8.10 / v0.8.13 三份重構記錄——同一條決策線的三個時間點、不是三個獨立事件


三個時間點、兩次反轉

第一步(v0.7.6):全移除。 當時的狀態是 API 不一致——Book entity 已簡化成純字串、Library entity 還在用 book.id.value 的 VO API,測試因此跑不起來。決策是把 VO 系統整個移除:book.id.valuebook.id(裸字串)、比較邏輯改字串相等、記錄下來的效益是 API 直觀、記憶體降低。

第二步(v0.8.10):完全封裝。 VO 重新回到 codebase 後(BookId、BookTitle、BookISBN),新的重構往反方向推到底:目標「0 個 .value 外部存取」、公開介面只留 toString()(快取 key、資料庫)跟 displayValue(UI 顯示)。理由同樣成立:.value 暴露內部實作、同一個值有三種取法、測試綁死內部結構。代價是 176 個編譯錯誤起步,而且執行到 43% 就記錄了「工作量預估偏低」——cache 跟 database 這些基礎設施層對 .value 的依賴遠比預期廣。

第三步(v0.8.13):加回 getter。 當天深夜的緊急分析裡,BookId 跟 BookTitle「新增 .value getter」被列為合理且必要的修改、定位是「提供必要的相容性介面」——而且被描述成「符合 v0.8.10 封裝性重構原則」。完全封裝的理想在依賴現實前退讓,退讓被重新命名成相容性。

兩個極端各自的撞牆點

擺盪的機制是:兩極的論述都對、但都只對一半。

純字串的撞牆點:移除 VO 的記錄只記了贏面(-50% 程式碼、效能),但執行過程被迫新建「內嵌佔位符類別」(LibraryId、LibraryStatistics)——這個動作本身就是反證:有些概念即使在「去 VO」的世界裡仍然需要一個型別的形狀。裸字串的世界裡,ISBN 校驗、ID 格式這些不變式失去了強制點,任何字串都能冒充任何 ID。

完全封裝的撞牆點:基礎設施層是真實存在的消費者——快取需要 key、資料庫需要 column 值、序列化需要原始表示。「0 個 .value」把這些正當需求全部逼到 toString() 上,而 toString() 承擔不動:它的語意是「這個物件的字串表示」,跟「這個 VO 封裝的原始值」只是碰巧相等——哪天 toString() 為了除錯改成 BookId(abc-123) 格式,所有快取 key 就靜默換了一批。執行面還有一個 Dart 特有的陷阱被記錄下來:批次替換 .value 時得逐處區分 VO 的 .valueMap.values

穩態:原始值要有官方出口、出口要有語意

把兩次撞牆合起來看,VO 封裝的可操作邊界浮出來:封裝的對象是「任意操作」、不是「取值」本身。 基礎設施邊界對原始值的需求是正當的,正確做法是給它一個語意明確的官方出口,而不是禁止取值逼下游硬撬:

  • 給序列化 / 持久化:toJsonString()toDbValue() 這類名字說明用途的方法
  • 給 UI:displayValue
  • 給確實需要原始型別的銜接層:一個顯式的拆封方法——同類專案裡 Money extension type 的 toDecimal() 是乾淨的例子,註解直接寫明「供確實需要 Decimal 的場合(如格式化銜接層)」

有官方出口的世界裡,「誰在拆封」是可 grep 的(搜尋 toDecimal( 就是完整清單);沒有出口的世界裡,下游會用 toString() 硬接、或者像這個 case 一樣把 getter 加回來——而且加回來的 .value 沒有任何語意標記,跟重構前一模一樣。

判讀徵兆

  • 重構記錄裡出現「相容性介面」——檢查它是不是理想撤退的重新命名;撤退本身可能是對的、但要記下「原目標為什麼不可行」,否則下一輪重構會再朝原目標衝一次
  • 決策記錄只記贏面——反向的代價(本 case:移除 VO 失去不變式強制點)沒被記錄時,下次擺回去的推力就還在
  • 封裝重構的錯誤數在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口而不是硬改
  • toString() 被當成取值 API 用在快取 key / DB 值上——語意寄生,格式一改就是靜默事故

擺盪的根治不在選對某一極,在於把邊界寫成決策記錄:哪些出口存在、各自給誰用、為什麼不多不少。沒有這份記錄,每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。

相關閱讀