<?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>Copywith on Tarragon</title><link>https://tarrragon.github.io/blog/tags/copywith/</link><description>Recent content in Copywith on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 20 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/copywith/index.xml" rel="self" type="application/rss+xml"/><item><title>copyWith</title><link>https://tarrragon.github.io/blog/flutter/knowledge-cards/copywith/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/knowledge-cards/copywith/</guid><description>&lt;p>copyWith 是 Dart 生態中逐欄位覆寫物件的慣用方法：呼叫時只傳要改的欄位、其餘保留原值、回傳一個新實例。&lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed&lt;/a> 自動為每個 model 生成 copyWith，IDE 補全第一個跳出來的也是它——它是 Dart 的預設路徑。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>copyWith 對&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/data-bag/" data-link-title="Data Bag" data-link-desc="判斷一個型別要不要投資領域模型設計時使用。資料袋是欄位組合全部合法、沒有不變式要守的型別——DTO、API model、UI state 都屬於這一類。">資料袋&lt;/a>（DTO、API model、UI state）是正確工具——欄位組合全部合法、逐欄位覆寫語意清晰。但對有領域方法的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/entity/" data-link-title="Entity" data-link-desc="判斷一個概念該建成 entity 還是 value object 時使用。entity 的同一性由身份定義——欄位全部改變、只要身份參照不變就是同一個。">entity&lt;/a>、copyWith 是繞過&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>的逃生口：領域方法從「唯一路徑」降級成「建議路徑」、稽核軌跡開始出洞。判準是型別有沒有「不允許任意組合的欄位」——有，copyWith 就不該讓那些欄位 public 可寫。多數專案的 copyWith 由 &lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed&lt;/a> 生成、不是手寫——這也是它對所有型別一視同仁套用的原因。&lt;/p>
&lt;h2 id="nullable-欄位的三態缺口">Nullable 欄位的三態缺口&lt;/h2>
&lt;p>copyWith 在 nullable 欄位上有一個 Dart 型別系統的缺口：&lt;code>String? isbn&lt;/code> 只有兩態（有值 / null），而 copyWith 需要三態——「不改這欄」「改成某值」「清空成 null」。前兩態沒問題，第三態表達不出來。通用的補償手法是哨兵物件（sentinel），freezed 生成的 copyWith 內部就是用同樣的技巧。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>收窄的方向分三層：value object / DTO / UI state 保留 copyWith；有領域方法的 entity 把 copyWith 改 private 或從參數列移除受約束欄位；測試建構需求不足時修工廠的表達力、不修每一個拼裝點。完整機制見 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>、教學層見 &lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>copyWith 是 Dart 生態中逐欄位覆寫物件的慣用方法：呼叫時只傳要改的欄位、其餘保留原值、回傳一個新實例。<a href="/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed</a> 自動為每個 model 生成 copyWith，IDE 補全第一個跳出來的也是它——它是 Dart 的預設路徑。</p>
<h2 id="概念位置">概念位置</h2>
<p>copyWith 對<a href="/blog/ddd/knowledge-cards/data-bag/" data-link-title="Data Bag" data-link-desc="判斷一個型別要不要投資領域模型設計時使用。資料袋是欄位組合全部合法、沒有不變式要守的型別——DTO、API model、UI state 都屬於這一類。">資料袋</a>（DTO、API model、UI state）是正確工具——欄位組合全部合法、逐欄位覆寫語意清晰。但對有領域方法的 <a href="/blog/ddd/knowledge-cards/entity/" data-link-title="Entity" data-link-desc="判斷一個概念該建成 entity 還是 value object 時使用。entity 的同一性由身份定義——欄位全部改變、只要身份參照不變就是同一個。">entity</a>、copyWith 是繞過<a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>的逃生口：領域方法從「唯一路徑」降級成「建議路徑」、稽核軌跡開始出洞。判準是型別有沒有「不允許任意組合的欄位」——有，copyWith 就不該讓那些欄位 public 可寫。多數專案的 copyWith 由 <a href="/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed</a> 生成、不是手寫——這也是它對所有型別一視同仁套用的原因。</p>
<h2 id="nullable-欄位的三態缺口">Nullable 欄位的三態缺口</h2>
<p>copyWith 在 nullable 欄位上有一個 Dart 型別系統的缺口：<code>String? isbn</code> 只有兩態（有值 / null），而 copyWith 需要三態——「不改這欄」「改成某值」「清空成 null」。前兩態沒問題，第三態表達不出來。通用的補償手法是哨兵物件（sentinel），freezed 生成的 copyWith 內部就是用同樣的技巧。</p>
<h2 id="設計責任">設計責任</h2>
<p>收窄的方向分三層：value object / DTO / UI state 保留 copyWith；有領域方法的 entity 把 copyWith 改 private 或從參數列移除受約束欄位；測試建構需求不足時修工廠的表達力、不修每一個拼裝點。完整機制見 <a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>、教學層見 <a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>。</p>
]]></content:encoded></item><item><title>值物件的 Dart 實作路徑</title><link>https://tarrragon.github.io/blog/flutter/value-object-dart-implementation/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/value-object-dart-implementation/</guid><description>&lt;p>值物件在實作層的責任是把一個領域值裝進專用型別、讓型別開放的運算限縮成領域有意義的封閉集合。金額只該加減、乘數量、乘倍率；識別碼只該比對與傳遞；日期範圍只該判包含與交疊。底層的通用型別（數字、字串）開放的運算遠多於這個集合，差集裡的每個運算都是一個等著被誤用的 API——把值物件建起來，就是把差集從型別上關掉。&lt;/p>
&lt;p>「這個領域值該不該升級成值物件」的判定屬於理論層，判準是同一性語意與語意封閉、與語言無關，見 &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/knowledge-cards/value-object/" data-link-title="Value Object" data-link-desc="判斷一個概念該用內容比對還是身份追蹤時使用。value object 的同一性由內容定義——內容相等就是同一個、替換實例對系統沒有影響。">value object&lt;/a> 卡。本章接手判定之後的問題：在 Dart 裡，同一個「值物件」有三種實作載體，成本結構與適用情境各異——選哪條由這個值的欄位數、要不要 runtime 身份、以及專案對產生器的容忍度決定。&lt;/p>
&lt;h2 id="判斷什麼領域值值得升級">判斷：什麼領域值值得升級&lt;/h2>
&lt;p>值得升級的訊號是一個領域概念以裸的通用型別跨模組流通、而它的合法運算明顯少於底層型別。金額用 &lt;code>double&lt;/code> 或 &lt;code>Decimal&lt;/code>、識別碼用 &lt;code>String&lt;/code>、數量用 &lt;code>int&lt;/code>——這些型別在模組之間傳遞時，型別系統對「金額乘金額」「識別碼相加」這類無意義運算全部放行，因為它們在底層型別的世界裡都合法。合法運算集合小於底層集合、且裸型別已經跨越模組邊界，包一層的價值就成立。&lt;/p>
&lt;p>這裡有兩個常被壓成一個的獨立問題。精度是底層表示的問題——浮點數累加金額會把誤差堆到分位，換一個高精度數字型別就解決。語意是運算邊界的問題——換完精度型別後，「任何人都能對這個值做任意運算」原封不動。解掉第一個問題的當下第二個問題完整存在，而它要等夠多「拿金額亂算」的路徑累積後才顯形。把兩者混為一談的後果是換完 &lt;code>Decimal&lt;/code> 就宣告收工、語意缺口留在原地。&lt;/p>
&lt;p>反過來，不是每個領域值都值得升級。合法運算集合幾乎等於底層型別的集合時（一個真的就是任意整數的計數器），封閉沒有差集可關、專用型別只是多一層轉換。裸型別從不跨越模組邊界、只在單一函式內部短暫存在時，誤用的窗口太小、升級的維護成本收不回。判準操作化成一句話：盤點這個概念的合法運算清單、跟底層型別的運算集合做差集，差集非空且裸型別在模組間流動，才動手。&lt;/p>
&lt;h2 id="實作路徑的成本結構">實作路徑的成本結構&lt;/h2>
&lt;p>三種載體對應兩條軸：這個值是單一底層值還是多欄位複合值、以及需不需要 runtime 的型別身份。單一底層值（金額包一個數字、識別碼包一個字串）走 extension type 最省；多欄位複合值（地址、日期範圍、含幣別的金額）需要一個真正的物件裝多個欄位，走 class 路徑，class 又分手寫與 freezed 產生兩種。runtime 身份的需求橫切這兩軸——需要 &lt;code>is Money&lt;/code> 在執行期為真、或需要反射看得到型別時，只有 class 路徑滿足。&lt;/p>
&lt;p>Dart 3 的 record 是多欄位載體、還自帶結構相等，看起來像多欄位複合值的第四條路徑，但它不入選值物件的載體、原因在型別語意。record 是結構型別而非名目型別：兩個欄位形狀相同的概念（&lt;code>(String, String)&lt;/code> 的地址與姓名）在型別系統裡是同一個 record 型別、拿不到 &lt;code>DateRange&lt;/code> 這種領域名字，也就換不到「傳錯型別編譯期就擋」的保護。record 沒有建構子、無處掛不變式檢查，也無法限縮 API——任何拿到它的程式碼都能讀所有欄位、組任意新值。值物件要的名目身份、建構期不變式、封閉介面，record 結構上三個都不給。它適合的是函式的匿名多回傳與臨時分組，不是需要領域約束的值物件。&lt;/p>
&lt;h3 id="手寫-immutable-class-與-copywith">手寫 immutable class 與 copyWith&lt;/h3>
&lt;p>手寫 immutable class 是最直接的載體：所有欄位 &lt;code>final&lt;/code>、建構子帶不變式檢查、要「改」就造一個新實例。多欄位複合值在這條路徑上用 &lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/copywith/" data-link-title="copyWith" data-link-desc="物件的逐欄位覆寫方法在什麼時候是正確工具、什麼時候是逃生口時使用。copyWith 對資料袋語意清晰、對有領域方法的 entity 是繞過不變式的逃生口。">copyWith&lt;/a> 做逐欄位覆寫——傳要改的欄位、其餘保留原值、回傳新實例，這對欄位組合全部合法的值語意清晰。成本在 boilerplate：&lt;code>==&lt;/code> 與 &lt;code>hashCode&lt;/code> 要手寫且要涵蓋所有參與相等性的欄位、&lt;code>copyWith&lt;/code> 每加一個欄位就要同步一行，漏一個欄位的相等性比對是安靜的 bug。&lt;/p>
&lt;p>這條路徑的邊界在 copyWith 的適用範圍。對欄位組合全部合法的資料袋與純值物件，copyWith 是正確工具；對有領域方法、欄位之間有不變式約束的型別，全欄位 public 的 copyWith 是繞過領域方法的逃生口——領域方法從「唯一變更路徑」降級成「建議路徑」。判準是型別有沒有「不允許任意組合的欄位」，有的話那些欄位就不該讓 copyWith public 可寫。完整機制見 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>。&lt;/p>
&lt;h3 id="freezed-產生器">freezed 產生器&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed&lt;/a> 是把手寫 class 的 boilerplate 壓到接近零的產生器：標記 &lt;code>@freezed&lt;/code> 後自動產生 copyWith、&lt;code>==&lt;/code> / &lt;code>hashCode&lt;/code>、&lt;code>toString()&lt;/code>、以及 sealed union。多欄位複合值需要完整相等性與序列化、又不想手工維護每個欄位的同步時，freezed 是這條路徑的主流選擇；它的 sealed union 在枚舉分層上另有價值——exhaustive switch 讓「忘記決定新成員歸哪類」在編譯期就走不通。&lt;/p>
&lt;p>成本結構有兩面。一面是工具依賴：freezed 走 &lt;code>build_runner&lt;/code> 產生程式碼，專案要接受產生器的建置步驟與產物管理。另一面是預設路徑的一視同仁——freezed 不區分資料袋和有領域方法的 entity，每個被標記的 class 都得到全欄位 public copyWith，包含狀態欄位與稽核欄位。規範說「請走領域方法」、工具預設給全欄位 copyWith，兩者衝突時預設會贏。在 entity 上使用 freezed 需要額外收窄：把 copyWith 改 private、或從參數列移除受約束的欄位。結構細節見 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>值物件在實作層的責任是把一個領域值裝進專用型別、讓型別開放的運算限縮成領域有意義的封閉集合。金額只該加減、乘數量、乘倍率；識別碼只該比對與傳遞；日期範圍只該判包含與交疊。底層的通用型別（數字、字串）開放的運算遠多於這個集合，差集裡的每個運算都是一個等著被誤用的 API——把值物件建起來，就是把差集從型別上關掉。</p>
<p>「這個領域值該不該升級成值物件」的判定屬於理論層，判準是同一性語意與語意封閉、與語言無關，見 <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/knowledge-cards/value-object/" data-link-title="Value Object" data-link-desc="判斷一個概念該用內容比對還是身份追蹤時使用。value object 的同一性由內容定義——內容相等就是同一個、替換實例對系統沒有影響。">value object</a> 卡。本章接手判定之後的問題：在 Dart 裡，同一個「值物件」有三種實作載體，成本結構與適用情境各異——選哪條由這個值的欄位數、要不要 runtime 身份、以及專案對產生器的容忍度決定。</p>
<h2 id="判斷什麼領域值值得升級">判斷：什麼領域值值得升級</h2>
<p>值得升級的訊號是一個領域概念以裸的通用型別跨模組流通、而它的合法運算明顯少於底層型別。金額用 <code>double</code> 或 <code>Decimal</code>、識別碼用 <code>String</code>、數量用 <code>int</code>——這些型別在模組之間傳遞時，型別系統對「金額乘金額」「識別碼相加」這類無意義運算全部放行，因為它們在底層型別的世界裡都合法。合法運算集合小於底層集合、且裸型別已經跨越模組邊界，包一層的價值就成立。</p>
<p>這裡有兩個常被壓成一個的獨立問題。精度是底層表示的問題——浮點數累加金額會把誤差堆到分位，換一個高精度數字型別就解決。語意是運算邊界的問題——換完精度型別後，「任何人都能對這個值做任意運算」原封不動。解掉第一個問題的當下第二個問題完整存在，而它要等夠多「拿金額亂算」的路徑累積後才顯形。把兩者混為一談的後果是換完 <code>Decimal</code> 就宣告收工、語意缺口留在原地。</p>
<p>反過來，不是每個領域值都值得升級。合法運算集合幾乎等於底層型別的集合時（一個真的就是任意整數的計數器），封閉沒有差集可關、專用型別只是多一層轉換。裸型別從不跨越模組邊界、只在單一函式內部短暫存在時，誤用的窗口太小、升級的維護成本收不回。判準操作化成一句話：盤點這個概念的合法運算清單、跟底層型別的運算集合做差集，差集非空且裸型別在模組間流動，才動手。</p>
<h2 id="實作路徑的成本結構">實作路徑的成本結構</h2>
<p>三種載體對應兩條軸：這個值是單一底層值還是多欄位複合值、以及需不需要 runtime 的型別身份。單一底層值（金額包一個數字、識別碼包一個字串）走 extension type 最省；多欄位複合值（地址、日期範圍、含幣別的金額）需要一個真正的物件裝多個欄位，走 class 路徑，class 又分手寫與 freezed 產生兩種。runtime 身份的需求橫切這兩軸——需要 <code>is Money</code> 在執行期為真、或需要反射看得到型別時，只有 class 路徑滿足。</p>
<p>Dart 3 的 record 是多欄位載體、還自帶結構相等，看起來像多欄位複合值的第四條路徑，但它不入選值物件的載體、原因在型別語意。record 是結構型別而非名目型別：兩個欄位形狀相同的概念（<code>(String, String)</code> 的地址與姓名）在型別系統裡是同一個 record 型別、拿不到 <code>DateRange</code> 這種領域名字，也就換不到「傳錯型別編譯期就擋」的保護。record 沒有建構子、無處掛不變式檢查，也無法限縮 API——任何拿到它的程式碼都能讀所有欄位、組任意新值。值物件要的名目身份、建構期不變式、封閉介面，record 結構上三個都不給。它適合的是函式的匿名多回傳與臨時分組，不是需要領域約束的值物件。</p>
<h3 id="手寫-immutable-class-與-copywith">手寫 immutable class 與 copyWith</h3>
<p>手寫 immutable class 是最直接的載體：所有欄位 <code>final</code>、建構子帶不變式檢查、要「改」就造一個新實例。多欄位複合值在這條路徑上用 <a href="/blog/flutter/knowledge-cards/copywith/" data-link-title="copyWith" data-link-desc="物件的逐欄位覆寫方法在什麼時候是正確工具、什麼時候是逃生口時使用。copyWith 對資料袋語意清晰、對有領域方法的 entity 是繞過不變式的逃生口。">copyWith</a> 做逐欄位覆寫——傳要改的欄位、其餘保留原值、回傳新實例，這對欄位組合全部合法的值語意清晰。成本在 boilerplate：<code>==</code> 與 <code>hashCode</code> 要手寫且要涵蓋所有參與相等性的欄位、<code>copyWith</code> 每加一個欄位就要同步一行，漏一個欄位的相等性比對是安靜的 bug。</p>
<p>這條路徑的邊界在 copyWith 的適用範圍。對欄位組合全部合法的資料袋與純值物件，copyWith 是正確工具；對有領域方法、欄位之間有不變式約束的型別，全欄位 public 的 copyWith 是繞過領域方法的逃生口——領域方法從「唯一變更路徑」降級成「建議路徑」。判準是型別有沒有「不允許任意組合的欄位」，有的話那些欄位就不該讓 copyWith public 可寫。完整機制見 <a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>。</p>
<h3 id="freezed-產生器">freezed 產生器</h3>
<p><a href="/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed</a> 是把手寫 class 的 boilerplate 壓到接近零的產生器：標記 <code>@freezed</code> 後自動產生 copyWith、<code>==</code> / <code>hashCode</code>、<code>toString()</code>、以及 sealed union。多欄位複合值需要完整相等性與序列化、又不想手工維護每個欄位的同步時，freezed 是這條路徑的主流選擇；它的 sealed union 在枚舉分層上另有價值——exhaustive switch 讓「忘記決定新成員歸哪類」在編譯期就走不通。</p>
<p>成本結構有兩面。一面是工具依賴：freezed 走 <code>build_runner</code> 產生程式碼，專案要接受產生器的建置步驟與產物管理。另一面是預設路徑的一視同仁——freezed 不區分資料袋和有領域方法的 entity，每個被標記的 class 都得到全欄位 public copyWith，包含狀態欄位與稽核欄位。規範說「請走領域方法」、工具預設給全欄位 copyWith，兩者衝突時預設會贏。在 entity 上使用 freezed 需要額外收窄：把 copyWith 改 private、或從參數列移除受約束的欄位。結構細節見 <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a>。</p>
<h3 id="extension-type-零成本包裝">extension type 零成本包裝</h3>
<p><a href="/blog/flutter/knowledge-cards/extension-type/" data-link-title="Extension Type" data-link-desc="Dart 3 的零成本包裝型別——runtime 不存在額外物件、只在編譯期提供型別安全。用在 value object 的語意封閉需要零 overhead 時。">extension type</a> 是 Dart 3.3 起提供的載體，把單一底層值包成一個新名字、限縮可用的 API、而 runtime 不存在額外物件——編譯後就是底層型別本身，所有約束活在編譯期。單一底層值的語意封閉、又在高頻路徑上流通（金額在每筆訂單明細累加、識別碼在每次查詢傳遞）時，這條路徑用零 runtime 開銷換到型別安全。它跟 class 路徑是互斥的實作選擇：class 走 runtime、有型別身份也有 overhead；extension type 走編譯期、零 overhead 但 runtime 透明，<code>is</code> 與 <code>as</code> 看到的是底層型別。</p>
<p>用 extension type 時要在設計期定 subtype 決策——它是底層型別的 subtype（寬鬆：可隱式 upcast 回底層、封裝邊界弱）還是獨立型別（嚴格：只能顯式拆封、每個銜接點都要拆）。金額的做法是 <code>implements Object</code> 而只有 Object：既有的格式化入口 <code>formatAmount(Object)</code> 能直接吃它、不必改簽名；同時它不是數字型別的 subtype，於是不能被傳進任何收數字型別的參數，裸運算沒有回來的路。這條路徑不搭 copyWith——extension type 沒有 runtime 物件可以逐欄位覆寫，逐欄位覆寫語意只在 class 路徑上成立。</p>
<p>三條路徑的選型錨點收在下表，每一列的成本結構與適用情境在上面各自的段落展開：</p>
<table>
  <thead>
      <tr>
          <th>載體</th>
          <th>適用的值形狀</th>
          <th>成本結構</th>
          <th>runtime 身份</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>手寫 immutable class</td>
          <td>多欄位複合值</td>
          <td>手寫 <code>==</code> / <code>hashCode</code> / copyWith</td>
          <td>有</td>
      </tr>
      <tr>
          <td>freezed</td>
          <td>多欄位複合值、要完整相等</td>
          <td>build_runner 依賴、預設全欄位 copyWith</td>
          <td>有</td>
      </tr>
      <tr>
          <td>extension type</td>
          <td>單一底層值、高頻流通</td>
          <td>零 runtime 開銷、runtime 透明</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<h2 id="從原始型別遷移過去">從原始型別遷移過去</h2>
<p>值物件多半在裸型別的誤用累積之後才補上去，於是實作值物件常常等於一次型別遷移。一個金額欄位的遷移軌跡把兩個獨立問題各解一段：最初是浮點數、累加誤差堆到分位；第一段換成高精度數字型別、精度問題解決；金額仍然是裸的通用數字、任何拿到它的程式碼都能做任意運算，於是第二段把它包進 extension type，開放的運算限於領域有意義的集合。運算列表本身就是領域規則的宣告：金額加減可以、乘數量可以、乘倍率可以（刻意跟數量分開簽名），金額乘金額不存在、因為介面沒開放。想對它做底層型別的任意運算，得先顯式呼叫拆封方法，那一行拆封程式碼就是 code review 的攔截點。這條軌跡的完整素材見 <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>。</p>
<p>遷移動的是全專案的欄位，安全網是 characterization test——遷移前對著舊實作寫、鎖住當前輸出（包含當前的邊界行為，例如找零算出負數時歸零），遷移後全綠就證明型別替換沒有帶入行為變化。它跟一般測試的差別在斷言的性質：它驗證「行為不變」、不驗證「行為正確」。正確性是另一批測試的事——把兩個問題混在同一批測試裡，遷移期間的紅燈就分不清是「換壞了」還是「本來就錯」。做法展開見 <a href="/blog/work-log/flutter_characterization_test_migration_safety_net/" data-link-title="測「不變」、不測「正確」 — characterization test 當遷移安全網" data-link-desc="大規模型別遷移前，對著舊實作寫一批鎖住現有行為的測試——包括看起來像 bug 的邊界怪癖也照鎖，遷移後全綠證明「換底沒改行為」。正確性是另一批測試的職責、混在一起紅燈就無法歸因。附測試環境的原生依賴三個斷點（FFI、plugin、late init）與替身解法。">測「不變」、不測「正確」：characterization test</a>。</p>
<h2 id="取值出口封裝的對象是運算不是取值">取值出口：封裝的對象是運算、不是取值</h2>
<p>值物件封住的是「任意運算」、不是「取原始值」本身。基礎設施邊界對原始值有正當需求——快取需要 key、資料庫需要 column 值、序列化需要原始表示、格式化銜接層需要底層型別。正確做法是給原始值一個語意明確的官方出口，而不是禁止取值逼下游硬撬。金額的拆封方法 <code>toDecimal()</code> 就是這種出口，註解直接寫明供哪種場合使用；序列化給 <code>toDbValue()</code> 這類名字說明用途的方法；UI 顯示給 <code>displayValue</code>。有官方出口的世界裡「誰在拆封」是可 grep 的（搜出口方法名就是完整清單），封裝邊界是明示的。</p>
<p>把取值本身當違規會來回撞牆。一個 App 的識別碼型別的封裝政策擺盪過兩輪：先追「零個外部取值」把公開介面只留 <code>toString()</code>，撞上基礎設施層的正當需求後又把取值 getter 加回來、改名叫「相容性介面」。兩個極端各自的撞牆點是同一個病的兩面——完全封裝逼正當消費把 <code>toString()</code> 當取值 API 用（語意寄生，<code>toString()</code> 哪天為除錯改格式、快取 key 就靜默換一批），零封裝則讓 ISBN 校驗、ID 格式這些不變式失去強制點。穩態在中間：原始值有官方出口、出口有語意、邊界寫進決策記錄。擺盪的完整軌跡見 <a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">Value Object 的封裝擺盪</a>，出口設計的理論層展開見 <a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理值物件在 Dart 的實作載體選擇，是實作層知識。三個上游判定不在本章：一個型別該不該模型化成領域模型（入口判準見 <a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>）、模型化之後該用 entity 還是 value object（同一性判準見 <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/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）。值物件把封閉做在型別層，防的是無心誤用——刻意用反射或 dynamic 拆封仍然繞得過，威脅模型是「防止意外」而不是「防止刻意」。</p>
<p>型別層防護的另一半責任落在 entity 而非 value object：entity 的同一性由身份定義、有生命週期、變更要走領域方法，那條路徑的收窄（copyWith 逃生口、稽核軌跡凍結）跟本章的值物件封閉是相鄰但不同的問題，路由到 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>。</p>
<h2 id="下一步">下一步</h2>
<p>三條實作路徑各有 case 可深讀：copyWith 的適用邊界在 <a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>、freezed 的結構在 <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a>、extension type 的 subtype 決策與遷移在 <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>。取值出口的封裝邊界在 <a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">Value Object 的封裝擺盪</a>、遷移安全網在 <a href="/blog/work-log/flutter_characterization_test_migration_safety_net/" data-link-title="測「不變」、不測「正確」 — characterization test 當遷移安全網" data-link-desc="大規模型別遷移前，對著舊實作寫一批鎖住現有行為的測試——包括看起來像 bug 的邊界怪癖也照鎖，遷移後全綠證明「換底沒改行為」。正確性是另一批測試的職責、混在一起紅燈就無法歸因。附測試環境的原生依賴三個斷點（FFI、plugin、late init）與替身解法。">characterization test</a>。理論地基從 <a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 指南的模型設計主梯</a> 進，值物件在其中的位置是 <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> 的語意封閉段。</p>
]]></content:encoded></item><item><title>copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞</title><link>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：修一個效能基準測試的 &lt;code>InvalidBookIdException&lt;/code>，追根因時發現它是同族語意錯誤的第二起
&lt;strong>疑問來源&lt;/strong>：「copyWith 是方便的做法，但通常不是最好的設計」——這個直覺是否成立？
&lt;strong>整理目的&lt;/strong>：把「copyWith 什麼時候是對的工具、什麼時候是逃生口」的判斷邊界記下來，連同這個專案實際踩的三個坑
&lt;strong>本文邊界&lt;/strong>：這是一篇 work-log，回溯一次具體專案的設計檢視；它不主張消滅 copyWith——結論恰恰相反，問題從來不在 copyWith 本身&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事件起點一個-3-字元的-id">事件起點：一個 3 字元的 ID&lt;/h2>
&lt;p>書籍管理 App 專案的效能基準測試炸了一個例外：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">InvalidBookIdException: Book ID must be at least 5 characters long&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>炸點在測試的 Arrange 段：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">book&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createForTest&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nl">id:&lt;/span> &lt;span class="s1">&amp;#39;perf-bm-001-&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="n">padLeft&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;0&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nl">title:&lt;/span> &lt;span class="s1">&amp;#39;效能基準測試書籍 &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nl">author:&lt;/span> &lt;span class="s1">&amp;#39;作者 &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">).&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">bookTags:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createForTest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">id:&lt;/span> &lt;span class="s1">&amp;#39;tmp&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">bookTags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// &amp;lt;- 這行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">BookTag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">primary&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">categoryId:&lt;/span> &lt;span class="n">TagCategoryIds&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">custom&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">value:&lt;/span> &lt;span class="s1">&amp;#39;科幻&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>'tmp'&lt;/code> 只有 3 個字元，&lt;code>BookId&lt;/code> 的 value object 要求至少 5 個，於是炸了。&lt;/p>
&lt;p>最小修法顯而易見：把 &lt;code>'tmp'&lt;/code> 改成 &lt;code>'tmp-12345'&lt;/code>。但這個修法是錯的。&lt;/p>
&lt;h2 id="長度是症狀語意才是病">長度是症狀，語意才是病&lt;/h2>
&lt;p>看那行的意圖：它想要「保留 &lt;code>createForTest&lt;/code> 產生的預設 bookTags，再追加三個自訂 tag」。但它取預設值的方式，是&lt;strong>建一個立刻丟棄的物件，只為了拿它的一個欄位&lt;/strong>。&lt;/p>
&lt;p>正確的寫法是取自己的：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">baseBook&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createForTest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">id:&lt;/span> &lt;span class="s1">&amp;#39;perf-bm-001-...&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">book&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">baseBook&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">bookTags:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">baseBook&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">bookTags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 取自己的預設值
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">BookTag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">primary&lt;/span>&lt;span class="p">(...),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把 &lt;code>'tmp'&lt;/code> 改長只會讓例外消失，語意錯誤原封不動地留著。而且這不是孤例——同一個專案的測試資料產生器幾天前才修過一模一樣的寫法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修復前：用一個全新預設書籍的 bookTags，
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 丟棄呼叫端指定的 author / isbn
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">bookTags:&lt;/span> &lt;span class="p">[...&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createForTest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">id:&lt;/span> &lt;span class="n">bookId&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">bookTags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">...])&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一種錯誤在兩個檔案各出現一次。這時候該問的就不是「怎麼修」，而是「&lt;strong>為什麼這種寫法會自然長出來&lt;/strong>」。&lt;/p>
&lt;h2 id="追問copywith-通常不是最好的設計">追問：copyWith 通常不是最好的設計？&lt;/h2>
&lt;p>這個直覺方向是對的，但不加限定會誤傷。精確的說法是：&lt;/p>
&lt;h3 id="問題不在-copywith在於-public-copywith-掛在-entity-上">問題不在 copyWith，在於 public copyWith 掛在 entity 上&lt;/h3>
&lt;p>copyWith 對純資料載體是正確工具——DTO、API model、UI state、小的 value object。這些東西沒有領域不變式，它們就是一袋欄位，逐欄位覆寫語意清晰、沒有代價。Dart 生態也是這樣用它的：freezed 幫每個 model 自動生成 copyWith，這在 data class 的世界完全合理。&lt;/p>
&lt;p>但這個專案的 &lt;code>Book&lt;/code> 不是一袋欄位。它是 entity，帶著一組&lt;strong>有意圖的狀態轉換方法&lt;/strong>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">Book&lt;/span> &lt;span class="n">startEnrichment&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1">// 開始豐富化
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">completeEnrichment&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1">// 完成豐富化
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">markAsAvailable&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1">// 標記可用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">setImportanceLevel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">level&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個方法都會往 &lt;code>modificationHistory&lt;/code> 追加一筆稽核紀錄——這是領域模型的核心價值：狀態怎麼變的，有跡可循。&lt;/p>
&lt;p>然後，&lt;code>Book&lt;/code> 同時有一個 public 的、18 個參數的 &lt;code>copyWith&lt;/code>，而且參數列裡&lt;strong>包含 &lt;code>status&lt;/code> 和 &lt;code>modificationHistory&lt;/code>&lt;/strong>。&lt;/p>
&lt;h2 id="實證一領域方法被繞過稽核軌跡有洞">實證一：領域方法被繞過，稽核軌跡有洞&lt;/h2>
&lt;p>有了 public copyWith，領域方法就從「唯一路徑」降級成「建議路徑」。grep 一下就找到繞過的實例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 書籍工廠層
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">status:&lt;/span> &lt;span class="n">BookStatus&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">available&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">setReadingStatus&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">readingStatus&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">status:&lt;/span> &lt;span class="n">BookStatus&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">enriched&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這兩處直接改 &lt;code>status&lt;/code>，繞過了 &lt;code>markAsAvailable()&lt;/code> 和 &lt;code>completeEnrichment()&lt;/code>。後果：這些狀態轉換&lt;strong>沒有進入 modificationHistory&lt;/strong>。稽核軌跡有洞，而且是靜默的——沒有任何錯誤、警告或測試失敗會告訴你。&lt;/p>
&lt;h2 id="實證二註解宣稱的約束從未被強制">實證二：註解宣稱的約束，從未被強制&lt;/h2>
&lt;p>&lt;code>completeEnrichment()&lt;/code> 的文件註解寫著：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 約束：只能從enriching狀態轉換，確保狀態流程正確
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">completeEnrichment&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">newHistory&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_modificationHistory&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">addChange&lt;/span>&lt;span class="p">(...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">status:&lt;/span> &lt;span class="n">BookStatus&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">enriched&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">modificationHistory:&lt;/span> &lt;span class="n">newHistory&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實作裡沒有任何 &lt;code>if&lt;/code>、&lt;code>assert&lt;/code> 或 &lt;code>throw&lt;/code>。grep 計數是零。&lt;/p>
&lt;p>這比「沒有約束」更糟——註解讓讀者&lt;strong>以為&lt;/strong>有防護。而且就算方法內部加了檢查，&lt;code>copyWith(status: ...)&lt;/code> 還是繞得過去。約束要成立，逃生口就得先關上。&lt;/p>
&lt;h2 id="實證三測試作者自己也分不清兩條路徑">實證三：測試作者自己也分不清兩條路徑&lt;/h2>
&lt;p>同專案更早的測試修復記錄裡有一筆直接的證言。一個測試用 &lt;code>book.copyWith(readingStatus: ReadingStatus.reading)&lt;/code> 改狀態、然後期待 &lt;code>modificationHistory&lt;/code> 出現兩條變更紀錄——實際只有一條、測試失敗。修法是改呼叫業務方法 &lt;code>setReadingStatus()&lt;/code>、期待一條紀錄。&lt;/p>
&lt;p>這個失敗的測試值得記，因為它證明混淆不是理論風險：&lt;strong>連寫測試的人都把 copyWith 當成了業務入口&lt;/strong>、以為它會留稽核痕跡。兩條路徑（工具方法不記錄、業務方法記錄）並存在同一個 public 介面上，每個使用者都要自己記得哪條是哪條——而「要記得」的規則遲早有人忘。&lt;/p>
&lt;h2 id="逃生口機制為什麼那種寫法會自然長出來">逃生口機制：為什麼那種寫法會自然長出來&lt;/h2>
&lt;p>回到最初的測試 bug。為什麼有人會寫 &lt;code>Book.createForTest(id: 'tmp').bookTags&lt;/code>？&lt;/p>
&lt;p>因為 &lt;code>Book.createForTest&lt;/code> 只接受 &lt;code>id&lt;/code> / &lt;code>title&lt;/code> / &lt;code>author&lt;/code> / &lt;code>isbn&lt;/code> 四個參數，&lt;strong>不接受 &lt;code>bookTags&lt;/code>&lt;/strong>。測試想表達「預設 tags 再加三個自訂 tag」，工廠給不了這個表達力，於是 copyWith 成了唯一的出路——而在用 copyWith 拼裝的當下，順手建個臨時物件撈預設值，就是最短路徑。&lt;/p>
&lt;p>這就是 copyWith 作為逃生口的危險之處：&lt;strong>它總是有辦法讓你把物件拼出來，所以你永遠不會被迫去修那個表達力不足的工廠。&lt;/strong> 建構路徑的缺陷被逃生口吸收掉，然後以語意錯誤的形式在別處復發——這個專案復發了兩次。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：修一個效能基準測試的 <code>InvalidBookIdException</code>，追根因時發現它是同族語意錯誤的第二起
<strong>疑問來源</strong>：「copyWith 是方便的做法，但通常不是最好的設計」——這個直覺是否成立？
<strong>整理目的</strong>：把「copyWith 什麼時候是對的工具、什麼時候是逃生口」的判斷邊界記下來，連同這個專案實際踩的三個坑
<strong>本文邊界</strong>：這是一篇 work-log，回溯一次具體專案的設計檢視；它不主張消滅 copyWith——結論恰恰相反，問題從來不在 copyWith 本身</p></blockquote>
<hr>
<h2 id="事件起點一個-3-字元的-id">事件起點：一個 3 字元的 ID</h2>
<p>書籍管理 App 專案的效能基準測試炸了一個例外：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">InvalidBookIdException: Book ID must be at least 5 characters long</span></span></code></pre></div><p>炸點在測試的 Arrange 段：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">book</span> <span class="o">=</span> <span class="n">Book</span><span class="p">.</span><span class="n">createForTest</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">id:</span> <span class="s1">&#39;perf-bm-001-</span><span class="si">${</span><span class="n">i</span><span class="p">.</span><span class="n">toString</span><span class="p">().</span><span class="n">padLeft</span><span class="p">(</span><span class="m">4</span><span class="p">,</span> <span class="s1">&#39;0&#39;</span><span class="p">)</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nl">title:</span> <span class="s1">&#39;效能基準測試書籍 </span><span class="si">$</span><span class="n">i</span><span class="s1">&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nl">author:</span> <span class="s1">&#39;作者 </span><span class="si">$</span><span class="n">i</span><span class="s1">&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">).</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">bookTags:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">...</span><span class="n">Book</span><span class="p">.</span><span class="n">createForTest</span><span class="p">(</span><span class="nl">id:</span> <span class="s1">&#39;tmp&#39;</span><span class="p">).</span><span class="n">bookTags</span><span class="p">,</span>   <span class="c1">// &lt;- 這行
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="n">BookTag</span><span class="p">.</span><span class="n">primary</span><span class="p">(</span><span class="nl">categoryId:</span> <span class="n">TagCategoryIds</span><span class="p">.</span><span class="n">custom</span><span class="p">,</span> <span class="nl">value:</span> <span class="s1">&#39;科幻&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1"></span><span class="p">]);</span></span></span></code></pre></div><p><code>'tmp'</code> 只有 3 個字元，<code>BookId</code> 的 value object 要求至少 5 個，於是炸了。</p>
<p>最小修法顯而易見：把 <code>'tmp'</code> 改成 <code>'tmp-12345'</code>。但這個修法是錯的。</p>
<h2 id="長度是症狀語意才是病">長度是症狀，語意才是病</h2>
<p>看那行的意圖：它想要「保留 <code>createForTest</code> 產生的預設 bookTags，再追加三個自訂 tag」。但它取預設值的方式，是<strong>建一個立刻丟棄的物件，只為了拿它的一個欄位</strong>。</p>
<p>正確的寫法是取自己的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">baseBook</span> <span class="o">=</span> <span class="n">Book</span><span class="p">.</span><span class="n">createForTest</span><span class="p">(</span><span class="nl">id:</span> <span class="s1">&#39;perf-bm-001-...&#39;</span><span class="p">,</span> <span class="p">...);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">final</span> <span class="n">book</span> <span class="o">=</span> <span class="n">baseBook</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">bookTags:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">...</span><span class="n">baseBook</span><span class="p">.</span><span class="n">bookTags</span><span class="p">,</span>   <span class="c1">// 取自己的預設值
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="n">BookTag</span><span class="p">.</span><span class="n">primary</span><span class="p">(...),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>把 <code>'tmp'</code> 改長只會讓例外消失，語意錯誤原封不動地留著。而且這不是孤例——同一個專案的測試資料產生器幾天前才修過一模一樣的寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 修復前：用一個全新預設書籍的 bookTags，
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 丟棄呼叫端指定的 author / isbn
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">copyWith</span><span class="p">(</span><span class="nl">bookTags:</span> <span class="p">[...</span><span class="n">Book</span><span class="p">.</span><span class="n">createForTest</span><span class="p">(</span><span class="nl">id:</span> <span class="n">bookId</span><span class="p">).</span><span class="n">bookTags</span><span class="p">,</span> <span class="p">...])</span></span></span></code></pre></div><p>同一種錯誤在兩個檔案各出現一次。這時候該問的就不是「怎麼修」，而是「<strong>為什麼這種寫法會自然長出來</strong>」。</p>
<h2 id="追問copywith-通常不是最好的設計">追問：copyWith 通常不是最好的設計？</h2>
<p>這個直覺方向是對的，但不加限定會誤傷。精確的說法是：</p>
<h3 id="問題不在-copywith在於-public-copywith-掛在-entity-上">問題不在 copyWith，在於 public copyWith 掛在 entity 上</h3>
<p>copyWith 對純資料載體是正確工具——DTO、API model、UI state、小的 value object。這些東西沒有領域不變式，它們就是一袋欄位，逐欄位覆寫語意清晰、沒有代價。Dart 生態也是這樣用它的：freezed 幫每個 model 自動生成 copyWith，這在 data class 的世界完全合理。</p>
<p>但這個專案的 <code>Book</code> 不是一袋欄位。它是 entity，帶著一組<strong>有意圖的狀態轉換方法</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">Book</span> <span class="n">startEnrichment</span><span class="p">()</span>     <span class="c1">// 開始豐富化
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">Book</span> <span class="n">completeEnrichment</span><span class="p">()</span>  <span class="c1">// 完成豐富化
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">Book</span> <span class="n">markAsAvailable</span><span class="p">()</span>     <span class="c1">// 標記可用
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="n">Book</span> <span class="n">setImportanceLevel</span><span class="p">(</span><span class="kt">int</span> <span class="n">level</span><span class="p">)</span></span></span></code></pre></div><p>每個方法都會往 <code>modificationHistory</code> 追加一筆稽核紀錄——這是領域模型的核心價值：狀態怎麼變的，有跡可循。</p>
<p>然後，<code>Book</code> 同時有一個 public 的、18 個參數的 <code>copyWith</code>，而且參數列裡<strong>包含 <code>status</code> 和 <code>modificationHistory</code></strong>。</p>
<h2 id="實證一領域方法被繞過稽核軌跡有洞">實證一：領域方法被繞過，稽核軌跡有洞</h2>
<p>有了 public copyWith，領域方法就從「唯一路徑」降級成「建議路徑」。grep 一下就找到繞過的實例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 書籍工廠層
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">).</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">status:</span> <span class="n">BookStatus</span><span class="p">.</span><span class="n">available</span><span class="p">).</span><span class="n">setReadingStatus</span><span class="p">(</span><span class="n">readingStatus</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// ...
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">).</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">status:</span> <span class="n">BookStatus</span><span class="p">.</span><span class="n">enriched</span><span class="p">);</span></span></span></code></pre></div><p>這兩處直接改 <code>status</code>，繞過了 <code>markAsAvailable()</code> 和 <code>completeEnrichment()</code>。後果：這些狀態轉換<strong>沒有進入 modificationHistory</strong>。稽核軌跡有洞，而且是靜默的——沒有任何錯誤、警告或測試失敗會告訴你。</p>
<h2 id="實證二註解宣稱的約束從未被強制">實證二：註解宣稱的約束，從未被強制</h2>
<p><code>completeEnrichment()</code> 的文件註解寫著：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 約束：只能從enriching狀態轉換，確保狀態流程正確
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">Book</span> <span class="n">completeEnrichment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">final</span> <span class="n">newHistory</span> <span class="o">=</span> <span class="n">_modificationHistory</span><span class="p">.</span><span class="n">addChange</span><span class="p">(...);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="n">copyWith</span><span class="p">(</span><span class="nl">status:</span> <span class="n">BookStatus</span><span class="p">.</span><span class="n">enriched</span><span class="p">,</span> <span class="nl">modificationHistory:</span> <span class="n">newHistory</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>實作裡沒有任何 <code>if</code>、<code>assert</code> 或 <code>throw</code>。grep 計數是零。</p>
<p>這比「沒有約束」更糟——註解讓讀者<strong>以為</strong>有防護。而且就算方法內部加了檢查，<code>copyWith(status: ...)</code> 還是繞得過去。約束要成立，逃生口就得先關上。</p>
<h2 id="實證三測試作者自己也分不清兩條路徑">實證三：測試作者自己也分不清兩條路徑</h2>
<p>同專案更早的測試修復記錄裡有一筆直接的證言。一個測試用 <code>book.copyWith(readingStatus: ReadingStatus.reading)</code> 改狀態、然後期待 <code>modificationHistory</code> 出現兩條變更紀錄——實際只有一條、測試失敗。修法是改呼叫業務方法 <code>setReadingStatus()</code>、期待一條紀錄。</p>
<p>這個失敗的測試值得記，因為它證明混淆不是理論風險：<strong>連寫測試的人都把 copyWith 當成了業務入口</strong>、以為它會留稽核痕跡。兩條路徑（工具方法不記錄、業務方法記錄）並存在同一個 public 介面上，每個使用者都要自己記得哪條是哪條——而「要記得」的規則遲早有人忘。</p>
<h2 id="逃生口機制為什麼那種寫法會自然長出來">逃生口機制：為什麼那種寫法會自然長出來</h2>
<p>回到最初的測試 bug。為什麼有人會寫 <code>Book.createForTest(id: 'tmp').bookTags</code>？</p>
<p>因為 <code>Book.createForTest</code> 只接受 <code>id</code> / <code>title</code> / <code>author</code> / <code>isbn</code> 四個參數，<strong>不接受 <code>bookTags</code></strong>。測試想表達「預設 tags 再加三個自訂 tag」，工廠給不了這個表達力，於是 copyWith 成了唯一的出路——而在用 copyWith 拼裝的當下，順手建個臨時物件撈預設值，就是最短路徑。</p>
<p>這就是 copyWith 作為逃生口的危險之處：<strong>它總是有辦法讓你把物件拼出來，所以你永遠不會被迫去修那個表達力不足的工廠。</strong> 建構路徑的缺陷被逃生口吸收掉，然後以語意錯誤的形式在別處復發——這個專案復發了兩次。</p>
<h2 id="生態推力預設路徑塑造習慣">生態推力：預設路徑塑造習慣</h2>
<p>還有一層值得說：Dart 生態在推你往 copyWith 走。freezed 自動生成它、教學範例到處用它、IDE 補全第一個跳出來的就是它。它是<strong>預設路徑</strong>。</p>
<p>這和之前寫過的〈工具的預設行為決定使用者習慣〉是同一件事：規範說「狀態轉換請走領域方法」，工具預設給你一個全欄位的 copyWith——<strong>規範和預設打架時，預設會贏</strong>。差別只在這次預設值站在錯的一邊。</p>
<h2 id="修法方向分層收窄不是消滅">修法方向：分層收窄，不是消滅</h2>
<p>這個專案的 copyWith 呼叫點：<code>lib/</code> 301 處、<code>test/</code> 115 處。消滅它不現實，也不正確——大部分呼叫點在 value object 和 UI state 上，那裡它是對的工具。</p>
<p>收窄的方向分三層：</p>
<table>
  <thead>
      <tr>
          <th>對象</th>
          <th>處置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>value object / DTO / UI state</td>
          <td>保留 copyWith，這裡它是正確工具</td>
      </tr>
      <tr>
          <td>有領域方法的 entity（如 <code>Book</code>）</td>
          <td>copyWith 改 private 僅供領域方法內部使用；或至少從參數列移除 <code>status</code>、<code>modificationHistory</code> 這類「必須經由領域方法變更」的欄位</td>
      </tr>
      <tr>
          <td>測試建構</td>
          <td>讓 <code>createForTest</code> 接受 <code>bookTags</code>，消除用 copyWith 拼裝的動機——修工廠的表達力，不是修每一個拼裝點</td>
      </tr>
  </tbody>
</table>
<p>判斷準則濃縮成一句：<strong>這個型別有沒有「不允許任意組合的欄位」？</strong> 有，copyWith 就不該讓那些欄位 public 可寫；沒有，copyWith 就是正當的便利工具。</p>
<h2 id="附註即使在正當場景copywith-也有一個表達力缺口">附註：即使在正當場景、copyWith 也有一個表達力缺口</h2>
<p>value object 跟 UI state 上的 copyWith 是正確工具，但手寫時有一個 Dart 型別系統的缺口：<code>String? isbn</code> 這種 nullable 參數只有兩態（有值 / null），而 copyWith 的語意需要三態——「不改這欄」「改成某值」「清空成 null」。前兩態沒問題，第三態表達不出來：<code>copyWith(isbn: null)</code> 跟「沒傳 isbn」在函式內看起來一模一樣。</p>
<p>通用的補償手法是哨兵物件，兩個不同專案各自長出了同一份程式碼：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">static</span> <span class="kd">const</span> <span class="n">_sentinel</span> <span class="o">=</span> <span class="kt">Object</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">State</span> <span class="n">copyWith</span><span class="p">({</span><span class="kt">Object</span><span class="o">?</span> <span class="n">member</span> <span class="o">=</span> <span class="n">_sentinel</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="n">State</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nl">member:</span> <span class="n">member</span> <span class="o">==</span> <span class="n">_sentinel</span> <span class="o">?</span> <span class="k">this</span><span class="p">.</span><span class="n">member</span> <span class="o">:</span> <span class="n">member</span> <span class="o">as</span> <span class="n">Member</span><span class="o">?</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>有了哨兵，「登出會員（明確設 null）」這類操作才能經由 copyWith 表達。freezed 生成的 copyWith 內部就是用同樣的哨兵技巧處理這件事——手寫 immutable state 時這是要自己補的部分，漏掉的症狀是「清空欄位的操作靜默變成保留原值」。</p>
<h2 id="收束三個坑的共同結構">收束：三個坑的共同結構</h2>
<p>這次追出來的三個坑——語意錯誤的測試拼裝、被繞過的領域方法、從未強制的註解約束——共同結構是同一個：</p>
<h3 id="設計意圖只寫在文件層沒有落在型別層或執行層">設計意圖只寫在文件層，沒有落在型別層或執行層</h3>
<p>「請走領域方法」是慣例，copyWith 不擋你；「只能從 enriching 轉換」是註解，實作不查你；「測試該用工廠」是期望，工廠沒能力你就繞。每一個「請、應該、建議」都是一個沒關上的逃生口，而逃生口的使用者不是壞人——他們只是走了阻力最小的路。</p>
<p>要讓意圖成立，就得讓違反意圖的路徑<strong>走不通</strong>，而不是寫文件請大家不要走。</p>
<p>這次追出來的兩個可重用原則各自抽成 report 卡：意圖的強制層次在 <a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>、缺陷的轉移機制在 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>。概念地基在 DDD 模組：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>（copyWith 該不該掛的判準入口）、<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>（文件層失效機制的教學層展開）、<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>（變更路徑收斂與稽核出洞機制）、<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>（工廠表達力不足的缺陷轉移）。</p>
]]></content:encoded></item></channel></rss>