<?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>Value-Object on Tarragon</title><link>https://tarrragon.github.io/blog/tags/value-object/</link><description>Recent content in Value-Object 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/value-object/index.xml" rel="self" type="application/rss+xml"/><item><title>entity 與 value object 的判準</title><link>https://tarrragon.github.io/blog/ddd/entity-vs-value-object/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/entity-vs-value-object/</guid><description>&lt;p>型別判定為領域模型之後（判定方式見 &lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a>），下一個決策是身份語意：這個概念的「同一個」由什麼定義。身份語意決定業務規則作用在什麼對象上——判錯的後果直接撞上模組的源頭句「把業務規則放進領域模型、讓違反規則的路徑走不通」：規則以為自己守住了「那一筆」，實際作用在「內容相同的隨便一筆」上，違反規則的路徑照樣走得通。&lt;/p>
&lt;h2 id="同一性是兩者的分界">同一性是兩者的分界&lt;/h2>
&lt;p>entity 的同一性由身份定義：欄位可以全部改變、只要身份參照不變就是同一個；兩個欄位完全相同的 entity 仍然是兩個。value object 的同一性由內容定義：內容相等就是同一個，替換一個內容相同的實例對系統沒有任何影響。這條分界推導出兩者相反的設計形狀——entity 有生命週期、狀態沿業務流程演進、變更要有路徑；value object 不可變、要「改」就是造一個新值換上去。&lt;/p>
&lt;p>相等性定義本身可以承載業務規則。一個 POS 專案的購物車品項用內容比對判定同一項、而折扣參與比對——手動改過價的品項被視為獨立的訂單行，合併購物車時只有規格、折扣、口味全部相同的品項才累加數量。「什麼算同一個」在這裡是業務決策寫進相等性定義的例子，而這正是 value object 的表達力所在：同一性規則集中在一個定義裡、所有比對點共用。&lt;/p>
&lt;h2 id="判準操作需不需要-identity-based-定位">判準：操作需不需要 identity-based 定位&lt;/h2>
&lt;p>判準是對這個物件的操作、需不需要精確指到某一個實體——概念重不重要、有沒有 id 欄位可以填，都不參與判斷。上述 POS 專案把這條判準踩出完整的階段軌跡：點餐階段的品項操作是「加一份」「換口味」，內容相等就是同一個、value object 的內容比對足夠；品項被掛單系統接受後獲得後端身份，操作變成「取消那一筆」「改那一筆的量」——同商品同口味的三筆明細內容完全相同，取消其中一筆時內容比對無法指定是哪一筆，此刻模型必須升級成持有身份參照的形態（&lt;a href="https://tarrragon.github.io/blog/work-log/dart_pos_item_four_lifecycle_models/" data-link-title="同一個品項、四個 model — value object 什麼時候該升級成 entity" data-link-desc="同一個業務概念要不要拆成多個 model、value object 什麼時候該升級成 entity——判準是操作需不需要 identity-based 回寫。以 POS 品項從點選、掛單、結算到歷史訂單的四階段模型為例，含 snapshot 與 live reference 的凍結時機。">同一個品項、四個 model&lt;/a>）。&lt;/p>
&lt;p>操作形態對應三種模型選擇：&lt;/p>
&lt;ul>
&lt;li>操作以內容為對象（累加、合併、比對、替換）——value object，內容相等性就是全部所需。&lt;/li>
&lt;li>操作要指到特定實體（取消那一筆、改那一筆的回寫，或讀取側的關聯、去重、生命週期追蹤）——entity，或至少是持有身份參照的包裝。&lt;/li>
&lt;li>操作只剩查閱與退貨這類對既成事實的處置——live 內容參照凍結成 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a>、身份保留作退貨與取消的鍵，見下一節。&lt;/li>
&lt;/ul>
&lt;p>判準的常見誤用是拿概念重要性代替操作分析：「訂單很重要所以是 entity」推不出正確結論，訂單行在輸入階段就是純內容比對；反方向「它有 id 欄位所以是 entity」同樣失效，id 可以只是序列化需要的欄位、與同一性判定無關。判準的作用對象是操作清單，操作清單來自業務流程——這也是為什麼身份語意的判定要等操作盤點之後才能做。&lt;/p>
&lt;h2 id="判準隨生命週期重問">判準隨生命週期重問&lt;/h2>
&lt;p>同一個業務概念的身份語意會在生命週期的轉折點改變，每個轉折點都要重問一次判準。上述品項模型的完整軌跡是四個模型接力：純需求描述（無 id、內容比對）、後端實體（後端 id）、訂單行（把多筆後端明細收攏成一行、持有它們的身份集合）、歷史明細（id 加全欄位 snapshot）。每一次交棒都對應身份狀態的真實變化，四個模型是身份語意在三個轉折點上改變的結果、而不是重複建模。&lt;/p>
&lt;p>概念成為歷史事實之後，live 內容參照要凍結、身份繼續承重。歷史訂單明細保存下單當時的商品與價格 snapshot——商品後續改名、下架、調價，訂單仍顯示當時購買的內容；身份參照在這個階段轉而承擔退貨與取消操作的鍵。凍結時機的判準是業務對「過去」的要求：歷史記錄反映事件發生當下的世界，持有 live 參照的歷史會跟著現在的資料漂移。反過來，還在進行中的購物車品項持有 live 參照是正確的——會員身分改變、價格即時跟著變，這是進行中狀態的業務需求。同一個「參照要不要凍結」的問題，答案由生命週期階段決定。&lt;/p>
&lt;p>單一模型通吃全生命週期的代價在每個階段各自浮現：改量操作靠內容比對會誤中同內容的其他筆、歷史訂單持 live 參照會跟著商品改名漂移。拆分自己也有帳要付——層間 mapping、交棒處的同步成本、模型數量的認知負擔；轉折點少、各階段操作集合幾乎重合的概念，單一模型加階段旗標反而便宜。四個模型是這個 domain 有三個真實轉折點的結果、而不是通用配方——模型的邊界跟著身份語意的轉折點切，每段模型只服務自己階段的操作。&lt;/p>
&lt;h2 id="value-object-的價值語意封閉">value object 的價值：語意封閉&lt;/h2>
&lt;p>value object 的第二個價值獨立於同一性判定（也獨立於容器型別的類別判定——資料袋裡照樣可以放語意封閉的欄位型別）：把一個領域概念的合法運算限縮成封閉集合。判讀訊號是一個領域概念的合法運算集合、明顯小於它底層型別的運算集合——差集裡的每個運算都是一個等著被誤用的 API。金額是標準案例：底層數字型別開放任意四則運算，但「金額乘金額」在領域裡沒有意義、「金額加折扣率」是單位錯誤；同一個 POS 專案把金額換成高精度數字型別之後、這些誤用仍然全部放行，第二次遷移把金額包成 Money 型別、開放的運算限於領域有意義的集合（金額加減、乘數量、乘倍率、退款的負號）——運算列表本身就是領域規則的宣告（&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;/p>
&lt;p>這個案例同時標出兩個常被混淆的獨立問題：精度（浮點誤差）換底層型別就解決、語意（任意運算全放行）要包 domain type 才解決。解掉第一個問題的當下、第二個問題還完整存在，而它要等夠多誤用路徑累積後才顯形。判準操作化：盤點概念的合法運算清單、跟底層型別的運算集合做差集；差集非空、且裸型別跨模組邊界流動（或差集裡的誤用已經實際發生過一次），包一層的價值就成立。這層封閉防的是無心誤用；刻意拆封仍然可行，攔截點是拆封處的 code review，型別層防護的完整邊界見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>。&lt;/p>
&lt;h2 id="枚舉也是-value-object-建模">枚舉也是 value object 建模&lt;/h2>
&lt;p>分類值是 value object 的一種、同樣適用建模判準，而枚舉最常見的設計錯誤是粒度：分類系統的粒度是消費者的屬性、不是分類系統自己的屬性。同一個 POS 專案的支付方式有兩類消費者、粒度需求相反：序列化要無損對齊後端的完整列舉（對帳時兩筆記錄一筆支付寶一筆微信、壓成同一類就回不去了）、UI 行為分流只有少數真正的分歧（要不要找零、限不限會員）。單一枚舉選哪個粒度都犧牲一方，解法是分層——保真層無損對齊後端、行為層歸併成行為真正分歧的大類、層間用 exhaustive switch 衍生：「忘記決定新渠道歸哪類」這條違反路徑在編譯期就走不通（&lt;a href="https://tarrragon.github.io/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類&lt;/a>）。&lt;/p>
&lt;p>分層的判斷方式是列消費者：消費者一種、單一枚舉足夠；消費者多種且粒度需求不同、每個消費者一層，層的粒度是「這個消費者眼中真正有分歧的數量」。粒度選錯的訊號是例外註解與重複開始增生——粗粒度層長出「有些成員其實……」的例外說明、細粒度層的行為謂詞大半是複製貼上。另一個相鄰但不同的病要區分開：多個正交的分類軸被壓進同一個枚舉（狀態、格式、來源混裝）——那是拆軸問題、分層救不了，訊號同樣是例外增生、但修法是先把軸分開。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>改量、取消、退貨這類操作用內容比對定位對象——同內容的其他實體會被誤中，操作清單已經要求 identity-based 回寫、模型該升級。&lt;/li>
&lt;li>歷史記錄的顯示內容跟著現行資料變動（商品改名、訂單明細跟著變），是參照凍結時機漏掉的訊號：成為事實的資料要 snapshot。&lt;/li>
&lt;li>一個領域概念以裸的通用型別跨模組流通（金額是 double、識別碼是 string）、而它的合法運算遠少於底層型別——語意封閉的價值已成立，包 domain type。&lt;/li>
&lt;li>枚舉的行為謂詞大量重複、或某一類長出「有些成員例外」的註解：粒度或軸的選擇跟消費者需求不合，先列消費者清單再決定分層或拆軸。&lt;/li>
&lt;/ul>
&lt;p>函數式生態（Haskell、Elixir、F#）的對應形態不同但判準相同：entity 的同一性用 opaque type handle + 函數操作替代 mutable state + method，value object 用 newtype / smart constructor 確保合法值只能從受控管道建出。載體從 class 換成 module visibility 和 type wrapper，「操作需不需要 identity-based 定位」這條判準不變。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>身份與規則就位之後，規則的落點：&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;li>變更路徑收斂與稽核凍結：&lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>&lt;/li>
&lt;li>型別類別的入口判準：&lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a>&lt;/li>
&lt;li>Dart 的實作層整合（三種載體的選型判準、遷移安全網、取值出口設計）：&lt;a href="https://tarrragon.github.io/blog/flutter/value-object-dart-implementation/" data-link-title="值物件的 Dart 實作路徑" data-link-desc="一個領域值該不該脫離裸的通用型別、以及在 Dart 用哪種載體實作時使用。手寫 immutable class、freezed 產生器、extension type 零成本包裝的成本結構不同——欄位數、要不要 runtime 身份、boilerplate 容忍度決定選哪條，以及從原始型別遷移過去怎麼鎖住行為不變。">值物件的 Dart 實作路徑&lt;/a>；個別 case 細節：&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;a href="https://tarrragon.github.io/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>型別判定為領域模型之後（判定方式見 <a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>），下一個決策是身份語意：這個概念的「同一個」由什麼定義。身份語意決定業務規則作用在什麼對象上——判錯的後果直接撞上模組的源頭句「把業務規則放進領域模型、讓違反規則的路徑走不通」：規則以為自己守住了「那一筆」，實際作用在「內容相同的隨便一筆」上，違反規則的路徑照樣走得通。</p>
<h2 id="同一性是兩者的分界">同一性是兩者的分界</h2>
<p>entity 的同一性由身份定義：欄位可以全部改變、只要身份參照不變就是同一個；兩個欄位完全相同的 entity 仍然是兩個。value object 的同一性由內容定義：內容相等就是同一個，替換一個內容相同的實例對系統沒有任何影響。這條分界推導出兩者相反的設計形狀——entity 有生命週期、狀態沿業務流程演進、變更要有路徑；value object 不可變、要「改」就是造一個新值換上去。</p>
<p>相等性定義本身可以承載業務規則。一個 POS 專案的購物車品項用內容比對判定同一項、而折扣參與比對——手動改過價的品項被視為獨立的訂單行，合併購物車時只有規格、折扣、口味全部相同的品項才累加數量。「什麼算同一個」在這裡是業務決策寫進相等性定義的例子，而這正是 value object 的表達力所在：同一性規則集中在一個定義裡、所有比對點共用。</p>
<h2 id="判準操作需不需要-identity-based-定位">判準：操作需不需要 identity-based 定位</h2>
<p>判準是對這個物件的操作、需不需要精確指到某一個實體——概念重不重要、有沒有 id 欄位可以填，都不參與判斷。上述 POS 專案把這條判準踩出完整的階段軌跡：點餐階段的品項操作是「加一份」「換口味」，內容相等就是同一個、value object 的內容比對足夠；品項被掛單系統接受後獲得後端身份，操作變成「取消那一筆」「改那一筆的量」——同商品同口味的三筆明細內容完全相同，取消其中一筆時內容比對無法指定是哪一筆，此刻模型必須升級成持有身份參照的形態（<a href="/blog/work-log/dart_pos_item_four_lifecycle_models/" data-link-title="同一個品項、四個 model — value object 什麼時候該升級成 entity" data-link-desc="同一個業務概念要不要拆成多個 model、value object 什麼時候該升級成 entity——判準是操作需不需要 identity-based 回寫。以 POS 品項從點選、掛單、結算到歷史訂單的四階段模型為例，含 snapshot 與 live reference 的凍結時機。">同一個品項、四個 model</a>）。</p>
<p>操作形態對應三種模型選擇：</p>
<ul>
<li>操作以內容為對象（累加、合併、比對、替換）——value object，內容相等性就是全部所需。</li>
<li>操作要指到特定實體（取消那一筆、改那一筆的回寫，或讀取側的關聯、去重、生命週期追蹤）——entity，或至少是持有身份參照的包裝。</li>
<li>操作只剩查閱與退貨這類對既成事實的處置——live 內容參照凍結成 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a>、身份保留作退貨與取消的鍵，見下一節。</li>
</ul>
<p>判準的常見誤用是拿概念重要性代替操作分析：「訂單很重要所以是 entity」推不出正確結論，訂單行在輸入階段就是純內容比對；反方向「它有 id 欄位所以是 entity」同樣失效，id 可以只是序列化需要的欄位、與同一性判定無關。判準的作用對象是操作清單，操作清單來自業務流程——這也是為什麼身份語意的判定要等操作盤點之後才能做。</p>
<h2 id="判準隨生命週期重問">判準隨生命週期重問</h2>
<p>同一個業務概念的身份語意會在生命週期的轉折點改變，每個轉折點都要重問一次判準。上述品項模型的完整軌跡是四個模型接力：純需求描述（無 id、內容比對）、後端實體（後端 id）、訂單行（把多筆後端明細收攏成一行、持有它們的身份集合）、歷史明細（id 加全欄位 snapshot）。每一次交棒都對應身份狀態的真實變化，四個模型是身份語意在三個轉折點上改變的結果、而不是重複建模。</p>
<p>概念成為歷史事實之後，live 內容參照要凍結、身份繼續承重。歷史訂單明細保存下單當時的商品與價格 snapshot——商品後續改名、下架、調價，訂單仍顯示當時購買的內容；身份參照在這個階段轉而承擔退貨與取消操作的鍵。凍結時機的判準是業務對「過去」的要求：歷史記錄反映事件發生當下的世界，持有 live 參照的歷史會跟著現在的資料漂移。反過來，還在進行中的購物車品項持有 live 參照是正確的——會員身分改變、價格即時跟著變，這是進行中狀態的業務需求。同一個「參照要不要凍結」的問題，答案由生命週期階段決定。</p>
<p>單一模型通吃全生命週期的代價在每個階段各自浮現：改量操作靠內容比對會誤中同內容的其他筆、歷史訂單持 live 參照會跟著商品改名漂移。拆分自己也有帳要付——層間 mapping、交棒處的同步成本、模型數量的認知負擔；轉折點少、各階段操作集合幾乎重合的概念，單一模型加階段旗標反而便宜。四個模型是這個 domain 有三個真實轉折點的結果、而不是通用配方——模型的邊界跟著身份語意的轉折點切，每段模型只服務自己階段的操作。</p>
<h2 id="value-object-的價值語意封閉">value object 的價值：語意封閉</h2>
<p>value object 的第二個價值獨立於同一性判定（也獨立於容器型別的類別判定——資料袋裡照樣可以放語意封閉的欄位型別）：把一個領域概念的合法運算限縮成封閉集合。判讀訊號是一個領域概念的合法運算集合、明顯小於它底層型別的運算集合——差集裡的每個運算都是一個等著被誤用的 API。金額是標準案例：底層數字型別開放任意四則運算，但「金額乘金額」在領域裡沒有意義、「金額加折扣率」是單位錯誤；同一個 POS 專案把金額換成高精度數字型別之後、這些誤用仍然全部放行，第二次遷移把金額包成 Money 型別、開放的運算限於領域有意義的集合（金額加減、乘數量、乘倍率、退款的負號）——運算列表本身就是領域規則的宣告（<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>這個案例同時標出兩個常被混淆的獨立問題：精度（浮點誤差）換底層型別就解決、語意（任意運算全放行）要包 domain type 才解決。解掉第一個問題的當下、第二個問題還完整存在，而它要等夠多誤用路徑累積後才顯形。判準操作化：盤點概念的合法運算清單、跟底層型別的運算集合做差集；差集非空、且裸型別跨模組邊界流動（或差集裡的誤用已經實際發生過一次），包一層的價值就成立。這層封閉防的是無心誤用；刻意拆封仍然可行，攔截點是拆封處的 code review，型別層防護的完整邊界見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</p>
<h2 id="枚舉也是-value-object-建模">枚舉也是 value object 建模</h2>
<p>分類值是 value object 的一種、同樣適用建模判準，而枚舉最常見的設計錯誤是粒度：分類系統的粒度是消費者的屬性、不是分類系統自己的屬性。同一個 POS 專案的支付方式有兩類消費者、粒度需求相反：序列化要無損對齊後端的完整列舉（對帳時兩筆記錄一筆支付寶一筆微信、壓成同一類就回不去了）、UI 行為分流只有少數真正的分歧（要不要找零、限不限會員）。單一枚舉選哪個粒度都犧牲一方，解法是分層——保真層無損對齊後端、行為層歸併成行為真正分歧的大類、層間用 exhaustive switch 衍生：「忘記決定新渠道歸哪類」這條違反路徑在編譯期就走不通（<a href="/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類</a>）。</p>
<p>分層的判斷方式是列消費者：消費者一種、單一枚舉足夠；消費者多種且粒度需求不同、每個消費者一層，層的粒度是「這個消費者眼中真正有分歧的數量」。粒度選錯的訊號是例外註解與重複開始增生——粗粒度層長出「有些成員其實……」的例外說明、細粒度層的行為謂詞大半是複製貼上。另一個相鄰但不同的病要區分開：多個正交的分類軸被壓進同一個枚舉（狀態、格式、來源混裝）——那是拆軸問題、分層救不了，訊號同樣是例外增生、但修法是先把軸分開。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>改量、取消、退貨這類操作用內容比對定位對象——同內容的其他實體會被誤中，操作清單已經要求 identity-based 回寫、模型該升級。</li>
<li>歷史記錄的顯示內容跟著現行資料變動（商品改名、訂單明細跟著變），是參照凍結時機漏掉的訊號：成為事實的資料要 snapshot。</li>
<li>一個領域概念以裸的通用型別跨模組流通（金額是 double、識別碼是 string）、而它的合法運算遠少於底層型別——語意封閉的價值已成立，包 domain type。</li>
<li>枚舉的行為謂詞大量重複、或某一類長出「有些成員例外」的註解：粒度或軸的選擇跟消費者需求不合，先列消費者清單再決定分層或拆軸。</li>
</ul>
<p>函數式生態（Haskell、Elixir、F#）的對應形態不同但判準相同：entity 的同一性用 opaque type handle + 函數操作替代 mutable state + method，value object 用 newtype / smart constructor 確保合法值只能從受控管道建出。載體從 class 換成 module visibility 和 type wrapper，「操作需不需要 identity-based 定位」這條判準不變。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>身份與規則就位之後，規則的落點：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>變更路徑收斂與稽核凍結：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>型別類別的入口判準：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a></li>
<li>Dart 的實作層整合（三種載體的選型判準、遷移安全網、取值出口設計）：<a href="/blog/flutter/value-object-dart-implementation/" data-link-title="值物件的 Dart 實作路徑" data-link-desc="一個領域值該不該脫離裸的通用型別、以及在 Dart 用哪種載體實作時使用。手寫 immutable class、freezed 產生器、extension type 零成本包裝的成本結構不同——欄位數、要不要 runtime 身份、boilerplate 容忍度決定選哪條，以及從原始型別遷移過去怎麼鎖住行為不變。">值物件的 Dart 實作路徑</a>；個別 case 細節：<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/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類</a></li>
</ul>
]]></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>Value Object</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/value-object/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/value-object/</guid><description>&lt;p>Value object 的同一性由內容定義：內容相等就是同一個、替換一個內容相同的實例對系統沒有任何影響。要「改」就是造一個新值換上去——不可變。跟 &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> 相反：entity 的同一性由身份定義。相等性定義本身可以承載業務規則：「什麼算同一個」是業務決策寫進相等性定義的例子。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Value object 有兩個獨立的價值。第一個跟 entity 的判準有關——操作以內容為對象（累加、合併、比對、替換）就用 value object。第二個是語意封閉：把一個領域概念的合法運算限縮成封閉集合——差集裡的每個運算都是等著被誤用的 API。語意封閉的價值獨立於同一性判定、也獨立於容器型別的類別——&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>裡照樣可以放語意封閉的 value object。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>一個領域概念以裸的通用型別跨模組流通（金額是 double、識別碼是 string）、而它的合法運算遠少於底層型別——語意封閉的價值已成立。封裝後要給原始值一個語意明確的官方出口（見 &lt;a href="https://tarrragon.github.io/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a>）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Value object 需要決定相等性定義（哪些欄位參與比對、參與本身就是業務規則）、不可變策略、以及封裝出口。枚舉是 value object 的一種——粒度判準看消費者需求、不看分類系統本身。判準的完整展開見 &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;/p></description><content:encoded><![CDATA[<p>Value object 的同一性由內容定義：內容相等就是同一個、替換一個內容相同的實例對系統沒有任何影響。要「改」就是造一個新值換上去——不可變。跟 <a href="/blog/ddd/knowledge-cards/entity/" data-link-title="Entity" data-link-desc="判斷一個概念該建成 entity 還是 value object 時使用。entity 的同一性由身份定義——欄位全部改變、只要身份參照不變就是同一個。">entity</a> 相反：entity 的同一性由身份定義。相等性定義本身可以承載業務規則：「什麼算同一個」是業務決策寫進相等性定義的例子。</p>
<h2 id="概念位置">概念位置</h2>
<p>Value object 有兩個獨立的價值。第一個跟 entity 的判準有關——操作以內容為對象（累加、合併、比對、替換）就用 value object。第二個是語意封閉：把一個領域概念的合法運算限縮成封閉集合——差集裡的每個運算都是等著被誤用的 API。語意封閉的價值獨立於同一性判定、也獨立於容器型別的類別——<a href="/blog/ddd/knowledge-cards/data-bag/" data-link-title="Data Bag" data-link-desc="判斷一個型別要不要投資領域模型設計時使用。資料袋是欄位組合全部合法、沒有不變式要守的型別——DTO、API model、UI state 都屬於這一類。">資料袋</a>裡照樣可以放語意封閉的 value object。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>一個領域概念以裸的通用型別跨模組流通（金額是 double、識別碼是 string）、而它的合法運算遠少於底層型別——語意封閉的價值已成立。封裝後要給原始值一個語意明確的官方出口（見 <a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>）。</p>
<h2 id="設計責任">設計責任</h2>
<p>Value object 需要決定相等性定義（哪些欄位參與比對、參與本身就是業務規則）、不可變策略、以及封裝出口。枚舉是 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>。</p>
]]></content:encoded></item><item><title>建構路徑設計</title><link>https://tarrragon.github.io/blog/ddd/construction-path-design/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/construction-path-design/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 展開了建構期不變式（物件建構時必須通過的業務規則檢查）——物件出生的那一刻就必須合法。本章接下來的問題是建構路徑本身的設計：工廠的表達力有沒有覆蓋消費者的正當需求、出口有沒有給原始值一個語意明確的位置。「讓違反規則的路徑走不通」在建構路徑有一個常被忽略的前提——「讓遵守規則的路徑走得通」：正當需求在工廠裡找不到出口時、逃生口就成了預設路徑、規則也跟著失效。&lt;/p>
&lt;h2 id="工廠的表達力與消費者需求">工廠的表達力與消費者需求&lt;/h2>
&lt;p>工廠（建構子、命名工廠、builder）是合法物件的唯一入口。它的職責有兩個：保證出生即合法（不變式檢查）、同時覆蓋消費者的正當需求（給得出消費者想要的合法組合）。兩個職責有張力——保證越嚴、接受的參數越窄；解法是讓工廠的表達力跟上需求、而不是放棄保證。&lt;/p>
&lt;p>一個書籍管理 App 的 &lt;code>createForTest&lt;/code> 工廠只接受 &lt;code>id&lt;/code> / &lt;code>title&lt;/code> / &lt;code>author&lt;/code> / &lt;code>isbn&lt;/code> 四個參數。測試想表達「預設 tags 再追加三個自訂 tag」——這是合法的需求、工廠的表達力沒覆蓋到。需求進不了工廠，就會從別的出口出去——在這個專案，出口是全欄位 copyWith（&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;p>工廠參數列的設計判準：收的是「有語意的需求」（預設 tags 加自訂、指定初始狀態、帶入驗證過的關聯物件），不是「所有欄位的任意值」。後者退化成另一個全欄位建構器，保證也跟著消失——分寸在「覆蓋高頻的合法組合、不開放任意拼裝」——「高頻」的門檻見下一節：同一組合出現第二次就收進工廠。&lt;/p>
&lt;h2 id="逃生口吸收建構路徑的缺陷">逃生口吸收建構路徑的缺陷&lt;/h2>
&lt;p>逃生口吸收是建構路徑缺陷長期不被修好的核心機制：表達力缺口碰上萬能拼裝工具、上游缺陷被下游繞道消化、工廠永遠不會被迫改進。機制分三步。第一步、工廠表達不了需求：上述 &lt;code>createForTest&lt;/code> 不接受 &lt;code>bookTags&lt;/code>。第二步、逃生口接住需求：copyWith 總是能拼、它成為唯一出路。第三步、拼裝現場的最短路徑埋下語意錯誤：在用 copyWith 拼的當下、順手建個臨時物件撈預設值（&lt;code>Book.createForTest(id: 'tmp').bookTags&lt;/code>）是阻力最小的寫法——而 &lt;code>'tmp'&lt;/code> 不滿足 BookId 的長度約束。同型寫法幾天前才在另一個檔案修過一次。&lt;/p>
&lt;p>關鍵在第二步的因果方向：逃生口不只讓錯誤寫法變可能、它讓正確的修法變不必要。沒有逃生口時、工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法；有逃生口時、每個被擋的需求都繞出去了、工廠的缺陷永遠不會痛。同族語意錯誤第二次出現就是結構在選擇行為的證據：兩個作者（或同作者在不同時刻）在同一個表達力缺口前面各自獨立走出同一條最短路徑（&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;p>修法對準上游：讓工廠能表達需求（&lt;code>createForTest&lt;/code> 接受 &lt;code>bookTags&lt;/code>），拼裝的動機就消失了。修每一個拼裝點——把 &lt;code>'tmp'&lt;/code> 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀，下一個測試還是會在同一個表達力缺口前面選擇拼裝。門檻是兩次：第一次拼裝可以當個案修掉——只出現一次的特殊組合用拼裝是合理的、工廠也有參數列膨脹的反向代價。第二次出現就該停止修拼裝點、轉頭找共同面對的表達力缺口。工廠產物的 copyWith 呼叫在覆寫哪些欄位、就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查（&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>）。&lt;/p>
&lt;h2 id="原始值的官方出口">原始值的官方出口&lt;/h2>
&lt;p>建構路徑的另一面是出口：value object 封裝了原始值之後、基礎設施層（快取 key、資料庫 column、序列化）要怎麼拿到它需要的表示。穩態是封裝任意操作、而非封裝取值本身——基礎設施邊界的需求是正當的、該給語意明確的出口。這個穩態從擺盪中浮出。&lt;/p>
&lt;p>同一個書籍管理 App 的 value object 封裝在三個版本間擺盪。v0.7.6 全移除（裸字串），不變式失去強制點、任何字串冒充任何 ID。隨後 v0.8.10 推向另一極——完全封裝、目標零個 &lt;code>.value&lt;/code> 外部存取——基礎設施層的 176 個編譯錯誤暴露這些消費是正當需求，把它們全逼到 &lt;code>toString()&lt;/code> 上是語意寄生、格式一改快取 key 靜默換一批。最終 v0.8.13 加回 &lt;code>.value&lt;/code> getter、被重新命名成「相容性介面」，實質是理想在依賴現實前退讓（&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪&lt;/a>）。&lt;/p>
&lt;p>穩態的操作化：出口的名字說明用途：&lt;code>toJsonString()&lt;/code>（序列化）、&lt;code>toDbValue()&lt;/code>（持久化）、&lt;code>displayValue&lt;/code>（UI）。「誰在拆封」就變成可 grep 的——一個 POS 專案的 Money 型別用 &lt;code>toDecimal()&lt;/code> 作為官方拆封口、搜尋 &lt;code>toDecimal(&lt;/code> 就是拆封清單。沒有官方出口的世界裡、下游用 &lt;code>toString()&lt;/code> 硬接或把 &lt;code>.value&lt;/code> getter 加回來、拆封處完全不可追蹤。&lt;/p>
&lt;p>擺盪的根治不在選對某一極、在於把邊界寫成決策記錄：哪些出口存在、各自給誰用、為什麼不多不少。出口增長到大多數消費者都有專屬方法時、封裝的值已不成立——此時直接暴露一個語意明確的拆封方法，比維護多個用途近似的出口便宜。沒有這份記錄、每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。判讀徵兆：重構記錄出現「相容性介面」——檢查它是不是理想撤退的重新命名；決策記錄只記贏面——反方向的代價沒被記、下次擺回去的推力還在；封裝重構在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口不是硬改。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>測試 Arrange 段出現「工廠 + copyWith 拼裝」——盤點拼裝在補什麼欄位、高頻欄位收進工廠參數列。工廠參數列長期不變、而它的產物被 copyWith 環繞——表達力已落後需求，原則見 &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>。&lt;/li>
&lt;li>建一個立刻丟棄的物件、只為了拿它的一個欄位？這是語意錯誤的標記，先找它真正想表達的需求。&lt;/li>
&lt;li>同族語意錯誤第二次出現——停止修個案、找兩個案共同面對的建構路徑缺口。&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 被當成取值 API 用在快取 key / DB 值——語意寄生、格式一改靜默事故。出口要有語意明確的名字，語意封閉判準見 &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;/li>
&lt;li>VO 封裝重構的編譯錯誤在基礎設施層爆量——基礎設施是正當消費者、該給出口。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>物件組好之後、整個系統怎麼被組起來：&lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>&lt;/li>
&lt;li>出生合法的機制：&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;li>變更路徑收斂：&lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>&lt;/li>
&lt;li>型別類別的入口判準：&lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a>&lt;/li>
&lt;li>value object 的語意封閉判準：&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;/li>
&lt;li>Dart 的語言細節（copyWith 三態缺口、工廠表達力擴充、extension type 的拆封口）：&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/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪&lt;/a>&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>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 展開了建構期不變式（物件建構時必須通過的業務規則檢查）——物件出生的那一刻就必須合法。本章接下來的問題是建構路徑本身的設計：工廠的表達力有沒有覆蓋消費者的正當需求、出口有沒有給原始值一個語意明確的位置。「讓違反規則的路徑走不通」在建構路徑有一個常被忽略的前提——「讓遵守規則的路徑走得通」：正當需求在工廠裡找不到出口時、逃生口就成了預設路徑、規則也跟著失效。</p>
<h2 id="工廠的表達力與消費者需求">工廠的表達力與消費者需求</h2>
<p>工廠（建構子、命名工廠、builder）是合法物件的唯一入口。它的職責有兩個：保證出生即合法（不變式檢查）、同時覆蓋消費者的正當需求（給得出消費者想要的合法組合）。兩個職責有張力——保證越嚴、接受的參數越窄；解法是讓工廠的表達力跟上需求、而不是放棄保證。</p>
<p>一個書籍管理 App 的 <code>createForTest</code> 工廠只接受 <code>id</code> / <code>title</code> / <code>author</code> / <code>isbn</code> 四個參數。測試想表達「預設 tags 再追加三個自訂 tag」——這是合法的需求、工廠的表達力沒覆蓋到。需求進不了工廠，就會從別的出口出去——在這個專案，出口是全欄位 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>）。</p>
<p>工廠參數列的設計判準：收的是「有語意的需求」（預設 tags 加自訂、指定初始狀態、帶入驗證過的關聯物件），不是「所有欄位的任意值」。後者退化成另一個全欄位建構器，保證也跟著消失——分寸在「覆蓋高頻的合法組合、不開放任意拼裝」——「高頻」的門檻見下一節：同一組合出現第二次就收進工廠。</p>
<h2 id="逃生口吸收建構路徑的缺陷">逃生口吸收建構路徑的缺陷</h2>
<p>逃生口吸收是建構路徑缺陷長期不被修好的核心機制：表達力缺口碰上萬能拼裝工具、上游缺陷被下游繞道消化、工廠永遠不會被迫改進。機制分三步。第一步、工廠表達不了需求：上述 <code>createForTest</code> 不接受 <code>bookTags</code>。第二步、逃生口接住需求：copyWith 總是能拼、它成為唯一出路。第三步、拼裝現場的最短路徑埋下語意錯誤：在用 copyWith 拼的當下、順手建個臨時物件撈預設值（<code>Book.createForTest(id: 'tmp').bookTags</code>）是阻力最小的寫法——而 <code>'tmp'</code> 不滿足 BookId 的長度約束。同型寫法幾天前才在另一個檔案修過一次。</p>
<p>關鍵在第二步的因果方向：逃生口不只讓錯誤寫法變可能、它讓正確的修法變不必要。沒有逃生口時、工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法；有逃生口時、每個被擋的需求都繞出去了、工廠的缺陷永遠不會痛。同族語意錯誤第二次出現就是結構在選擇行為的證據：兩個作者（或同作者在不同時刻）在同一個表達力缺口前面各自獨立走出同一條最短路徑（<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>
<p>修法對準上游：讓工廠能表達需求（<code>createForTest</code> 接受 <code>bookTags</code>），拼裝的動機就消失了。修每一個拼裝點——把 <code>'tmp'</code> 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀，下一個測試還是會在同一個表達力缺口前面選擇拼裝。門檻是兩次：第一次拼裝可以當個案修掉——只出現一次的特殊組合用拼裝是合理的、工廠也有參數列膨脹的反向代價。第二次出現就該停止修拼裝點、轉頭找共同面對的表達力缺口。工廠產物的 copyWith 呼叫在覆寫哪些欄位、就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查（<a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>）。</p>
<h2 id="原始值的官方出口">原始值的官方出口</h2>
<p>建構路徑的另一面是出口：value object 封裝了原始值之後、基礎設施層（快取 key、資料庫 column、序列化）要怎麼拿到它需要的表示。穩態是封裝任意操作、而非封裝取值本身——基礎設施邊界的需求是正當的、該給語意明確的出口。這個穩態從擺盪中浮出。</p>
<p>同一個書籍管理 App 的 value object 封裝在三個版本間擺盪。v0.7.6 全移除（裸字串），不變式失去強制點、任何字串冒充任何 ID。隨後 v0.8.10 推向另一極——完全封裝、目標零個 <code>.value</code> 外部存取——基礎設施層的 176 個編譯錯誤暴露這些消費是正當需求，把它們全逼到 <code>toString()</code> 上是語意寄生、格式一改快取 key 靜默換一批。最終 v0.8.13 加回 <code>.value</code> getter、被重新命名成「相容性介面」，實質是理想在依賴現實前退讓（<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪</a>）。</p>
<p>穩態的操作化：出口的名字說明用途：<code>toJsonString()</code>（序列化）、<code>toDbValue()</code>（持久化）、<code>displayValue</code>（UI）。「誰在拆封」就變成可 grep 的——一個 POS 專案的 Money 型別用 <code>toDecimal()</code> 作為官方拆封口、搜尋 <code>toDecimal(</code> 就是拆封清單。沒有官方出口的世界裡、下游用 <code>toString()</code> 硬接或把 <code>.value</code> getter 加回來、拆封處完全不可追蹤。</p>
<p>擺盪的根治不在選對某一極、在於把邊界寫成決策記錄：哪些出口存在、各自給誰用、為什麼不多不少。出口增長到大多數消費者都有專屬方法時、封裝的值已不成立——此時直接暴露一個語意明確的拆封方法，比維護多個用途近似的出口便宜。沒有這份記錄、每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。判讀徵兆：重構記錄出現「相容性介面」——檢查它是不是理想撤退的重新命名；決策記錄只記贏面——反方向的代價沒被記、下次擺回去的推力還在；封裝重構在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口不是硬改。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>測試 Arrange 段出現「工廠 + copyWith 拼裝」——盤點拼裝在補什麼欄位、高頻欄位收進工廠參數列。工廠參數列長期不變、而它的產物被 copyWith 環繞——表達力已落後需求，原則見 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223</a>。</li>
<li>建一個立刻丟棄的物件、只為了拿它的一個欄位？這是語意錯誤的標記，先找它真正想表達的需求。</li>
<li>同族語意錯誤第二次出現——停止修個案、找兩個案共同面對的建構路徑缺口。</li>
<li><code>toString()</code> 被當成取值 API 用在快取 key / DB 值——語意寄生、格式一改靜默事故。出口要有語意明確的名字，語意封閉判準見 <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>。</li>
<li>VO 封裝重構的編譯錯誤在基礎設施層爆量——基礎設施是正當消費者、該給出口。</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>物件組好之後、整個系統怎麼被組起來：<a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></li>
<li>出生合法的機制：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>變更路徑收斂：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>型別類別的入口判準：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a></li>
<li>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></li>
<li>Dart 的語言細節（copyWith 三態缺口、工廠表達力擴充、extension type 的拆封口）：<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/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪</a></li>
<li>原則層：<a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a></li>
</ul>
]]></content:encoded></item><item><title>「978ABC」被拒的理由寫著長度不對 — 驗證的兩層分工與順序陷阱</title><link>https://tarrragon.github.io/blog/work-log/flutter_domain_input_validation_placement/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_domain_input_validation_placement/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的查詢輸入層——&lt;code>BookQueryInput&lt;/code> value object 加 &lt;code>BookInputValidator&lt;/code> 驗證器。實作過程撞了兩個問題：測試想建一個全空的輸入來測 validator、被 VO 的建構驗證擋住建不出來；ISBN 填 &lt;code>978ABC&lt;/code> 被拒、錯誤訊息卻說「長度必須是 10 或 13 位」
&lt;strong>疑問來源&lt;/strong>：驗證邏輯到底該放建構子還是 validator？以及那個張冠李戴的錯誤訊息是怎麼來的？
&lt;strong>整理目的&lt;/strong>：記下驗證的兩層分工判準、以及「先標準化再檢查」的證據銷毀陷阱
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.11.3 的實作記錄（41 個單元測試的 TDD 過程、含三個實作期問題的解法）&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="兩層分工存在條件-vs-輸入品質">兩層分工：存在條件 vs 輸入品質&lt;/h2>
&lt;p>這一層的設計把驗證拆在兩個位置，各守一種性質的規則：&lt;/p>
&lt;p>&lt;strong>&lt;code>BookQueryInput&lt;/code> 的建構期不變式&lt;/strong>：至少一個查詢參數非空。這是&lt;strong>存在條件&lt;/strong>——四個欄位全空的「查詢輸入」在語意上不是一個查詢，這種物件不該存在於系統的任何角落。違反它的處置是拒絕建構：拿到 &lt;code>BookQueryInput&lt;/code> 實例的任何下游、都可以信任它至少有一個參數。&lt;/p>
&lt;p>&lt;strong>&lt;code>BookInputValidator&lt;/code> 的格式驗證&lt;/strong>：ISBN 格式（10 或 13 位、允許連字符與空格）、標題與作者長度（1-255 字元）、附帶標準化（去連字符、收斂空白）。這是&lt;strong>輸入品質&lt;/strong>——使用者打錯很正常，處置不是拒絕存在、是回一個 &lt;code>ValidationResult&lt;/code>：錯誤碼清單、本地化訊息、以及標準化後的值。&lt;/p>
&lt;p>判準收成一句：&lt;strong>違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator&lt;/strong>。前者失敗是程式錯誤（哪段程式碼試圖建一個不合法的物件？）、後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向都會現形：格式驗證塞進建構子，UI 層要 try-catch 例外再翻譯成欄位錯誤、錯誤碼與訊息的結構化全部丟失；存在條件放進 validator，全空的物件能在系統裡流通、每個消費者都要自己防。&lt;/p>
&lt;p>有趣的是這個分工是被測試&lt;strong>撞&lt;/strong>出來的：測試想建全空實例去測 validator 的「至少一個參數」規則、被建構不變式擋住。這個衝突不是誰錯——它暴露了「至少一個參數」同時被兩層宣告。釐清後規則歸建構期（存在條件）、validator 的對應測試改測空白字串等輸入品質情境。測試建不出 fixture、經常就是層次劃分待釐清的訊號。&lt;/p>
&lt;h2 id="順序陷阱先標準化等於先銷毀證據">順序陷阱：先標準化、等於先銷毀證據&lt;/h2>
&lt;p>第二個問題是條精緻的小 bug。ISBN 驗證的原始順序是「先標準化、再檢查」：標準化移除所有非數字字元、然後檢查位數。輸入 &lt;code>978ABC&lt;/code> 走完這條管線：字母被移除、剩 &lt;code>978&lt;/code>、三位數、被拒——錯誤訊息是「長度必須是 10 或 13 位」。&lt;/p>
&lt;p>拒絕是對的、&lt;strong>理由是錯的&lt;/strong>。使用者的實際問題是「ISBN 含字母」，訊息卻叫他去檢查長度——他數了數自己輸入的六個字元、更困惑了。機制上這是&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="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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">RegExp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">r&amp;#39;^[\d\-\s]+$&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">hasMatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isbn&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="k">return&lt;/span> &lt;span class="s1">&amp;#39;invalid_isbn_format&amp;#39;&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="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="c1">// 通過格式檢查的才標準化、再驗位數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">normalized&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">normalizeIsbn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isbn&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一般化的規則：&lt;strong>診斷在證據被破壞之前做&lt;/strong>。管線裡任何有損轉換（去除字元、截斷、大小寫合併、去重）之後的檢查，都只能回報轉換後世界的錯誤——想給使用者他輸入層面的錯誤訊息、檢查就得在轉換前。這條規則在錯誤處理鏈上反覆適用：wrap 例外時保留原始例外、log 時保留原始輸入，同一個「別讓下游只看到殘骸」。&lt;/p>
&lt;p>另一個小設計也值得帶走：&lt;code>ValidationResult&lt;/code> 直接攜帶 &lt;code>normalizedIsbn&lt;/code> / &lt;code>normalizedTitle&lt;/code>——驗證跟標準化一次完成、下游拿標準化值繼續用，不會出現「驗證器驗一個版本、查詢用另一個版本」的分裂。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>測試建不出想要的 fixture、被建構驗證擋住——存在條件與輸入品質可能混在同一層、先釐清歸屬&lt;/li>
&lt;li>錯誤訊息與使用者的實際輸入對不上（說長度、其實是字元；說格式、其實是空值）——檢查點在有損轉換之後、往管線上游搬&lt;/li>
&lt;li>UI 層用 try-catch 接建構例外再翻譯成表單錯誤——格式驗證放錯層了、它該回結構化結果不該拋&lt;/li>
&lt;li>驗證器回布林——錯誤碼、訊息、標準化值都沒有位置放，遲早長出第二套平行邏輯&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>不變式住哪一層的全景：&lt;a href="https://tarrragon.github.io/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通&lt;/a>——本文的建構期不變式是「執行層強制」的標準形態&lt;/li>
&lt;li>同族的分類學：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式&lt;/a>——那篇是錯誤的分類不變式、本文是輸入的存在不變式&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a>——建構子與 validator 的分工是建構路徑設計的入口決策；存在條件與輸入品質的分層邊界見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的查詢輸入層——<code>BookQueryInput</code> value object 加 <code>BookInputValidator</code> 驗證器。實作過程撞了兩個問題：測試想建一個全空的輸入來測 validator、被 VO 的建構驗證擋住建不出來；ISBN 填 <code>978ABC</code> 被拒、錯誤訊息卻說「長度必須是 10 或 13 位」
<strong>疑問來源</strong>：驗證邏輯到底該放建構子還是 validator？以及那個張冠李戴的錯誤訊息是怎麼來的？
<strong>整理目的</strong>：記下驗證的兩層分工判準、以及「先標準化再檢查」的證據銷毀陷阱
<strong>本文邊界</strong>：素材是該專案 v0.11.3 的實作記錄（41 個單元測試的 TDD 過程、含三個實作期問題的解法）</p></blockquote>
<hr>
<h2 id="兩層分工存在條件-vs-輸入品質">兩層分工：存在條件 vs 輸入品質</h2>
<p>這一層的設計把驗證拆在兩個位置，各守一種性質的規則：</p>
<p><strong><code>BookQueryInput</code> 的建構期不變式</strong>：至少一個查詢參數非空。這是<strong>存在條件</strong>——四個欄位全空的「查詢輸入」在語意上不是一個查詢，這種物件不該存在於系統的任何角落。違反它的處置是拒絕建構：拿到 <code>BookQueryInput</code> 實例的任何下游、都可以信任它至少有一個參數。</p>
<p><strong><code>BookInputValidator</code> 的格式驗證</strong>：ISBN 格式（10 或 13 位、允許連字符與空格）、標題與作者長度（1-255 字元）、附帶標準化（去連字符、收斂空白）。這是<strong>輸入品質</strong>——使用者打錯很正常，處置不是拒絕存在、是回一個 <code>ValidationResult</code>：錯誤碼清單、本地化訊息、以及標準化後的值。</p>
<p>判準收成一句：<strong>違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator</strong>。前者失敗是程式錯誤（哪段程式碼試圖建一個不合法的物件？）、後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向都會現形：格式驗證塞進建構子，UI 層要 try-catch 例外再翻譯成欄位錯誤、錯誤碼與訊息的結構化全部丟失；存在條件放進 validator，全空的物件能在系統裡流通、每個消費者都要自己防。</p>
<p>有趣的是這個分工是被測試<strong>撞</strong>出來的：測試想建全空實例去測 validator 的「至少一個參數」規則、被建構不變式擋住。這個衝突不是誰錯——它暴露了「至少一個參數」同時被兩層宣告。釐清後規則歸建構期（存在條件）、validator 的對應測試改測空白字串等輸入品質情境。測試建不出 fixture、經常就是層次劃分待釐清的訊號。</p>
<h2 id="順序陷阱先標準化等於先銷毀證據">順序陷阱：先標準化、等於先銷毀證據</h2>
<p>第二個問題是條精緻的小 bug。ISBN 驗證的原始順序是「先標準化、再檢查」：標準化移除所有非數字字元、然後檢查位數。輸入 <code>978ABC</code> 走完這條管線：字母被移除、剩 <code>978</code>、三位數、被拒——錯誤訊息是「長度必須是 10 或 13 位」。</p>
<p>拒絕是對的、<strong>理由是錯的</strong>。使用者的實際問題是「ISBN 含字母」，訊息卻叫他去檢查長度——他數了數自己輸入的六個字元、更困惑了。機制上這是<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="c1">// 先對原始輸入檢查格式——證據還在
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">RegExp</span><span class="p">(</span><span class="s1">r&#39;^[\d\-\s]+$&#39;</span><span class="p">).</span><span class="n">hasMatch</span><span class="p">(</span><span class="n">isbn</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="s1">&#39;invalid_isbn_format&#39;</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="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 通過格式檢查的才標準化、再驗位數
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">normalized</span> <span class="o">=</span> <span class="n">normalizeIsbn</span><span class="p">(</span><span class="n">isbn</span><span class="p">);</span></span></span></code></pre></div><p>一般化的規則：<strong>診斷在證據被破壞之前做</strong>。管線裡任何有損轉換（去除字元、截斷、大小寫合併、去重）之後的檢查，都只能回報轉換後世界的錯誤——想給使用者他輸入層面的錯誤訊息、檢查就得在轉換前。這條規則在錯誤處理鏈上反覆適用：wrap 例外時保留原始例外、log 時保留原始輸入，同一個「別讓下游只看到殘骸」。</p>
<p>另一個小設計也值得帶走：<code>ValidationResult</code> 直接攜帶 <code>normalizedIsbn</code> / <code>normalizedTitle</code>——驗證跟標準化一次完成、下游拿標準化值繼續用，不會出現「驗證器驗一個版本、查詢用另一個版本」的分裂。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>測試建不出想要的 fixture、被建構驗證擋住——存在條件與輸入品質可能混在同一層、先釐清歸屬</li>
<li>錯誤訊息與使用者的實際輸入對不上（說長度、其實是字元；說格式、其實是空值）——檢查點在有損轉換之後、往管線上游搬</li>
<li>UI 層用 try-catch 接建構例外再翻譯成表單錯誤——格式驗證放錯層了、它該回結構化結果不該拋</li>
<li>驗證器回布林——錯誤碼、訊息、標準化值都沒有位置放，遲早長出第二套平行邏輯</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>不變式住哪一層的全景：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>——本文的建構期不變式是「執行層強制」的標準形態</li>
<li>同族的分類學：<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式</a>——那篇是錯誤的分類不變式、本文是輸入的存在不變式</li>
<li>概念地基：<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>——建構子與 validator 的分工是建構路徑設計的入口決策；存在條件與輸入品質的分層邊界見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite 只吃三種型別 — value object 在持久化邊界的序列化契約</title><link>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：&lt;code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported&lt;/code>——所有涉及 SQLite 的 CRUD 操作都掛
&lt;strong>疑問來源&lt;/strong>：同一個 map 裡 &lt;code>id&lt;/code> 跟 &lt;code>title&lt;/code> 都存得進去，為什麼 &lt;code>author&lt;/code> 炸了？
&lt;strong>整理目的&lt;/strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法&lt;/h2>
&lt;p>炸點在 repository 把 entity 轉成資料庫 map 的方法：&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">Map&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_bookToMap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Book&lt;/span> &lt;span class="n">book&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="k">return&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="s1">&amp;#39;id&amp;#39;&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">id&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookId → String，存得進去
&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="s1">&amp;#39;title&amp;#39;&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">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="c1">// BookTitle → String，存得進去
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="s1">&amp;#39;author&amp;#39;&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">author&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// BookAuthor 物件直接塞 → 炸
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>sqflite 底下的 SQLite 只接受 &lt;code>num&lt;/code>、&lt;code>String&lt;/code>、&lt;code>Uint8List&lt;/code> 三種型別。&lt;code>BookAuthor&lt;/code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：&lt;strong>誰負責把領域型別拆成儲存型別？&lt;/strong>&lt;/p>
&lt;h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界&lt;/h2>
&lt;p>修法本身一行：&lt;code>'author': book.author.toString()&lt;/code>；讀回的方向 &lt;code>_mapToBook&lt;/code> 已經在用 &lt;code>BookAuthor.fromString(map['author'])&lt;/code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 &lt;code>_bookToMap&lt;/code> / &lt;code>_mapToBook&lt;/code> 一對方法裡。&lt;/p>
&lt;p>這個歸位讓錯誤的形態變得可預測：&lt;strong>每個新的 VO 欄位都要在這對方法裡出現一次&lt;/strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。&lt;/p>
&lt;h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制&lt;/h2>
&lt;p>用 &lt;code>toString()&lt;/code> / &lt;code>fromString()&lt;/code> 當序列化通道，工作的前提是 &lt;code>fromString(x.toString()) == x&lt;/code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。&lt;/p>
&lt;p>具體的斷裂點兩種。其一，&lt;code>toString()&lt;/code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 &lt;code>BookAuthor(name: ...)&lt;/code>，資料庫裡從此存進去的是新格式、舊資料用新 &lt;code>fromString&lt;/code> 讀不回來——&lt;strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步&lt;/strong>。其二，格式本身有損：這個專案的 &lt;code>BookAuthor&lt;/code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。&lt;/p>
&lt;p>正解方向修復記錄也留了：語意明確的序列化介面（&lt;code>toDbValue()&lt;/code> / 專用 &lt;code>Serializable&lt;/code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 &lt;code>fromString(v.toString()) == v&lt;/code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>&lt;code>Invalid argument ... with type X. Only num, String and Uint8List are supported&lt;/code>——X 就是漏轉換的 VO、去 &lt;code>_toMap&lt;/code> 系方法找它&lt;/li>
&lt;li>repository 的 map 轉換裡混用「物件直接放」跟「&lt;code>.toString()&lt;/code>」兩種寫法——前者是還沒炸的候選&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式&lt;/li>
&lt;li>VO 有 &lt;code>fromString&lt;/code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>出口語意的原則版：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪&lt;/a>——那篇論證「原始值要有語意明確的官方出口」，本文是 &lt;code>toString()&lt;/code> 被當出口用的實際風險清單&lt;/li>
&lt;li>持久化邊界的另一面：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化&lt;/a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的 entity 持久化邊界章節&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的資料庫整合測試全面失敗，錯誤訊息：<code>Invalid argument 整合測試作者 with type BookAuthor. Only num, String and Uint8List are supported</code>——所有涉及 SQLite 的 CRUD 操作都掛
<strong>疑問來源</strong>：同一個 map 裡 <code>id</code> 跟 <code>title</code> 都存得進去，為什麼 <code>author</code> 炸了？
<strong>整理目的</strong>：記下 value object 跨持久化邊界的轉換責任、以及 toString/fromString 這條隱性契約的風險
<strong>本文邊界</strong>：素材是該專案 v0.10.6 的修復規劃記錄；sqflite 的型別限制是 SQLite 本身的特性、不是套件的設計選擇</p></blockquote>
<hr>
<h2 id="錯誤現場三個欄位兩種寫法">錯誤現場：三個欄位、兩種寫法</h2>
<p>炸點在 repository 把 entity 轉成資料庫 map 的方法：</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">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">_bookToMap</span><span class="p">(</span><span class="n">Book</span> <span class="n">book</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s1">&#39;id&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">id</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>        <span class="c1">// BookId → String，存得進去
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;title&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">title</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>  <span class="c1">// BookTitle → String，存得進去
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;author&#39;</span><span class="o">:</span> <span class="n">book</span><span class="p">.</span><span class="n">author</span><span class="p">,</span>           <span class="c1">// BookAuthor 物件直接塞 → 炸
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="p">...</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>sqflite 底下的 SQLite 只接受 <code>num</code>、<code>String</code>、<code>Uint8List</code> 三種型別。<code>BookAuthor</code> 是帶內部狀態的 value object（作者清單、譯者），直接放進 map 就是把一個 Dart 物件遞給不認識它的儲存引擎。錯誤訊息其實說得很清楚——難的不是修，是這個錯誤揭露的責任問題：<strong>誰負責把領域型別拆成儲存型別？</strong></p>
<h2 id="責任歸位轉換發生在-repository-邊界">責任歸位：轉換發生在 repository 邊界</h2>
<p>修法本身一行：<code>'author': book.author.toString()</code>；讀回的方向 <code>_mapToBook</code> 已經在用 <code>BookAuthor.fromString(map['author'])</code> 重建。架構上這是 adapter 的職責放在 repository 層——domain 的 value object 不知道 SQLite 存在、SQLite 不知道 value object 存在，兩個世界的轉換集中在 I/O 邊界的 <code>_bookToMap</code> / <code>_mapToBook</code> 一對方法裡。</p>
<p>這個歸位讓錯誤的形態變得可預測：<strong>每個新的 VO 欄位都要在這對方法裡出現一次</strong>，漏掉序列化端會炸 Invalid argument（吵、好抓）、漏掉反序列化端會在讀取時炸型別轉換（也吵）。真正安靜的坑在第三種情況——兩端都寫了、但不對稱。</p>
<h2 id="隱性契約tostring-與-fromstring-的對稱性沒人強制">隱性契約：toString 與 fromString 的對稱性沒人強制</h2>
<p>用 <code>toString()</code> / <code>fromString()</code> 當序列化通道，工作的前提是 <code>fromString(x.toString()) == x</code>——而這條契約沒有任何機制在守。修復記錄自己就把風險寫進了已知限制：複雜物件轉字串可能遺失部分內部狀態、未來需要更精細的序列化機制。</p>
<p>具體的斷裂點兩種。其一，<code>toString()</code> 的本業是除錯表示，哪天有人為了 log 可讀性把格式改成 <code>BookAuthor(name: ...)</code>，資料庫裡從此存進去的是新格式、舊資料用新 <code>fromString</code> 讀不回來——<strong>兩個消費者（除錯與持久化）寄生在同一個方法上、變更理由不同步</strong>。其二，格式本身有損：這個專案的 <code>BookAuthor</code> 把多作者序列化成「作者1, 作者2」、譯者成「作者 (譯者 譯)」——作者名字裡出現逗號或括號時，roundtrip 就不再對稱。</p>
<p>正解方向修復記錄也留了：語意明確的序列化介面（<code>toDbValue()</code> / 專用 <code>Serializable</code>、或 JSON 結構化），讓「持久化格式」成為一個有自己名字、自己測試、自己變更理由的東西。過渡期至少要補上對稱性測試——對每個 VO 斷言 <code>fromString(v.toString()) == v</code>、含邊界值（空作者、多作者、含譯者），把隱性契約變成會紅的測試。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li><code>Invalid argument ... with type X. Only num, String and Uint8List are supported</code>——X 就是漏轉換的 VO、去 <code>_toMap</code> 系方法找它</li>
<li>repository 的 map 轉換裡混用「物件直接放」跟「<code>.toString()</code>」兩種寫法——前者是還沒炸的候選</li>
<li><code>toString()</code> 同時服務除錯輸出跟持久化 / 快取 key——語意寄生，兩個消費者遲早有一個要改格式</li>
<li>VO 有 <code>fromString</code> 但測試裡沒有任何 roundtrip 斷言——對稱契約處於未驗證狀態</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>出口語意的原則版：<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>——那篇論證「原始值要有語意明確的官方出口」，本文是 <code>toString()</code> 被當出口用的實際風險清單</li>
<li>持久化邊界的另一面：<a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化</a>——那篇是欄位沒進出邊界、本文是進了邊界但轉換錯誤，roundtrip 測試同時守住兩者</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的 entity 持久化邊界章節</li>
</ul>
]]></content:encoded></item><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><item><title>同一個品項、四個 model — value object 什麼時候該升級成 entity</title><link>https://tarrragon.github.io/blog/work-log/dart_pos_item_four_lifecycle_models/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_pos_item_four_lifecycle_models/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：整理 POS 專案的購物車模型時，發現「一個商品品項」這個概念在 codebase 裡有四個 model：&lt;code>CartItem&lt;/code>、&lt;code>ShoppingCartDetail&lt;/code>、&lt;code>OrderedCartItem&lt;/code>、&lt;code>OrderItem&lt;/code>。乍看是重複建模
&lt;strong>疑問來源&lt;/strong>：四個 model 是過度設計、還是各有不可合併的職責？如果是後者，拆分的判準是什麼？
&lt;strong>整理目的&lt;/strong>：把「同一個業務概念何時該拆 model、value object 何時升級成 entity」的判斷邊界記下來
&lt;strong>本文邊界&lt;/strong>：素材是一個 Flutter POS App 的現行實作；「四個」是這個 domain 的結果、不是通用配方——判準才是可遷移的部分&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="四個-model-各在哪個生命週期">四個 model 各在哪個生命週期&lt;/h2>
&lt;p>一個商品從「使用者點選」到「進了歷史訂單」，這個專案用四個 model 接力表達：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Model&lt;/th>
 &lt;th>生命週期階段&lt;/th>
 &lt;th>同一性的依據&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>CartItem&lt;/code>&lt;/td>
 &lt;td>點餐輸入、還沒送出&lt;/td>
 &lt;td>內容比對（spec + 折扣 + 口味集合）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ShoppingCartDetail&lt;/code>&lt;/td>
 &lt;td>掛單系統接受後的後端實體&lt;/td>
 &lt;td>後端 &lt;code>detail.id&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>OrderedCartItem&lt;/code>&lt;/td>
 &lt;td>結帳畫面上的一筆訂單行&lt;/td>
 &lt;td>&lt;code>sourceDetailIds&lt;/code>（摺疊多筆 detail）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>OrderItem&lt;/code>&lt;/td>
 &lt;td>結完帳的歷史訂單明細&lt;/td>
 &lt;td>&lt;code>detailId&lt;/code> + 全欄位 snapshot&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一次交棒都對應一個身份狀態的變化，這是四個 model 不可合併的原因。&lt;/p>
&lt;h2 id="階段一cartitem-是純需求描述沒有-id">階段一：CartItem 是純需求描述、沒有 id&lt;/h2>
&lt;p>&lt;code>CartItem&lt;/code> 表達「使用者想要什麼」：商品、規格、數量、折扣、口味。它沒有任何 id 欄位——兩個 &lt;code>CartItem&lt;/code> 是不是同一項，靠 &lt;code>isSameItem()&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="kt">bool&lt;/span> &lt;span class="n">isSameItem&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CartItem&lt;/span> &lt;span class="n">other&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">specification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">specification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="kc">false&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">discount&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">discount&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="kc">false&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="c1">// 口味集合比對（不考慮順序）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是 value object 的語意：&lt;strong>內容相等就是同一個&lt;/strong>。合併購物車（&lt;code>mergeItems&lt;/code>）靠這個判定把相同品項的數量累加。值得留意折扣也參與同一性判定——改過價的品項是不同的訂單行，這是業務規則直接寫進相等性定義的例子。&lt;/p>
&lt;h2 id="階段二掛單接受的那一刻identity-誕生">階段二：掛單接受的那一刻、identity 誕生&lt;/h2>
&lt;p>需求被掛單系統接受、寫進 &lt;code>ShoppingCart.details&lt;/code> 之後，每筆明細獲得了後端身份 &lt;code>detail.id&lt;/code>。model 的原始註解把這個轉折講得很清楚：&lt;/p>
&lt;blockquote>
&lt;p>一旦這個需求被掛單系統接受、寫進 details，它就獲得了後端身份（detail.id），從這刻起在前端應以 OrderedCartItem 表達——客人加點同一項三次，邏輯上是一筆訂單行（一個 OrderedCartItem），實體上是三筆 detail。&lt;/p>&lt;/blockquote>
&lt;p>&lt;code>OrderedCartItem&lt;/code> 的結構只有兩個欄位：&lt;code>cartItem&lt;/code>（內容）加 &lt;code>sourceDetailIds&lt;/code>（身份）。它存在的理由是&lt;strong>操作需要精確回寫&lt;/strong>：改數量、單品取消、單品改價，都必須映射回後端要修改的那幾筆 detail。內容比對在這裡不夠用——同商品同口味的三筆 detail 內容完全相同，取消其中一筆時內容比對無法指定是哪一筆。&lt;/p>
&lt;p>購物車 model 上有一段對應的契約註解：UI 顯示的列表經過合併與過濾，「UI 列表的 index 跟 details 的 index 不是同一個東西」，任何 UI 到後端 detail 的操作都要透過 &lt;code>sourceDetailIds&lt;/code> 做 id-based 比對。用 index 對應兩個列表是這個結構下最容易踩的錯誤路徑，契約直接把它寫死在文件裡。&lt;/p>
&lt;h2 id="階段三結完帳參照凍結成-snapshot">階段三：結完帳、參照凍結成 snapshot&lt;/h2>
&lt;p>&lt;code>OrderItem&lt;/code> 是結帳完成後的歷史事實。它跟 &lt;code>CartItem&lt;/code> 的關鍵差異是參照的凍結：&lt;/p>
&lt;ul>
&lt;li>&lt;code>CartItem&lt;/code> 持有 live 的 &lt;code>Product&lt;/code> 參照，價格即時查當前規格（會員身分變了、價格跟著變）&lt;/li>
&lt;li>&lt;code>OrderItem&lt;/code> 保存 &lt;code>OrderDetailProduct&lt;/code> / &lt;code>OrderDetailProductSpecification&lt;/code> 的 snapshot，註解明說「即使後續商品改名/下架，訂單仍顯示當時購買的內容」；&lt;code>unitPrice&lt;/code> 也在 &lt;code>fromResponse&lt;/code> 時依當時的會員身分擇一凍結&lt;/li>
&lt;/ul>
&lt;p>&lt;code>detailId&lt;/code> 在這個階段承擔新職責：退貨與取消 API 的鍵、以及同訂單中區分「同商品不同口味」的唯一鍵。&lt;/p>
&lt;h2 id="判準操作需不需要-identity-based-回寫">判準：操作需不需要 identity-based 回寫&lt;/h2>
&lt;p>把三次交棒放在一起看，「value object 什麼時候該升級成 entity」的答案就浮出來了。判準是&lt;strong>對這個物件的操作，需不需要精確指到某一個實體&lt;/strong>——概念重不重要、有沒有 id 欄位可以填，都不參與這個判斷。&lt;/p>
&lt;ul>
&lt;li>需求描述階段：操作是「加一份」「換口味」，內容相等就是同一個，value object 的內容比對足夠&lt;/li>
&lt;li>進入外部系統之後：操作是「取消那一筆」「改那一筆的量」，必須 identity-based 回寫，此時需要 entity（或至少像 &lt;code>OrderedCartItem&lt;/code> 這樣持有身份參照的包裝）&lt;/li>
&lt;li>成為歷史事實之後：操作只剩查閱與退貨，連 live 參照都要凍結成 snapshot——歷史不隨現在的資料變動&lt;/li>
&lt;/ul>
&lt;p>反過來看單一 model 通吃的代價：改量操作靠內容比對會誤中同內容的其他筆；歷史訂單持 live 參照會跟著商品改名漂移。四個 model 不是重複，是身份語意在三個轉折點上真的變了。&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/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>（凍結作為稽核端點的教學層展開）&lt;/li>
&lt;li>同專案的 snapshot 對照組：entity 稽核軌跡的洞（&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;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：整理 POS 專案的購物車模型時，發現「一個商品品項」這個概念在 codebase 裡有四個 model：<code>CartItem</code>、<code>ShoppingCartDetail</code>、<code>OrderedCartItem</code>、<code>OrderItem</code>。乍看是重複建模
<strong>疑問來源</strong>：四個 model 是過度設計、還是各有不可合併的職責？如果是後者，拆分的判準是什麼？
<strong>整理目的</strong>：把「同一個業務概念何時該拆 model、value object 何時升級成 entity」的判斷邊界記下來
<strong>本文邊界</strong>：素材是一個 Flutter POS App 的現行實作；「四個」是這個 domain 的結果、不是通用配方——判準才是可遷移的部分</p></blockquote>
<hr>
<h2 id="四個-model-各在哪個生命週期">四個 model 各在哪個生命週期</h2>
<p>一個商品從「使用者點選」到「進了歷史訂單」，這個專案用四個 model 接力表達：</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>生命週期階段</th>
          <th>同一性的依據</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>CartItem</code></td>
          <td>點餐輸入、還沒送出</td>
          <td>內容比對（spec + 折扣 + 口味集合）</td>
      </tr>
      <tr>
          <td><code>ShoppingCartDetail</code></td>
          <td>掛單系統接受後的後端實體</td>
          <td>後端 <code>detail.id</code></td>
      </tr>
      <tr>
          <td><code>OrderedCartItem</code></td>
          <td>結帳畫面上的一筆訂單行</td>
          <td><code>sourceDetailIds</code>（摺疊多筆 detail）</td>
      </tr>
      <tr>
          <td><code>OrderItem</code></td>
          <td>結完帳的歷史訂單明細</td>
          <td><code>detailId</code> + 全欄位 snapshot</td>
      </tr>
  </tbody>
</table>
<p>每一次交棒都對應一個身份狀態的變化，這是四個 model 不可合併的原因。</p>
<h2 id="階段一cartitem-是純需求描述沒有-id">階段一：CartItem 是純需求描述、沒有 id</h2>
<p><code>CartItem</code> 表達「使用者想要什麼」：商品、規格、數量、折扣、口味。它沒有任何 id 欄位——兩個 <code>CartItem</code> 是不是同一項，靠 <code>isSameItem()</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="kt">bool</span> <span class="n">isSameItem</span><span class="p">(</span><span class="n">CartItem</span> <span class="n">other</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">specification</span><span class="p">.</span><span class="n">id</span> <span class="o">!=</span> <span class="n">other</span><span class="p">.</span><span class="n">specification</span><span class="p">.</span><span class="n">id</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">discount</span> <span class="o">!=</span> <span class="n">other</span><span class="p">.</span><span class="n">discount</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</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="c1">// 口味集合比對（不考慮順序）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="p">...</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這是 value object 的語意：<strong>內容相等就是同一個</strong>。合併購物車（<code>mergeItems</code>）靠這個判定把相同品項的數量累加。值得留意折扣也參與同一性判定——改過價的品項是不同的訂單行，這是業務規則直接寫進相等性定義的例子。</p>
<h2 id="階段二掛單接受的那一刻identity-誕生">階段二：掛單接受的那一刻、identity 誕生</h2>
<p>需求被掛單系統接受、寫進 <code>ShoppingCart.details</code> 之後，每筆明細獲得了後端身份 <code>detail.id</code>。model 的原始註解把這個轉折講得很清楚：</p>
<blockquote>
<p>一旦這個需求被掛單系統接受、寫進 details，它就獲得了後端身份（detail.id），從這刻起在前端應以 OrderedCartItem 表達——客人加點同一項三次，邏輯上是一筆訂單行（一個 OrderedCartItem），實體上是三筆 detail。</p></blockquote>
<p><code>OrderedCartItem</code> 的結構只有兩個欄位：<code>cartItem</code>（內容）加 <code>sourceDetailIds</code>（身份）。它存在的理由是<strong>操作需要精確回寫</strong>：改數量、單品取消、單品改價，都必須映射回後端要修改的那幾筆 detail。內容比對在這裡不夠用——同商品同口味的三筆 detail 內容完全相同，取消其中一筆時內容比對無法指定是哪一筆。</p>
<p>購物車 model 上有一段對應的契約註解：UI 顯示的列表經過合併與過濾，「UI 列表的 index 跟 details 的 index 不是同一個東西」，任何 UI 到後端 detail 的操作都要透過 <code>sourceDetailIds</code> 做 id-based 比對。用 index 對應兩個列表是這個結構下最容易踩的錯誤路徑，契約直接把它寫死在文件裡。</p>
<h2 id="階段三結完帳參照凍結成-snapshot">階段三：結完帳、參照凍結成 snapshot</h2>
<p><code>OrderItem</code> 是結帳完成後的歷史事實。它跟 <code>CartItem</code> 的關鍵差異是參照的凍結：</p>
<ul>
<li><code>CartItem</code> 持有 live 的 <code>Product</code> 參照，價格即時查當前規格（會員身分變了、價格跟著變）</li>
<li><code>OrderItem</code> 保存 <code>OrderDetailProduct</code> / <code>OrderDetailProductSpecification</code> 的 snapshot，註解明說「即使後續商品改名/下架，訂單仍顯示當時購買的內容」；<code>unitPrice</code> 也在 <code>fromResponse</code> 時依當時的會員身分擇一凍結</li>
</ul>
<p><code>detailId</code> 在這個階段承擔新職責：退貨與取消 API 的鍵、以及同訂單中區分「同商品不同口味」的唯一鍵。</p>
<h2 id="判準操作需不需要-identity-based-回寫">判準：操作需不需要 identity-based 回寫</h2>
<p>把三次交棒放在一起看，「value object 什麼時候該升級成 entity」的答案就浮出來了。判準是<strong>對這個物件的操作，需不需要精確指到某一個實體</strong>——概念重不重要、有沒有 id 欄位可以填，都不參與這個判斷。</p>
<ul>
<li>需求描述階段：操作是「加一份」「換口味」，內容相等就是同一個，value object 的內容比對足夠</li>
<li>進入外部系統之後：操作是「取消那一筆」「改那一筆的量」，必須 identity-based 回寫，此時需要 entity（或至少像 <code>OrderedCartItem</code> 這樣持有身份參照的包裝）</li>
<li>成為歷史事實之後：操作只剩查閱與退貨，連 live 參照都要凍結成 snapshot——歷史不隨現在的資料變動</li>
</ul>
<p>反過來看單一 model 通吃的代價：改量操作靠內容比對會誤中同內容的其他筆；歷史訂單持 live 參照會跟著商品改名漂移。四個 model 不是重複，是身份語意在三個轉折點上真的變了。</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/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>（凍結作為稽核端點的教學層展開）</li>
<li>同專案的 snapshot 對照組：entity 稽核軌跡的洞（<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>）——那篇談變更路徑的完整性，本文談身份與參照的凍結時機，兩者合起來是「歷史事實怎麼被保護」的兩個面</li>
</ul>
]]></content:encoded></item><item><title>兩個 ImportResult 各自都合理 — 傘狀名的碰撞與做一半的重命名</title><link>https://tarrragon.github.io/blog/work-log/flutter_import_result_name_collision/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_import_result_name_collision/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 import domain 介面盤點，發現兩個同名類別並存：&lt;code>value_objects/import_result.dart&lt;/code> 跟 &lt;code>models/import_result.dart&lt;/code>——都叫 &lt;code>ImportResult&lt;/code>、欄位完全不同、引用時全憑 import 路徑分辨
&lt;strong>疑問來源&lt;/strong>：兩個類別各自看都命名合理，撞名是怎麼發生的？重命名之後的 Phase 4 稽核又抓到什麼？
&lt;strong>整理目的&lt;/strong>：記下傘狀名的碰撞機制、依職責命名的修法、以及「重命名是原子操作組」的教訓
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.12.1 的介面盤點與 Phase 4 重構稽核報告&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="碰撞兩個匯入結果各自誕生都合理">碰撞：兩個「匯入結果」、各自誕生都合理&lt;/h2>
&lt;p>攤開兩個類別的內容，撞名的成因就清楚了：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案&lt;/th>
 &lt;th>欄位&lt;/th>
 &lt;th>它其實是什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>value_objects/import_result.dart&lt;/code>&lt;/td>
 &lt;td>&lt;code>isValid&lt;/code>、&lt;code>books&lt;/code>、&lt;code>errors&lt;/code>&lt;/td>
 &lt;td>&lt;strong>JSON 驗證&lt;/strong>的結果&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>models/import_result.dart&lt;/code>&lt;/td>
 &lt;td>&lt;code>isSuccess&lt;/code>、&lt;code>successfulBooks&lt;/code>、&lt;code>failedItems&lt;/code>、&lt;code>processingTimeMs&lt;/code>、&lt;code>peakMemoryMB&lt;/code>&lt;/td>
 &lt;td>&lt;strong>整個匯入操作&lt;/strong>的結果&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>寫驗證邏輯的人需要一個型別裝驗證結果——「這是匯入流程的結果」、叫 &lt;code>ImportResult&lt;/code>，合理；寫匯入執行的人需要一個型別裝執行結果——同樣的推理、同樣的名字。&lt;strong>「Result」是傘狀詞&lt;/strong>：它只說「某個東西的結果」、不說是哪個環節的，同一個 domain 裡任何階段的產出都有資格用它，於是第二個使用者出現時必然碰撞。碰撞的代價由所有讀者付：每次看到 &lt;code>ImportResult&lt;/code> 都要先看 import 路徑才知道在讀哪一個，而 IDE 自動匯入選錯路徑的錯誤、型別又剛好對不上時的錯誤訊息（「ImportResult 不是 ImportResult」）尤其折磨。&lt;/p>
&lt;h2 id="修法名字要能回答什麼操作的結果">修法：名字要能回答「什麼操作的結果」&lt;/h2>
&lt;p>決策保留 models 版當主要的 &lt;code>ImportResult&lt;/code>（它代表整個 use case 的產出、消費者最多），value object 版重命名為 &lt;code>ImportValidationResult&lt;/code>——名字補上了它缺的那一節：&lt;strong>驗證&lt;/strong>的結果。判準可以一般化：result / info / data / manager 這類傘狀名，掛上去之前先問「它是&lt;strong>哪個操作&lt;/strong>的 result」——答案就是名字該有的樣子。兩個同名類別並存時的診斷同理：先問哪一個的名字說謊了（通常是語意較窄的那個佔了寬名字）、改窄的那個。&lt;/p>
&lt;p>這跟&lt;a href="https://tarrragon.github.io/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">分層 enum&lt;/a> 的粒度判準是同一族：名字的顆粒度要配得上它指涉範圍的顆粒度，佔著寬名字的窄概念是碰撞的定時炸彈。&lt;/p>
&lt;h2 id="稽核抓到的做一半的重命名以及宣稱的漂移">稽核抓到的：做一半的重命名、以及宣稱的漂移&lt;/h2>
&lt;p>Phase 4 重構稽核在「已完成」的重命名上抓到殘局：類別名確實改成了 &lt;code>ImportValidationResult&lt;/code>——但&lt;strong>檔名還是 &lt;code>import_result.dart&lt;/code>&lt;/strong>。而且工作日誌宣稱檔案已重命名為 &lt;code>import_validation_result.dart&lt;/code>、與現實不符。&lt;/p>
&lt;p>三層漂移疊在一起：類別名（改了）、檔名（沒改）、文件宣稱（說改了）。做一半的重命名比不做更迷惑——現在檔名對讀者說「這裡是 ImportResult」、打開來是另一個名字，Dart 的「檔名對應主類別名」慣例反過來變成誤導。教訓收成兩條：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>重命名是一組原子操作&lt;/strong>：類別名、檔名、所有 import 路徑、測試引用、文件宣稱——清單上每一項都做完才算完成，IDE 的 rename 重構通常只保證前三項、檔名跟文件是人的責任&lt;/li>
&lt;li>&lt;strong>宣稱完成與實際完成是兩個 fact&lt;/strong>：工作日誌寫「已重命名」的當下可能是計畫、可能是部分完成——下游讀者無從分辨。這正是 Phase 4 稽核這類「驗收與執行分離」流程存在的理由，同構於 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_migration_read_path_gap_fake_green/" data-link-title="遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠" data-link-desc="資料模型遷移的通路要三段齊：寫入 backfill、讀取路徑、消費端 API。缺讀出那段時，新 API 拿到的永遠是空集合——而消費端測試的 fixture 自己建物件、不走真實讀取路徑，測試全綠掩蓋 runtime 靜默失效。依賴圖只列「誰先做」不列語意前提時，dashboard 的 ready 是假訊號。">read-path 分析&lt;/a>的獨立重驗紀律&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>同一個 domain 裡 grep 到兩個同名類別——先判哪個名字說謊（語意窄的佔寬名）、改窄者&lt;/li>
&lt;li>類別名含 Result / Info / Data / Manager 而前綴不含操作名——傘狀名候選，下一個同 domain 的產出型別就會撞上來&lt;/li>
&lt;li>檔名與主類別名不一致——半完成重命名的化石，補完或回退、別放著&lt;/li>
&lt;li>工作記錄宣稱的檔案狀態與 codebase 不符——把「宣稱」降級為線索、以 grep 結果為準&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>命名顆粒度的同族：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類&lt;/a>——名字與指涉範圍的顆粒度要相配&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/semantic-anchor-single-string/" data-link-title="語意錨用單一字串、同義雙名讓引用修復退回人腦對應" data-link-desc="引用錨在語意標題之後、語意名稱本身要是單一字串。同一個結構單位有兩個同義名稱（標題寫「決策記錄 &amp;#43; scaffold 建議」、引用寫「決策收斂階段」）時、語意引用的兩個核心收益同時失效：grep 要掃兩套 pattern 才完整（漏配置一個就漏一半引用點）、重排時的引用修復回到人腦對應。是 #155 引用端、#156 命名端之後的第三塊：命名唯一性。">#157 語意錨用單一字串&lt;/a>——同語意雙字串與同字串雙語意是一體兩面的引用災難；&lt;a href="https://tarrragon.github.io/blog/report/naming-as-iterated-artifact/" data-link-title="Naming 是 iterated artifact：第一個名字幾乎不對、四輪 review 才收斂" data-link-desc="命名（變數 / 函式 / 檔名 / slug / API endpoint）幾乎沒有「一次寫對」的可能：第一個名字基於當下狹窄的 context、會在後續 cross-call-site / grep / 重構中暴露錯位。命名的正確設計是 iterated — 寫第一版 → grep-ability 測試 → cross-call-site 一致性 → impl 洩漏 → 重命名。本卡是 #83 在「命名」場景的特化。">#84 Naming 是 iterated artifact&lt;/a>——第一版命名幾乎不對、cross-call-site 檢驗才收斂&lt;/li>
&lt;li>宣稱與實際的分離：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_migration_read_path_gap_fake_green/" data-link-title="遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠" data-link-desc="資料模型遷移的通路要三段齊：寫入 backfill、讀取路徑、消費端 API。缺讀出那段時，新 API 拿到的永遠是空集合——而消費端測試的 fixture 自己建物件、不走真實讀取路徑，測試全綠掩蓋 runtime 靜默失效。依賴圖只列「誰先做」不列語意前提時，dashboard 的 ready 是假訊號。">遷移計畫有寫入、有消費、缺讀出&lt;/a>——獨立重驗的紀律&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 import domain 介面盤點，發現兩個同名類別並存：<code>value_objects/import_result.dart</code> 跟 <code>models/import_result.dart</code>——都叫 <code>ImportResult</code>、欄位完全不同、引用時全憑 import 路徑分辨
<strong>疑問來源</strong>：兩個類別各自看都命名合理，撞名是怎麼發生的？重命名之後的 Phase 4 稽核又抓到什麼？
<strong>整理目的</strong>：記下傘狀名的碰撞機制、依職責命名的修法、以及「重命名是原子操作組」的教訓
<strong>本文邊界</strong>：素材是該專案 v0.12.1 的介面盤點與 Phase 4 重構稽核報告</p></blockquote>
<hr>
<h2 id="碰撞兩個匯入結果各自誕生都合理">碰撞：兩個「匯入結果」、各自誕生都合理</h2>
<p>攤開兩個類別的內容，撞名的成因就清楚了：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>欄位</th>
          <th>它其實是什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>value_objects/import_result.dart</code></td>
          <td><code>isValid</code>、<code>books</code>、<code>errors</code></td>
          <td><strong>JSON 驗證</strong>的結果</td>
      </tr>
      <tr>
          <td><code>models/import_result.dart</code></td>
          <td><code>isSuccess</code>、<code>successfulBooks</code>、<code>failedItems</code>、<code>processingTimeMs</code>、<code>peakMemoryMB</code></td>
          <td><strong>整個匯入操作</strong>的結果</td>
      </tr>
  </tbody>
</table>
<p>寫驗證邏輯的人需要一個型別裝驗證結果——「這是匯入流程的結果」、叫 <code>ImportResult</code>，合理；寫匯入執行的人需要一個型別裝執行結果——同樣的推理、同樣的名字。<strong>「Result」是傘狀詞</strong>：它只說「某個東西的結果」、不說是哪個環節的，同一個 domain 裡任何階段的產出都有資格用它，於是第二個使用者出現時必然碰撞。碰撞的代價由所有讀者付：每次看到 <code>ImportResult</code> 都要先看 import 路徑才知道在讀哪一個，而 IDE 自動匯入選錯路徑的錯誤、型別又剛好對不上時的錯誤訊息（「ImportResult 不是 ImportResult」）尤其折磨。</p>
<h2 id="修法名字要能回答什麼操作的結果">修法：名字要能回答「什麼操作的結果」</h2>
<p>決策保留 models 版當主要的 <code>ImportResult</code>（它代表整個 use case 的產出、消費者最多），value object 版重命名為 <code>ImportValidationResult</code>——名字補上了它缺的那一節：<strong>驗證</strong>的結果。判準可以一般化：result / info / data / manager 這類傘狀名，掛上去之前先問「它是<strong>哪個操作</strong>的 result」——答案就是名字該有的樣子。兩個同名類別並存時的診斷同理：先問哪一個的名字說謊了（通常是語意較窄的那個佔了寬名字）、改窄的那個。</p>
<p>這跟<a href="/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">分層 enum</a> 的粒度判準是同一族：名字的顆粒度要配得上它指涉範圍的顆粒度，佔著寬名字的窄概念是碰撞的定時炸彈。</p>
<h2 id="稽核抓到的做一半的重命名以及宣稱的漂移">稽核抓到的：做一半的重命名、以及宣稱的漂移</h2>
<p>Phase 4 重構稽核在「已完成」的重命名上抓到殘局：類別名確實改成了 <code>ImportValidationResult</code>——但<strong>檔名還是 <code>import_result.dart</code></strong>。而且工作日誌宣稱檔案已重命名為 <code>import_validation_result.dart</code>、與現實不符。</p>
<p>三層漂移疊在一起：類別名（改了）、檔名（沒改）、文件宣稱（說改了）。做一半的重命名比不做更迷惑——現在檔名對讀者說「這裡是 ImportResult」、打開來是另一個名字，Dart 的「檔名對應主類別名」慣例反過來變成誤導。教訓收成兩條：</p>
<ul>
<li><strong>重命名是一組原子操作</strong>：類別名、檔名、所有 import 路徑、測試引用、文件宣稱——清單上每一項都做完才算完成，IDE 的 rename 重構通常只保證前三項、檔名跟文件是人的責任</li>
<li><strong>宣稱完成與實際完成是兩個 fact</strong>：工作日誌寫「已重命名」的當下可能是計畫、可能是部分完成——下游讀者無從分辨。這正是 Phase 4 稽核這類「驗收與執行分離」流程存在的理由，同構於 <a href="/blog/work-log/flutter_migration_read_path_gap_fake_green/" data-link-title="遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠" data-link-desc="資料模型遷移的通路要三段齊：寫入 backfill、讀取路徑、消費端 API。缺讀出那段時，新 API 拿到的永遠是空集合——而消費端測試的 fixture 自己建物件、不走真實讀取路徑，測試全綠掩蓋 runtime 靜默失效。依賴圖只列「誰先做」不列語意前提時，dashboard 的 ready 是假訊號。">read-path 分析</a>的獨立重驗紀律</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>同一個 domain 裡 grep 到兩個同名類別——先判哪個名字說謊（語意窄的佔寬名）、改窄者</li>
<li>類別名含 Result / Info / Data / Manager 而前綴不含操作名——傘狀名候選，下一個同 domain 的產出型別就會撞上來</li>
<li>檔名與主類別名不一致——半完成重命名的化石，補完或回退、別放著</li>
<li>工作記錄宣稱的檔案狀態與 codebase 不符——把「宣稱」降級為線索、以 grep 結果為準</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>命名顆粒度的同族：<a href="/blog/work-log/dart_payment_dual_layer_enum/" data-link-title="16 種支付渠道、4 種行為分類 — 分層 enum：保真層與行為層的粒度分工" data-link-desc="同一個分類系統要同時服務序列化（要無損）跟 UI 行為分流（要粗粒度）時，單一 enum 選哪個粒度都錯。解法是分層：保真層無損對齊後端完整列舉、行為層收斂成行為真正分歧的少數大類、層間用 exhaustive switch 衍生——粒度轉換獲得編譯期保證。">16 種支付渠道、4 種行為分類</a>——名字與指涉範圍的顆粒度要相配</li>
<li>原則層：<a href="/blog/report/semantic-anchor-single-string/" data-link-title="語意錨用單一字串、同義雙名讓引用修復退回人腦對應" data-link-desc="引用錨在語意標題之後、語意名稱本身要是單一字串。同一個結構單位有兩個同義名稱（標題寫「決策記錄 &#43; scaffold 建議」、引用寫「決策收斂階段」）時、語意引用的兩個核心收益同時失效：grep 要掃兩套 pattern 才完整（漏配置一個就漏一半引用點）、重排時的引用修復回到人腦對應。是 #155 引用端、#156 命名端之後的第三塊：命名唯一性。">#157 語意錨用單一字串</a>——同語意雙字串與同字串雙語意是一體兩面的引用災難；<a href="/blog/report/naming-as-iterated-artifact/" data-link-title="Naming 是 iterated artifact：第一個名字幾乎不對、四輪 review 才收斂" data-link-desc="命名（變數 / 函式 / 檔名 / slug / API endpoint）幾乎沒有「一次寫對」的可能：第一個名字基於當下狹窄的 context、會在後續 cross-call-site / grep / 重構中暴露錯位。命名的正確設計是 iterated — 寫第一版 → grep-ability 測試 → cross-call-site 一致性 → impl 洩漏 → 重命名。本卡是 #83 在「命名」場景的特化。">#84 Naming 是 iterated artifact</a>——第一版命名幾乎不對、cross-call-site 檢驗才收斂</li>
<li>宣稱與實際的分離：<a href="/blog/work-log/flutter_migration_read_path_gap_fake_green/" data-link-title="遷移計畫有寫入、有消費、缺讀出 — read-path 缺口與 fixture 假綠" data-link-desc="資料模型遷移的通路要三段齊：寫入 backfill、讀取路徑、消費端 API。缺讀出那段時，新 API 拿到的永遠是空集合——而消費端測試的 fixture 自己建物件、不走真實讀取路徑，測試全綠掩蓋 runtime 靜默失效。依賴圖只列「誰先做」不列語意前提時，dashboard 的 ready 是假訊號。">遷移計畫有寫入、有消費、缺讀出</a>——獨立重驗的紀律</li>
</ul>
]]></content:encoded></item><item><title>取個原始值有四種寫法 — VO 的 toString 洩漏與 accessor 不一致</title><link>https://tarrragon.github.io/blog/work-log/flutter_vo_tostring_leak_accessor_inconsistency/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_vo_tostring_leak_accessor_inconsistency/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 引入 value object 之後，估計 100+ 個測試失敗——&lt;code>expect(book.title, '測試書籍')&lt;/code> 這類斷言全數過期，因為 &lt;code>book.title&lt;/code> 現在是 &lt;code>BookTitle&lt;/code>、它的 &lt;code>toString()&lt;/code> 回傳 &lt;code>BookTitle:&amp;lt;測試書籍&amp;gt;&lt;/code>
&lt;strong>疑問來源&lt;/strong>：改斷言就好？改成什麼——&lt;code>.value&lt;/code>、&lt;code>.displayValue&lt;/code>、還是 &lt;code>toString()&lt;/code>？追下去發現這個問題本身就是病灶
&lt;strong>整理目的&lt;/strong>：記下 VO 取值 accessor 不一致的兩層傷害、以及「集中取值知識」的止血法
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.11.15 的測試修復計畫與執行記錄；accessor 混亂的成因（&lt;code>.value&lt;/code> 的存廢擺盪）在&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">另一篇&lt;/a>有完整弧線&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="第一層傷害tostring-洩漏讓裸字串斷言靜默過期">第一層傷害：toString 洩漏讓裸字串斷言靜默過期&lt;/h2>
&lt;p>欄位從 &lt;code>String&lt;/code> 升級成 value object 之後，型別變了、但舊斷言不會編譯失敗——&lt;code>expect(actual, expected)&lt;/code> 的參數是 dynamic，&lt;code>BookTitle&lt;/code> 跟 &lt;code>'測試書籍'&lt;/code> 的比較合法地在 runtime 回 false。於是 VO 引入前累積的所有裸字串斷言，以「測試失敗」而不是「編譯錯誤」的形式集體過期，一次 100+ 個。&lt;/p>
&lt;p>自定義 &lt;code>toString()&lt;/code>（&lt;code>BookTitle:&amp;lt;測試書籍&amp;gt;&lt;/code> 這種帶型別前綴的除錯格式）讓失敗訊息更迷惑：期望值跟實際值看起來「幾乎一樣」，差一層包裝。這是 toString 語意寄生的又一個現場——它的本業是除錯表示，被斷言、被序列化、被快取 key 借用時，每個借用者都對它的格式有隱性依賴。&lt;/p>
&lt;h2 id="第二層傷害四種取法連修復者都寫錯">第二層傷害：四種取法、連修復者都寫錯&lt;/h2>
&lt;p>修復計畫的第一版推薦「改用 &lt;code>.value&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="n">expect&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">title&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;測試書籍&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">修復計畫推薦的寫法&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行階段發現這個推薦本身是錯的——實際的 accessor 分佈是：&lt;code>BookTitle&lt;/code> 跟 &lt;code>BookAuthor&lt;/code> 用 &lt;code>.displayValue&lt;/code>、&lt;code>BookId&lt;/code> 只能 &lt;code>toString()&lt;/code>、&lt;code>isbn&lt;/code> 根本是裸 &lt;code>String?&lt;/code> 不用取值。&lt;strong>「取原始值」在四個相鄰型別上是四種寫法&lt;/strong>，連專門來修這個問題的人都先踩了一次。&lt;/p>
&lt;p>這是介面不一致的可測量代價：API 的使用知識無法從一個型別遷移到下一個，每次使用都是一次查閱或一次賭注。而這個混亂不是誰設計出來的——它是 &lt;code>.value&lt;/code> 存廢擺盪的沉積物：封裝重構移除了 &lt;code>.value&lt;/code>、留下 &lt;code>toString()&lt;/code> 跟 &lt;code>displayValue&lt;/code> 兩個出口、緊急修復又給部分型別加回 &lt;code>.value&lt;/code>，幾輪下來每個 VO 停在擺盪的不同相位上。&lt;/p>
&lt;h2 id="止血helper-函數庫集中取值知識">止血：helper 函數庫集中取值知識&lt;/h2>
&lt;p>逐個改 100+ 斷言的過程中，策略從「批次改寫」轉向「建輔助函數庫」：&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">// test/helpers/value_object_test_helpers.dart
&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">expectBookTitleEquals&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">title&lt;/span>&lt;span class="p">,&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">3&lt;/span>&lt;span class="cl">&lt;span class="n">expectBookAuthorEquals&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">author&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;作者名&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>helper 把「每個 VO 怎麼取原始值」的知識&lt;strong>集中在一個檔案&lt;/strong>：斷言的呼叫端不再需要知道 &lt;code>BookTitle&lt;/code> 用 &lt;code>displayValue&lt;/code> 而 &lt;code>BookId&lt;/code> 用 &lt;code>toString()&lt;/code>——它們長得一樣、內部各自處理差異。附帶的複利是未來 accessor 再變動（例如統一成單一名字）時，要改的位置從 100+ 個斷言縮成一個 helper 檔。&lt;/p>
&lt;p>要分清楚的是：helper 是&lt;strong>止血、不是根治&lt;/strong>。它讓不一致的 API 可以被一致地使用，但不一致本身還在——lib/ 端的每個新消費者仍會面對四種取法。根治是家族統一 accessor（同名、同語意、同回傳型別），而那要先解決擺盪篇談的「出口語意」問題：先決定每個出口給誰用，名字才定得下來。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>型別升級（String → VO）後測試大量失敗、失敗訊息的期望與實際「差一層包裝」——裸值斷言過期，別逐個修、先決定統一的比較方式&lt;/li>
&lt;li>同一家族的型別、取原始值的寫法超過一種——API 知識不可遷移，每個消費者都在重新學&lt;/li>
&lt;li>修復計畫裡寫的 accessor 跟實作對不上——不一致已經騙到文件層了，這是「先盤點再動手」的訊號&lt;/li>
&lt;li>測試 helper 裡出現 per-type 的比較函數——止血有效，但把「統一 accessor」記進債務清單，別讓 helper 的存在掩蓋根治的必要&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>成因的完整弧線：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪&lt;/a>——四種取法是全移除、完全封裝、加回 getter 幾輪擺盪的沉積物&lt;/li>
&lt;li>toString 語意寄生的另一個現場：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_sqlite_value_object_serialization_boundary/" data-link-title="SQLite 只吃三種型別 — value object 在持久化邊界的序列化契約" data-link-desc="把 value object 直接塞給 sqflite 會炸 Invalid argument——SQLite 只接受 num / String / Uint8List，VO 必須在 repository 邊界拆成基本型別、讀回時重建。用 toString/fromString 當轉換通道是權宜：它依賴兩者對稱這條沒人強制的隱性契約，正解是語意明確的序列化方法。">SQLite 只吃三種型別&lt;/a>——除錯表示被借去當持久化通道&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——value object 的介面也是模組的公開契約、家族一致性是契約的一部分&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 引入 value object 之後，估計 100+ 個測試失敗——<code>expect(book.title, '測試書籍')</code> 這類斷言全數過期，因為 <code>book.title</code> 現在是 <code>BookTitle</code>、它的 <code>toString()</code> 回傳 <code>BookTitle:&lt;測試書籍&gt;</code>
<strong>疑問來源</strong>：改斷言就好？改成什麼——<code>.value</code>、<code>.displayValue</code>、還是 <code>toString()</code>？追下去發現這個問題本身就是病灶
<strong>整理目的</strong>：記下 VO 取值 accessor 不一致的兩層傷害、以及「集中取值知識」的止血法
<strong>本文邊界</strong>：素材是該專案 v0.11.15 的測試修復計畫與執行記錄；accessor 混亂的成因（<code>.value</code> 的存廢擺盪）在<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">另一篇</a>有完整弧線</p></blockquote>
<hr>
<h2 id="第一層傷害tostring-洩漏讓裸字串斷言靜默過期">第一層傷害：toString 洩漏讓裸字串斷言靜默過期</h2>
<p>欄位從 <code>String</code> 升級成 value object 之後，型別變了、但舊斷言不會編譯失敗——<code>expect(actual, expected)</code> 的參數是 dynamic，<code>BookTitle</code> 跟 <code>'測試書籍'</code> 的比較合法地在 runtime 回 false。於是 VO 引入前累積的所有裸字串斷言，以「測試失敗」而不是「編譯錯誤」的形式集體過期，一次 100+ 個。</p>
<p>自定義 <code>toString()</code>（<code>BookTitle:&lt;測試書籍&gt;</code> 這種帶型別前綴的除錯格式）讓失敗訊息更迷惑：期望值跟實際值看起來「幾乎一樣」，差一層包裝。這是 toString 語意寄生的又一個現場——它的本業是除錯表示，被斷言、被序列化、被快取 key 借用時，每個借用者都對它的格式有隱性依賴。</p>
<h2 id="第二層傷害四種取法連修復者都寫錯">第二層傷害：四種取法、連修復者都寫錯</h2>
<p>修復計畫的第一版推薦「改用 <code>.value</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="n">expect</span><span class="p">(</span><span class="n">book</span><span class="p">.</span><span class="n">title</span><span class="p">.</span><span class="n">value</span><span class="p">,</span> <span class="s1">&#39;測試書籍&#39;</span><span class="p">);</span>   <span class="o">//</span> <span class="err">修復計畫推薦的寫法</span></span></span></code></pre></div><p>執行階段發現這個推薦本身是錯的——實際的 accessor 分佈是：<code>BookTitle</code> 跟 <code>BookAuthor</code> 用 <code>.displayValue</code>、<code>BookId</code> 只能 <code>toString()</code>、<code>isbn</code> 根本是裸 <code>String?</code> 不用取值。<strong>「取原始值」在四個相鄰型別上是四種寫法</strong>，連專門來修這個問題的人都先踩了一次。</p>
<p>這是介面不一致的可測量代價：API 的使用知識無法從一個型別遷移到下一個，每次使用都是一次查閱或一次賭注。而這個混亂不是誰設計出來的——它是 <code>.value</code> 存廢擺盪的沉積物：封裝重構移除了 <code>.value</code>、留下 <code>toString()</code> 跟 <code>displayValue</code> 兩個出口、緊急修復又給部分型別加回 <code>.value</code>，幾輪下來每個 VO 停在擺盪的不同相位上。</p>
<h2 id="止血helper-函數庫集中取值知識">止血：helper 函數庫集中取值知識</h2>
<p>逐個改 100+ 斷言的過程中，策略從「批次改寫」轉向「建輔助函數庫」：</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">// test/helpers/value_object_test_helpers.dart
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">expectBookTitleEquals</span><span class="p">(</span><span class="n">book</span><span class="p">.</span><span class="n">title</span><span class="p">,</span> <span class="s1">&#39;測試書籍&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">expectBookAuthorEquals</span><span class="p">(</span><span class="n">book</span><span class="p">.</span><span class="n">author</span><span class="p">,</span> <span class="s1">&#39;作者名&#39;</span><span class="p">);</span></span></span></code></pre></div><p>helper 把「每個 VO 怎麼取原始值」的知識<strong>集中在一個檔案</strong>：斷言的呼叫端不再需要知道 <code>BookTitle</code> 用 <code>displayValue</code> 而 <code>BookId</code> 用 <code>toString()</code>——它們長得一樣、內部各自處理差異。附帶的複利是未來 accessor 再變動（例如統一成單一名字）時，要改的位置從 100+ 個斷言縮成一個 helper 檔。</p>
<p>要分清楚的是：helper 是<strong>止血、不是根治</strong>。它讓不一致的 API 可以被一致地使用，但不一致本身還在——lib/ 端的每個新消費者仍會面對四種取法。根治是家族統一 accessor（同名、同語意、同回傳型別），而那要先解決擺盪篇談的「出口語意」問題：先決定每個出口給誰用，名字才定得下來。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>型別升級（String → VO）後測試大量失敗、失敗訊息的期望與實際「差一層包裝」——裸值斷言過期，別逐個修、先決定統一的比較方式</li>
<li>同一家族的型別、取原始值的寫法超過一種——API 知識不可遷移，每個消費者都在重新學</li>
<li>修復計畫裡寫的 accessor 跟實作對不上——不一致已經騙到文件層了，這是「先盤點再動手」的訊號</li>
<li>測試 helper 裡出現 per-type 的比較函數——止血有效，但把「統一 accessor」記進債務清單，別讓 helper 的存在掩蓋根治的必要</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>成因的完整弧線：<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 封裝擺盪</a>——四種取法是全移除、完全封裝、加回 getter 幾輪擺盪的沉積物</li>
<li>toString 語意寄生的另一個現場：<a href="/blog/work-log/flutter_sqlite_value_object_serialization_boundary/" data-link-title="SQLite 只吃三種型別 — value object 在持久化邊界的序列化契約" data-link-desc="把 value object 直接塞給 sqflite 會炸 Invalid argument——SQLite 只接受 num / String / Uint8List，VO 必須在 repository 邊界拆成基本型別、讀回時重建。用 toString/fromString 當轉換通道是權宜：它依賴兩者對稱這條沒人強制的隱性契約，正解是語意明確的序列化方法。">SQLite 只吃三種型別</a>——除錯表示被借去當持久化通道</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——value object 的介面也是模組的公開契約、家族一致性是契約的一部分</li>
</ul>
]]></content:encoded></item><item><title>金額型別的三段遷移：double、Decimal、再到 Money extension type</title><link>https://tarrragon.github.io/blog/work-log/dart_money_extension_type_migration/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_money_extension_type_migration/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：整理 POS 專案的金額處理時，發現 git 歷史上金額型別換過兩次——&lt;code>double&lt;/code> 到 &lt;code>Decimal&lt;/code>、再從 &lt;code>Decimal&lt;/code> 到自訂的 &lt;code>Money&lt;/code>。第二次遷移乍看多餘：精度問題 &lt;code>Decimal&lt;/code> 已經解掉了
&lt;strong>疑問來源&lt;/strong>：&lt;code>Decimal&lt;/code> 哪裡不夠？第二次遷移買到的是什麼？
&lt;strong>整理目的&lt;/strong>：記下「精度」跟「語意」是金額型別的兩個獨立問題、以及 Dart extension type 在第二個問題上的實作手法
&lt;strong>本文邊界&lt;/strong>：以該專案的實際遷移軌跡為素材；extension type 是 Dart 3 的機制、其他語言的對應手法（newtype / value class）思路相同但細節不同&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="第一段double-的精度問題">第一段：double 的精度問題&lt;/h2>
&lt;p>最初所有金額欄位是 &lt;code>double&lt;/code>，JSON 轉換器把後端的 number 或 string 統一存成 &lt;code>double&lt;/code>。浮點數處理金額的問題是經典的：&lt;code>0.1 + 0.2 != 0.3&lt;/code>，累加訂單明細時誤差會累積到分位。&lt;/p>
&lt;p>第一次遷移（單一 PR 內兩個 commit）把所有 model 的金額欄位換成 &lt;code>Decimal&lt;/code>，API 接入層用 &lt;code>jsonToDecimal&lt;/code> 統一處理後端可能回 number 或 string 的格式差異。精度問題到此解決。&lt;/p>
&lt;h2 id="第二段decimal-解決了精度沒解決語意">第二段：Decimal 解決了精度、沒解決語意&lt;/h2>
&lt;p>換完 &lt;code>Decimal&lt;/code> 之後，金額仍然是一個&lt;strong>裸的通用數字型別&lt;/strong>。任何拿到 &lt;code>Decimal&lt;/code> 的程式碼都能對它做任意運算：兩個金額相乘（語意上不存在的運算）、金額跟折扣率直接相加、拿金額當數量用。型別系統對這些錯誤全部放行，因為它們在 &lt;code>Decimal&lt;/code> 的世界都是合法運算。&lt;/p>
&lt;p>這是 primitive obsession 的標準形態：值的表示對了、值的&lt;strong>語意邊界&lt;/strong>還是沒有。三個月後的第二次遷移把金額包進 &lt;code>Money&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="n">extension&lt;/span> &lt;span class="n">type&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">Money&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Decimal&lt;/span> &lt;span class="n">_raw&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="kt">Object&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="n">Money&lt;/span> &lt;span class="kd">operator&lt;/span> &lt;span class="o">+&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Money&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Money&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_raw&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_raw&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="n">Money&lt;/span> &lt;span class="kd">operator&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Money&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Money&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_raw&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">other&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_raw&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="n">Money&lt;/span> &lt;span class="kd">operator&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Money&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">_raw&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">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="kd">operator&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">quantity&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&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">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="n">multiplyByRate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Decimal&lt;/span> &lt;span class="n">rate&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&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">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="n">clamp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Money&lt;/span> &lt;span class="n">min&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="n">max&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&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="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&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>Decimal&lt;/code>，刻意跟數量分開簽名）可以——&lt;strong>金額乘金額不存在&lt;/strong>，因為介面沒開放。想對 &lt;code>Money&lt;/code> 做 &lt;code>Decimal&lt;/code> 的任意運算，得先顯式呼叫 &lt;code>toDecimal()&lt;/code> 拆封，那一行拆封程式碼就是 code review 的攔截點。&lt;/p>
&lt;h2 id="implements-object-的取捨要當-object不當-decimal">implements Object 的取捨：要當 Object、不當 Decimal&lt;/h2>
&lt;p>extension type 宣告 &lt;code>implements Object&lt;/code> 而只有這個，是一個精確的 subtype 決策：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>是 &lt;code>Object&lt;/code> 的 subtype&lt;/strong>：既有的格式化入口 &lt;code>formatAmount(Object)&lt;/code> 可以直接吃 &lt;code>Money&lt;/code>、不用改簽名&lt;/li>
&lt;li>&lt;strong>不是 &lt;code>Decimal&lt;/code> 的 subtype&lt;/strong>：如果宣告 &lt;code>implements Decimal&lt;/code>，&lt;code>Money&lt;/code> 就能被傳進任何收 &lt;code>Decimal&lt;/code> 的參數、所有裸運算又回來了——包裝等於白做&lt;/li>
&lt;/ul>
&lt;p>extension type 在 runtime 是零開銷的（編譯後就是底層的 &lt;code>Decimal&lt;/code>），所有約束都活在編譯期。這也意味著它的保護是編譯期的：反射或 dynamic 繞得過去，威脅模型是「防止無心的誤用」而不是「防止刻意拆封」。&lt;/p>
&lt;h2 id="遷移安全網characterization-test-先鎖行為">遷移安全網：characterization test 先鎖行為&lt;/h2>
&lt;p>第二次遷移動的是全專案的金額欄位，怎麼確認換型別沒改行為？這個專案在遷移前先寫了一批 characterization test，測試檔開頭直接註明用途：&lt;/p>
&lt;blockquote>
&lt;p>Characterization test —— 鎖住 CheckoutContext 結帳金額計算的現有行為。在 Money value object 遷移（階段 5）前建立。涵蓋應付金額 fold、現金找零（含負數歸零分支）、金額足夠判斷。&lt;/p>&lt;/blockquote>
&lt;p>characterization test 跟一般測試的差別在斷言的性質：它不驗證「行為正確」、驗證「行為不變」。遷移前對著舊實作寫、鎖住當前輸出（包含當前的邊界行為，例如找零算出負數時歸零），遷移後全綠就證明型別替換沒有帶入行為變化。正確性是另一個問題、留給另一批測試——把兩個問題混在同一批測試裡，遷移期間的紅燈就分不清是「換壞了」還是「本來就錯」。&lt;/p>
&lt;h2 id="收束兩個問題兩次遷移">收束：兩個問題、兩次遷移&lt;/h2>
&lt;p>金額型別有兩個獨立的問題，這個專案的軌跡恰好一段解一個：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>症狀&lt;/th>
 &lt;th>解法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>精度&lt;/td>
 &lt;td>浮點誤差累積到分位&lt;/td>
 &lt;td>&lt;code>double&lt;/code> 換 &lt;code>Decimal&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>語意&lt;/td>
 &lt;td>任何人都能對金額做任意運算&lt;/td>
 &lt;td>&lt;code>Decimal&lt;/code> 包成 &lt;code>Money&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一段遷移完成時「金額用 Decimal」看起來已經是終點，語意問題要等到夠多「拿金額亂算」的路徑存在後才顯形。判讀訊號是：&lt;strong>一個領域概念的合法運算集合、明顯小於它底層型別的運算集合&lt;/strong>時，包一層 domain type 的價值就成立——差集裡的每個運算都是一個等著被誤用的 API。&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> 的語意封閉段（本文是 primitive obsession 到 domain type 的實機案例）&lt;/li>
&lt;li>同族判準：&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;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：整理 POS 專案的金額處理時，發現 git 歷史上金額型別換過兩次——<code>double</code> 到 <code>Decimal</code>、再從 <code>Decimal</code> 到自訂的 <code>Money</code>。第二次遷移乍看多餘：精度問題 <code>Decimal</code> 已經解掉了
<strong>疑問來源</strong>：<code>Decimal</code> 哪裡不夠？第二次遷移買到的是什麼？
<strong>整理目的</strong>：記下「精度」跟「語意」是金額型別的兩個獨立問題、以及 Dart extension type 在第二個問題上的實作手法
<strong>本文邊界</strong>：以該專案的實際遷移軌跡為素材；extension type 是 Dart 3 的機制、其他語言的對應手法（newtype / value class）思路相同但細節不同</p></blockquote>
<hr>
<h2 id="第一段double-的精度問題">第一段：double 的精度問題</h2>
<p>最初所有金額欄位是 <code>double</code>，JSON 轉換器把後端的 number 或 string 統一存成 <code>double</code>。浮點數處理金額的問題是經典的：<code>0.1 + 0.2 != 0.3</code>，累加訂單明細時誤差會累積到分位。</p>
<p>第一次遷移（單一 PR 內兩個 commit）把所有 model 的金額欄位換成 <code>Decimal</code>，API 接入層用 <code>jsonToDecimal</code> 統一處理後端可能回 number 或 string 的格式差異。精度問題到此解決。</p>
<h2 id="第二段decimal-解決了精度沒解決語意">第二段：Decimal 解決了精度、沒解決語意</h2>
<p>換完 <code>Decimal</code> 之後，金額仍然是一個<strong>裸的通用數字型別</strong>。任何拿到 <code>Decimal</code> 的程式碼都能對它做任意運算：兩個金額相乘（語意上不存在的運算）、金額跟折扣率直接相加、拿金額當數量用。型別系統對這些錯誤全部放行，因為它們在 <code>Decimal</code> 的世界都是合法運算。</p>
<p>這是 primitive obsession 的標準形態：值的表示對了、值的<strong>語意邊界</strong>還是沒有。三個月後的第二次遷移把金額包進 <code>Money</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="n">extension</span> <span class="n">type</span> <span class="kd">const</span> <span class="n">Money</span><span class="p">.</span><span class="n">_</span><span class="p">(</span><span class="n">Decimal</span> <span class="n">_raw</span><span class="p">)</span> <span class="kd">implements</span> <span class="kt">Object</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">Money</span> <span class="kd">operator</span> <span class="o">+</span><span class="p">(</span><span class="n">Money</span> <span class="n">other</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Money</span><span class="p">.</span><span class="n">_</span><span class="p">(</span><span class="n">_raw</span> <span class="o">+</span> <span class="n">other</span><span class="p">.</span><span class="n">_raw</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">Money</span> <span class="kd">operator</span> <span class="o">-</span><span class="p">(</span><span class="n">Money</span> <span class="n">other</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Money</span><span class="p">.</span><span class="n">_</span><span class="p">(</span><span class="n">_raw</span> <span class="o">-</span> <span class="n">other</span><span class="p">.</span><span class="n">_raw</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">Money</span> <span class="kd">operator</span> <span class="o">-</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Money</span><span class="p">.</span><span class="n">_</span><span class="p">(</span><span class="o">-</span><span class="n">_raw</span><span class="p">);</span>              <span class="c1">// 退款 / 折讓
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="n">Money</span> <span class="kd">operator</span> <span class="o">*</span><span class="p">(</span><span class="kt">int</span> <span class="n">quantity</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">...;</span>             <span class="c1">// 金額 × 數量
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="n">Money</span> <span class="n">multiplyByRate</span><span class="p">(</span><span class="n">Decimal</span> <span class="n">rate</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">...;</span>         <span class="c1">// 會員價率、服務費率
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="n">Money</span> <span class="n">clamp</span><span class="p">(</span><span class="n">Money</span> <span class="n">min</span><span class="p">,</span> <span class="n">Money</span> <span class="n">max</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>運算列表本身就是領域規則的宣告：金額加金額可以、金額乘整數（數量）可以、金額乘倍率（<code>Decimal</code>，刻意跟數量分開簽名）可以——<strong>金額乘金額不存在</strong>，因為介面沒開放。想對 <code>Money</code> 做 <code>Decimal</code> 的任意運算，得先顯式呼叫 <code>toDecimal()</code> 拆封，那一行拆封程式碼就是 code review 的攔截點。</p>
<h2 id="implements-object-的取捨要當-object不當-decimal">implements Object 的取捨：要當 Object、不當 Decimal</h2>
<p>extension type 宣告 <code>implements Object</code> 而只有這個，是一個精確的 subtype 決策：</p>
<ul>
<li><strong>是 <code>Object</code> 的 subtype</strong>：既有的格式化入口 <code>formatAmount(Object)</code> 可以直接吃 <code>Money</code>、不用改簽名</li>
<li><strong>不是 <code>Decimal</code> 的 subtype</strong>：如果宣告 <code>implements Decimal</code>，<code>Money</code> 就能被傳進任何收 <code>Decimal</code> 的參數、所有裸運算又回來了——包裝等於白做</li>
</ul>
<p>extension type 在 runtime 是零開銷的（編譯後就是底層的 <code>Decimal</code>），所有約束都活在編譯期。這也意味著它的保護是編譯期的：反射或 dynamic 繞得過去，威脅模型是「防止無心的誤用」而不是「防止刻意拆封」。</p>
<h2 id="遷移安全網characterization-test-先鎖行為">遷移安全網：characterization test 先鎖行為</h2>
<p>第二次遷移動的是全專案的金額欄位，怎麼確認換型別沒改行為？這個專案在遷移前先寫了一批 characterization test，測試檔開頭直接註明用途：</p>
<blockquote>
<p>Characterization test —— 鎖住 CheckoutContext 結帳金額計算的現有行為。在 Money value object 遷移（階段 5）前建立。涵蓋應付金額 fold、現金找零（含負數歸零分支）、金額足夠判斷。</p></blockquote>
<p>characterization test 跟一般測試的差別在斷言的性質：它不驗證「行為正確」、驗證「行為不變」。遷移前對著舊實作寫、鎖住當前輸出（包含當前的邊界行為，例如找零算出負數時歸零），遷移後全綠就證明型別替換沒有帶入行為變化。正確性是另一個問題、留給另一批測試——把兩個問題混在同一批測試裡，遷移期間的紅燈就分不清是「換壞了」還是「本來就錯」。</p>
<h2 id="收束兩個問題兩次遷移">收束：兩個問題、兩次遷移</h2>
<p>金額型別有兩個獨立的問題，這個專案的軌跡恰好一段解一個：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>症狀</th>
          <th>解法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>精度</td>
          <td>浮點誤差累積到分位</td>
          <td><code>double</code> 換 <code>Decimal</code></td>
      </tr>
      <tr>
          <td>語意</td>
          <td>任何人都能對金額做任意運算</td>
          <td><code>Decimal</code> 包成 <code>Money</code></td>
      </tr>
  </tbody>
</table>
<p>第一段遷移完成時「金額用 Decimal」看起來已經是終點，語意問題要等到夠多「拿金額亂算」的路徑存在後才顯形。判讀訊號是：<strong>一個領域概念的合法運算集合、明顯小於它底層型別的運算集合</strong>時，包一層 domain type 的價值就成立——差集裡的每個運算都是一個等著被誤用的 API。</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> 的語意封閉段（本文是 primitive obsession 到 domain type 的實機案例）</li>
<li>同族判準：<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>——那篇的判準「有沒有不允許任意組合的欄位」在型別層的對應是「有沒有不允許的運算」，兩篇都是把約束從慣例層上移到型別層</li>
</ul>
]]></content:encoded></item></channel></rss>