<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Encapsulation on Tarragon</title><link>https://tarrragon.github.io/blog/tags/encapsulation/</link><description>Recent content in Encapsulation on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/encapsulation/index.xml" rel="self" type="application/rss+xml"/><item><title>Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter</title><link>https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 work-log 裡，Value Object 的封裝政策在短時間內擺盪了兩輪：先把整套 VO 系統移除改直接字串、之後 VO 重新出現並推「完全封裝」（目標 0 個 &lt;code>.value&lt;/code> 外部存取）、撞牆後又把 &lt;code>.value&lt;/code> getter 加回來
&lt;strong>疑問來源&lt;/strong>：每一次轉向的理由單獨看都成立，為什麼會來回擺？穩態在哪裡？
&lt;strong>整理目的&lt;/strong>：記下擺盪的機制、兩個極端各自的撞牆點、以及 VO 封裝邊界的可操作判準
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.7.6 / v0.8.10 / v0.8.13 三份重構記錄——同一條決策線的三個時間點、不是三個獨立事件&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="三個時間點兩次反轉">三個時間點、兩次反轉&lt;/h2>
&lt;p>&lt;strong>第一步（v0.7.6）：全移除。&lt;/strong> 當時的狀態是 API 不一致——Book entity 已簡化成純字串、Library entity 還在用 &lt;code>book.id.value&lt;/code> 的 VO API，測試因此跑不起來。決策是把 VO 系統整個移除：&lt;code>book.id.value&lt;/code> 改 &lt;code>book.id&lt;/code>（裸字串）、比較邏輯改字串相等、記錄下來的效益是 API 直觀、記憶體降低。&lt;/p>
&lt;p>&lt;strong>第二步（v0.8.10）：完全封裝。&lt;/strong> VO 重新回到 codebase 後（BookId、BookTitle、BookISBN），新的重構往反方向推到底：目標「0 個 &lt;code>.value&lt;/code> 外部存取」、公開介面只留 &lt;code>toString()&lt;/code>（快取 key、資料庫）跟 &lt;code>displayValue&lt;/code>（UI 顯示）。理由同樣成立：&lt;code>.value&lt;/code> 暴露內部實作、同一個值有三種取法、測試綁死內部結構。代價是 176 個編譯錯誤起步，而且執行到 43% 就記錄了「工作量預估偏低」——cache 跟 database 這些基礎設施層對 &lt;code>.value&lt;/code> 的依賴遠比預期廣。&lt;/p>
&lt;p>&lt;strong>第三步（v0.8.13）：加回 getter。&lt;/strong> 當天深夜的緊急分析裡，BookId 跟 BookTitle「新增 &lt;code>.value&lt;/code> getter」被列為合理且必要的修改、定位是「提供必要的相容性介面」——而且被描述成「符合 v0.8.10 封裝性重構原則」。完全封裝的理想在依賴現實前退讓，退讓被重新命名成相容性。&lt;/p>
&lt;h2 id="兩個極端各自的撞牆點">兩個極端各自的撞牆點&lt;/h2>
&lt;p>擺盪的機制是：兩極的論述都對、但都只對一半。&lt;/p>
&lt;p>&lt;strong>純字串的撞牆點&lt;/strong>：移除 VO 的記錄只記了贏面（-50% 程式碼、效能），但執行過程被迫新建「內嵌佔位符類別」（LibraryId、LibraryStatistics）——這個動作本身就是反證：有些概念即使在「去 VO」的世界裡仍然需要一個型別的形狀。裸字串的世界裡，ISBN 校驗、ID 格式這些不變式失去了強制點，任何字串都能冒充任何 ID。&lt;/p>
&lt;p>&lt;strong>完全封裝的撞牆點&lt;/strong>：基礎設施層是真實存在的消費者——快取需要 key、資料庫需要 column 值、序列化需要原始表示。「0 個 &lt;code>.value&lt;/code>」把這些正當需求全部逼到 &lt;code>toString()&lt;/code> 上，而 &lt;code>toString()&lt;/code> 承擔不動：它的語意是「這個物件的字串表示」，跟「這個 VO 封裝的原始值」只是碰巧相等——哪天 &lt;code>toString()&lt;/code> 為了除錯改成 &lt;code>BookId(abc-123)&lt;/code> 格式，所有快取 key 就靜默換了一批。執行面還有一個 Dart 特有的陷阱被記錄下來：批次替換 &lt;code>.value&lt;/code> 時得逐處區分 VO 的 &lt;code>.value&lt;/code> 跟 &lt;code>Map&lt;/code> 的 &lt;code>.values&lt;/code>。&lt;/p>
&lt;h2 id="穩態原始值要有官方出口出口要有語意">穩態：原始值要有官方出口、出口要有語意&lt;/h2>
&lt;p>把兩次撞牆合起來看，VO 封裝的可操作邊界浮出來：&lt;strong>封裝的對象是「任意操作」、不是「取值」本身。&lt;/strong> 基礎設施邊界對原始值的需求是正當的，正確做法是給它一個語意明確的官方出口，而不是禁止取值逼下游硬撬：&lt;/p>
&lt;ul>
&lt;li>給序列化 / 持久化：&lt;code>toJsonString()&lt;/code>、&lt;code>toDbValue()&lt;/code> 這類名字說明用途的方法&lt;/li>
&lt;li>給 UI：&lt;code>displayValue&lt;/code>&lt;/li>
&lt;li>給確實需要原始型別的銜接層：一個顯式的拆封方法——同類專案裡 &lt;code>Money&lt;/code> extension type 的 &lt;code>toDecimal()&lt;/code> 是乾淨的例子，註解直接寫明「供確實需要 Decimal 的場合（如格式化銜接層）」&lt;/li>
&lt;/ul>
&lt;p>有官方出口的世界裡，「誰在拆封」是可 grep 的（搜尋 &lt;code>toDecimal(&lt;/code> 就是完整清單）；沒有出口的世界裡，下游會用 &lt;code>toString()&lt;/code> 硬接、或者像這個 case 一樣把 getter 加回來——而且加回來的 &lt;code>.value&lt;/code> 沒有任何語意標記，跟重構前一模一樣。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>重構記錄裡出現「相容性介面」——檢查它是不是理想撤退的重新命名；撤退本身可能是對的、但要記下「原目標為什麼不可行」，否則下一輪重構會再朝原目標衝一次&lt;/li>
&lt;li>決策記錄只記贏面——反向的代價（本 case：移除 VO 失去不變式強制點）沒被記錄時，下次擺回去的推力就還在&lt;/li>
&lt;li>封裝重構的錯誤數在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口而不是硬改&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 被當成取值 API 用在快取 key / DB 值上——語意寄生，格式一改就是靜默事故&lt;/li>
&lt;/ul>
&lt;p>擺盪的根治不在選對某一極，在於&lt;strong>把邊界寫成決策記錄&lt;/strong>：哪些出口存在、各自給誰用、為什麼不多不少。沒有這份記錄，每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a>——語意封閉與拆封口的判準層；&lt;a href="https://tarrragon.github.io/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a>——原始值出口穩態的教學層展開&lt;/li>
&lt;li>出口設計的正面案例：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">金額型別的三段遷移&lt;/a>——&lt;code>Money&lt;/code> 的 &lt;code>toDecimal()&lt;/code> 就是「官方拆封口」的形態&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷&lt;/a>——「沒有官方出口、下游硬撬」跟「工廠表達力不足、測試用 copyWith 拼」是同一個機制的兩個面&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 work-log 裡，Value Object 的封裝政策在短時間內擺盪了兩輪：先把整套 VO 系統移除改直接字串、之後 VO 重新出現並推「完全封裝」（目標 0 個 <code>.value</code> 外部存取）、撞牆後又把 <code>.value</code> getter 加回來
<strong>疑問來源</strong>：每一次轉向的理由單獨看都成立，為什麼會來回擺？穩態在哪裡？
<strong>整理目的</strong>：記下擺盪的機制、兩個極端各自的撞牆點、以及 VO 封裝邊界的可操作判準
<strong>本文邊界</strong>：素材是該專案 v0.7.6 / v0.8.10 / v0.8.13 三份重構記錄——同一條決策線的三個時間點、不是三個獨立事件</p></blockquote>
<hr>
<h2 id="三個時間點兩次反轉">三個時間點、兩次反轉</h2>
<p><strong>第一步（v0.7.6）：全移除。</strong> 當時的狀態是 API 不一致——Book entity 已簡化成純字串、Library entity 還在用 <code>book.id.value</code> 的 VO API，測試因此跑不起來。決策是把 VO 系統整個移除：<code>book.id.value</code> 改 <code>book.id</code>（裸字串）、比較邏輯改字串相等、記錄下來的效益是 API 直觀、記憶體降低。</p>
<p><strong>第二步（v0.8.10）：完全封裝。</strong> VO 重新回到 codebase 後（BookId、BookTitle、BookISBN），新的重構往反方向推到底：目標「0 個 <code>.value</code> 外部存取」、公開介面只留 <code>toString()</code>（快取 key、資料庫）跟 <code>displayValue</code>（UI 顯示）。理由同樣成立：<code>.value</code> 暴露內部實作、同一個值有三種取法、測試綁死內部結構。代價是 176 個編譯錯誤起步，而且執行到 43% 就記錄了「工作量預估偏低」——cache 跟 database 這些基礎設施層對 <code>.value</code> 的依賴遠比預期廣。</p>
<p><strong>第三步（v0.8.13）：加回 getter。</strong> 當天深夜的緊急分析裡，BookId 跟 BookTitle「新增 <code>.value</code> getter」被列為合理且必要的修改、定位是「提供必要的相容性介面」——而且被描述成「符合 v0.8.10 封裝性重構原則」。完全封裝的理想在依賴現實前退讓，退讓被重新命名成相容性。</p>
<h2 id="兩個極端各自的撞牆點">兩個極端各自的撞牆點</h2>
<p>擺盪的機制是：兩極的論述都對、但都只對一半。</p>
<p><strong>純字串的撞牆點</strong>：移除 VO 的記錄只記了贏面（-50% 程式碼、效能），但執行過程被迫新建「內嵌佔位符類別」（LibraryId、LibraryStatistics）——這個動作本身就是反證：有些概念即使在「去 VO」的世界裡仍然需要一個型別的形狀。裸字串的世界裡，ISBN 校驗、ID 格式這些不變式失去了強制點，任何字串都能冒充任何 ID。</p>
<p><strong>完全封裝的撞牆點</strong>：基礎設施層是真實存在的消費者——快取需要 key、資料庫需要 column 值、序列化需要原始表示。「0 個 <code>.value</code>」把這些正當需求全部逼到 <code>toString()</code> 上，而 <code>toString()</code> 承擔不動：它的語意是「這個物件的字串表示」，跟「這個 VO 封裝的原始值」只是碰巧相等——哪天 <code>toString()</code> 為了除錯改成 <code>BookId(abc-123)</code> 格式，所有快取 key 就靜默換了一批。執行面還有一個 Dart 特有的陷阱被記錄下來：批次替換 <code>.value</code> 時得逐處區分 VO 的 <code>.value</code> 跟 <code>Map</code> 的 <code>.values</code>。</p>
<h2 id="穩態原始值要有官方出口出口要有語意">穩態：原始值要有官方出口、出口要有語意</h2>
<p>把兩次撞牆合起來看，VO 封裝的可操作邊界浮出來：<strong>封裝的對象是「任意操作」、不是「取值」本身。</strong> 基礎設施邊界對原始值的需求是正當的，正確做法是給它一個語意明確的官方出口，而不是禁止取值逼下游硬撬：</p>
<ul>
<li>給序列化 / 持久化：<code>toJsonString()</code>、<code>toDbValue()</code> 這類名字說明用途的方法</li>
<li>給 UI：<code>displayValue</code></li>
<li>給確實需要原始型別的銜接層：一個顯式的拆封方法——同類專案裡 <code>Money</code> extension type 的 <code>toDecimal()</code> 是乾淨的例子，註解直接寫明「供確實需要 Decimal 的場合（如格式化銜接層）」</li>
</ul>
<p>有官方出口的世界裡，「誰在拆封」是可 grep 的（搜尋 <code>toDecimal(</code> 就是完整清單）；沒有出口的世界裡，下游會用 <code>toString()</code> 硬接、或者像這個 case 一樣把 getter 加回來——而且加回來的 <code>.value</code> 沒有任何語意標記，跟重構前一模一樣。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>重構記錄裡出現「相容性介面」——檢查它是不是理想撤退的重新命名；撤退本身可能是對的、但要記下「原目標為什麼不可行」，否則下一輪重構會再朝原目標衝一次</li>
<li>決策記錄只記贏面——反向的代價（本 case：移除 VO 失去不變式強制點）沒被記錄時，下次擺回去的推力就還在</li>
<li>封裝重構的錯誤數在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口而不是硬改</li>
<li><code>toString()</code> 被當成取值 API 用在快取 key / DB 值上——語意寄生，格式一改就是靜默事故</li>
</ul>
<p>擺盪的根治不在選對某一極，在於<strong>把邊界寫成決策記錄</strong>：哪些出口存在、各自給誰用、為什麼不多不少。沒有這份記錄，每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a>——語意封閉與拆封口的判準層；<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>——原始值出口穩態的教學層展開</li>
<li>出口設計的正面案例：<a href="/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">金額型別的三段遷移</a>——<code>Money</code> 的 <code>toDecimal()</code> 就是「官方拆封口」的形態</li>
<li>原則層：<a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>——「沒有官方出口、下游硬撬」跟「工廠表達力不足、測試用 copyWith 拼」是同一個機制的兩個面</li>
</ul>
]]></content:encoded></item></channel></rss>