<?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>Entity on Tarragon</title><link>https://tarrragon.github.io/blog/tags/entity/</link><description>Recent content in Entity on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/entity/index.xml" rel="self" type="application/rss+xml"/><item><title>Entity</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/entity/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/entity/</guid><description>&lt;p>Entity 的同一性由身份定義：欄位可以全部改變、只要身份參照不變就是同一個；兩個欄位完全相同的 entity 仍然是兩個。這條定義推導出 entity 的設計形狀——有生命週期、狀態沿業務流程演進、變更要有路徑。跟 &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> 相反：value object 的同一性由內容定義、替換實例對系統沒有影響。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Entity 是領域模型的一種形態——型別先判定為領域模型（有&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>）、再判定身份語意（entity 或 value object）。判準是「操作需不需要 identity-based 回寫」：取消、改量、退貨這類要精確指到特定實體的操作，需要 entity；內容比對就足夠的操作用 value object。同一個業務概念的身份語意會隨生命週期階段改變——每個轉折點重問一次判準。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>改量、取消這類操作用內容比對定位對象——同內容的其他實體會被誤中，這是模型該升級成 entity 的訊號。entity 的變更路徑通常經由領域方法——如果 entity 同時有領域方法與全開放的覆寫工具，變更路徑正在退化。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Entity 需要決定身份的來源（資料庫序號、外部系統 ID、業務規則產生的識別碼）、生命週期的階段（何時誕生、何時交棒、何時成為歷史事實需要 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&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;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>Entity 的同一性由身份定義：欄位可以全部改變、只要身份參照不變就是同一個；兩個欄位完全相同的 entity 仍然是兩個。這條定義推導出 entity 的設計形狀——有生命週期、狀態沿業務流程演進、變更要有路徑。跟 <a href="/blog/ddd/knowledge-cards/value-object/" data-link-title="Value Object" data-link-desc="判斷一個概念該用內容比對還是身份追蹤時使用。value object 的同一性由內容定義——內容相等就是同一個、替換實例對系統沒有影響。">value object</a> 相反：value object 的同一性由內容定義、替換實例對系統沒有影響。</p>
<h2 id="概念位置">概念位置</h2>
<p>Entity 是領域模型的一種形態——型別先判定為領域模型（有<a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>）、再判定身份語意（entity 或 value object）。判準是「操作需不需要 identity-based 回寫」：取消、改量、退貨這類要精確指到特定實體的操作，需要 entity；內容比對就足夠的操作用 value object。同一個業務概念的身份語意會隨生命週期階段改變——每個轉折點重問一次判準。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>改量、取消這類操作用內容比對定位對象——同內容的其他實體會被誤中，這是模型該升級成 entity 的訊號。entity 的變更路徑通常經由領域方法——如果 entity 同時有領域方法與全開放的覆寫工具，變更路徑正在退化。</p>
<h2 id="設計責任">設計責任</h2>
<p>Entity 需要決定身份的來源（資料庫序號、外部系統 ID、業務規則產生的識別碼）、生命週期的階段（何時誕生、何時交棒、何時成為歷史事實需要 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a>）、以及變更的路徑（領域方法的設計，見 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</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>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>copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞</title><link>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：修一個效能基準測試的 &lt;code>InvalidBookIdException&lt;/code>，追根因時發現它是同族語意錯誤的第二起
&lt;strong>疑問來源&lt;/strong>：「copyWith 是方便的做法，但通常不是最好的設計」——這個直覺是否成立？
&lt;strong>整理目的&lt;/strong>：把「copyWith 什麼時候是對的工具、什麼時候是逃生口」的判斷邊界記下來，連同這個專案實際踩的三個坑
&lt;strong>本文邊界&lt;/strong>：這是一篇 work-log，回溯一次具體專案的設計檢視；它不主張消滅 copyWith——結論恰恰相反，問題從來不在 copyWith 本身&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事件起點一個-3-字元的-id">事件起點：一個 3 字元的 ID&lt;/h2>
&lt;p>書籍管理 App 專案的效能基準測試炸了一個例外：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">static</span> <span class="kd">const</span> <span class="n">_sentinel</span> <span class="o">=</span> <span class="kt">Object</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">State</span> <span class="n">copyWith</span><span class="p">({</span><span class="kt">Object</span><span class="o">?</span> <span class="n">member</span> <span class="o">=</span> <span class="n">_sentinel</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="n">State</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nl">member:</span> <span class="n">member</span> <span class="o">==</span> <span class="n">_sentinel</span> <span class="o">?</span> <span class="k">this</span><span class="p">.</span><span class="n">member</span> <span class="o">:</span> <span class="n">member</span> <span class="o">as</span> <span class="n">Member</span><span class="o">?</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>有了哨兵，「登出會員（明確設 null）」這類操作才能經由 copyWith 表達。freezed 生成的 copyWith 內部就是用同樣的哨兵技巧處理這件事——手寫 immutable state 時這是要自己補的部分，漏掉的症狀是「清空欄位的操作靜默變成保留原值」。</p>
<h2 id="收束三個坑的共同結構">收束：三個坑的共同結構</h2>
<p>這次追出來的三個坑——語意錯誤的測試拼裝、被繞過的領域方法、從未強制的註解約束——共同結構是同一個：</p>
<h3 id="設計意圖只寫在文件層沒有落在型別層或執行層">設計意圖只寫在文件層，沒有落在型別層或執行層</h3>
<p>「請走領域方法」是慣例，copyWith 不擋你；「只能從 enriching 轉換」是註解，實作不查你；「測試該用工廠」是期望，工廠沒能力你就繞。每一個「請、應該、建議」都是一個沒關上的逃生口，而逃生口的使用者不是壞人——他們只是走了阻力最小的路。</p>
<p>要讓意圖成立，就得讓違反意圖的路徑<strong>走不通</strong>，而不是寫文件請大家不要走。</p>
<p>這次追出來的兩個可重用原則各自抽成 report 卡：意圖的強制層次在 <a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>、缺陷的轉移機制在 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>。概念地基在 DDD 模組：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>（copyWith 該不該掛的判準入口）、<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>（文件層失效機制的教學層展開）、<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>（變更路徑收斂與稽核出洞機制）、<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>（工廠表達力不足的缺陷轉移）。</p>
]]></content:encoded></item><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>核心 entity 重寫、140+ 檔消費端不動 — Deprecated Getter Facade 的過渡設計</title><link>https://tarrragon.github.io/blog/work-log/flutter_deprecated_getter_facade_entity_migration/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_deprecated_getter_facade_entity_migration/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 Book entity 要從固定欄位（author、publisher、isbn、genre……）重寫成 tag-based 結構。動手前的 ripple 盤點：&lt;code>.author&lt;/code> 有 64 個檔在用、&lt;code>.isbn&lt;/code> 60 個、&lt;code>.publisher&lt;/code> 45 個——加上測試合計 140+ 檔受影響
&lt;strong>疑問來源&lt;/strong>：核心 entity 是所有 Service / Repository / ViewModel 的上游、必須先改；但 140+ 檔的 ripple 又讓「先改它」等於同時打爆整個專案。怎麼解這個死結？
&lt;strong>整理目的&lt;/strong>：記下大 ripple entity 演化的三個選項、facade 策略的機制與配套、以及它跟「永久相容層」的一線之隔
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.32 的 ticket 記錄（含 PM 前置調查與 SA 審查結論）；「認知負擔閾值 &amp;gt; 5 檔必須拆分」是該專案自訂的工作規則&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="先量化-ripple再選策略">先量化 ripple、再選策略&lt;/h2>
&lt;p>這次重寫在動手前做了一件關鍵的事：把「影響很大」量化成數字。逐欄位 grep 消費端：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>廢除欄位&lt;/th>
 &lt;th>lib/ 引用檔數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>.author&lt;/code>&lt;/td>
 &lt;td>64&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.isbn&lt;/code>&lt;/td>
 &lt;td>60&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.publisher&lt;/code>&lt;/td>
 &lt;td>45&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.source&lt;/code>&lt;/td>
 &lt;td>27&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.importanceLevel&lt;/code>&lt;/td>
 &lt;td>17&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.readingStatus&lt;/code>&lt;/td>
 &lt;td>16&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>加上 77 個測試檔、合計 140+ 檔。這張表直接判定了原提案（單一 ticket 重寫 entity）的死刑——PM 調查的結論寫得直白：「多 Wave migration 偽裝成單一 ticket」。數字的價值在這裡：&lt;strong>策略選擇是 ripple 規模的函數&lt;/strong>，不先量化就選策略、等於矇著眼選。&lt;/p>
&lt;h2 id="三個選項兩個否決理由">三個選項、兩個否決理由&lt;/h2>
&lt;p>SA 審查列了三條路、否決理由都寫進了記錄：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>直接移除 + 全量遷移&lt;/strong>：140+ 檔同時修改，違反該專案的認知負擔閾值（單次修改 &amp;gt; 5 檔必須拆分）、且無法在「測試 100% 通過」的前提下原子完成——改到一半的每個中間狀態都是編譯不過的&lt;/li>
&lt;li>&lt;strong>長期分支開發&lt;/strong>：分支與 main 的 merge conflict 成本隨時間指數成長，而且其他 Wave 的 ticket 依賴新 entity——分支隔離了風險、也隔離了下游的進度&lt;/li>
&lt;li>&lt;strong>Deprecated Getter Facade&lt;/strong>（勝出）：entity 換新結構、舊介面保留為過渡層&lt;/li>
&lt;/ul>
&lt;h2 id="facade-機制舊介面成為新資料的-view">Facade 機制：舊介面成為新資料的 view&lt;/h2>
&lt;p>核心手法一段程式碼講完：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// Book entity 內：新結構是 tag 關聯
&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">// 舊欄位保留為 deprecated getter、從新結構回讀
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="err">@&lt;/span>&lt;span class="n">Deprecated&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Use tagRepository.getTagsForBook(bookId, category: &amp;#34;author&amp;#34;) instead&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n">BookAuthor&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">author&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_legacyAuthorFromTags&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>entity 持有 repository 注入的 &lt;code>List&amp;lt;BookTag&amp;gt;&lt;/code>，每個廢除欄位各有一個 deprecated getter、從 tags 過濾對應分類、回傳&lt;strong>舊型別&lt;/strong>。效果分三層：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>消費端 140+ 檔零修改編譯通過&lt;/strong>——舊介面的形狀完整保留，只是資料來源換了&lt;/li>
&lt;li>&lt;strong>&lt;code>@Deprecated&lt;/code> 把遷移清單交給編譯器&lt;/strong>——每個舊呼叫點自動變成 warning，「還剩多少沒遷」隨時可查、不靠人工盤點&lt;/li>
&lt;li>&lt;strong>新程式碼從第一天用新 API&lt;/strong>（&lt;code>getTagsByCategory&lt;/code>）——新舊並行、但增量只往新的走&lt;/li>
&lt;/ul>
&lt;p>配套的兩個細節同樣值得記：新集合命名 &lt;code>bookTags&lt;/code> 刻意避開 entity 既有的 &lt;code>tags&lt;/code> 欄位（遷移期兩者並存、同名會災難）；序列化走雙軌——&lt;code>toJson&lt;/code> 維持 v1 格式相容既有持久化、新的交換格式獨立成 &lt;code>toInterchangeJson&lt;/code> v2，讀寫兩個世界互不干擾。&lt;/p>
&lt;h2 id="facade-與永久相容層的一線之隔退場計畫">facade 與永久相容層的一線之隔：退場計畫&lt;/h2>
&lt;p>這個策略跟同專案早年&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>.value&lt;/code> getter 當相容性介面」在機制上是同一件事——差別全在配套。那次的 getter 加回來就沒有然後了、成為永久的一部分；這次的 facade 在 ticket 系統裡直接 spawn 了九張後續票、逐 Wave 遷移各消費端，deprecated getter 的死期寫在 backlog 上。&lt;/p>
&lt;p>&lt;strong>facade 的性質由退場計畫決定&lt;/strong>：有計畫、它是分期償還的過渡層；沒計畫、它是把重構宣告完成的化妝——新舊兩套 API 永久並存、每個新人都要學「哪個是真的」。判斷一個 codebase 裡的 deprecated 標記是哪一種，看它有沒有對應的遷移工作項、以及 warning 數量的趨勢是降是平。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 Book entity 要從固定欄位（author、publisher、isbn、genre……）重寫成 tag-based 結構。動手前的 ripple 盤點：<code>.author</code> 有 64 個檔在用、<code>.isbn</code> 60 個、<code>.publisher</code> 45 個——加上測試合計 140+ 檔受影響
<strong>疑問來源</strong>：核心 entity 是所有 Service / Repository / ViewModel 的上游、必須先改；但 140+ 檔的 ripple 又讓「先改它」等於同時打爆整個專案。怎麼解這個死結？
<strong>整理目的</strong>：記下大 ripple entity 演化的三個選項、facade 策略的機制與配套、以及它跟「永久相容層」的一線之隔
<strong>本文邊界</strong>：素材是該專案 v0.32 的 ticket 記錄（含 PM 前置調查與 SA 審查結論）；「認知負擔閾值 &gt; 5 檔必須拆分」是該專案自訂的工作規則</p></blockquote>
<hr>
<h2 id="先量化-ripple再選策略">先量化 ripple、再選策略</h2>
<p>這次重寫在動手前做了一件關鍵的事：把「影響很大」量化成數字。逐欄位 grep 消費端：</p>
<table>
  <thead>
      <tr>
          <th>廢除欄位</th>
          <th>lib/ 引用檔數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.author</code></td>
          <td>64</td>
      </tr>
      <tr>
          <td><code>.isbn</code></td>
          <td>60</td>
      </tr>
      <tr>
          <td><code>.publisher</code></td>
          <td>45</td>
      </tr>
      <tr>
          <td><code>.source</code></td>
          <td>27</td>
      </tr>
      <tr>
          <td><code>.importanceLevel</code></td>
          <td>17</td>
      </tr>
      <tr>
          <td><code>.readingStatus</code></td>
          <td>16</td>
      </tr>
  </tbody>
</table>
<p>加上 77 個測試檔、合計 140+ 檔。這張表直接判定了原提案（單一 ticket 重寫 entity）的死刑——PM 調查的結論寫得直白：「多 Wave migration 偽裝成單一 ticket」。數字的價值在這裡：<strong>策略選擇是 ripple 規模的函數</strong>，不先量化就選策略、等於矇著眼選。</p>
<h2 id="三個選項兩個否決理由">三個選項、兩個否決理由</h2>
<p>SA 審查列了三條路、否決理由都寫進了記錄：</p>
<ul>
<li><strong>直接移除 + 全量遷移</strong>：140+ 檔同時修改，違反該專案的認知負擔閾值（單次修改 &gt; 5 檔必須拆分）、且無法在「測試 100% 通過」的前提下原子完成——改到一半的每個中間狀態都是編譯不過的</li>
<li><strong>長期分支開發</strong>：分支與 main 的 merge conflict 成本隨時間指數成長，而且其他 Wave 的 ticket 依賴新 entity——分支隔離了風險、也隔離了下游的進度</li>
<li><strong>Deprecated Getter Facade</strong>（勝出）：entity 換新結構、舊介面保留為過渡層</li>
</ul>
<h2 id="facade-機制舊介面成為新資料的-view">Facade 機制：舊介面成為新資料的 view</h2>
<p>核心手法一段程式碼講完：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Book entity 內：新結構是 tag 關聯
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 舊欄位保留為 deprecated getter、從新結構回讀
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="err">@</span><span class="n">Deprecated</span><span class="p">(</span><span class="s1">&#39;Use tagRepository.getTagsForBook(bookId, category: &#34;author&#34;) instead&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">BookAuthor</span> <span class="kd">get</span> <span class="n">author</span> <span class="o">=&gt;</span> <span class="n">_legacyAuthorFromTags</span><span class="p">();</span></span></span></code></pre></div><p>entity 持有 repository 注入的 <code>List&lt;BookTag&gt;</code>，每個廢除欄位各有一個 deprecated getter、從 tags 過濾對應分類、回傳<strong>舊型別</strong>。效果分三層：</p>
<ul>
<li><strong>消費端 140+ 檔零修改編譯通過</strong>——舊介面的形狀完整保留，只是資料來源換了</li>
<li><strong><code>@Deprecated</code> 把遷移清單交給編譯器</strong>——每個舊呼叫點自動變成 warning，「還剩多少沒遷」隨時可查、不靠人工盤點</li>
<li><strong>新程式碼從第一天用新 API</strong>（<code>getTagsByCategory</code>）——新舊並行、但增量只往新的走</li>
</ul>
<p>配套的兩個細節同樣值得記：新集合命名 <code>bookTags</code> 刻意避開 entity 既有的 <code>tags</code> 欄位（遷移期兩者並存、同名會災難）；序列化走雙軌——<code>toJson</code> 維持 v1 格式相容既有持久化、新的交換格式獨立成 <code>toInterchangeJson</code> v2，讀寫兩個世界互不干擾。</p>
<h2 id="facade-與永久相容層的一線之隔退場計畫">facade 與永久相容層的一線之隔：退場計畫</h2>
<p>這個策略跟同專案早年<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>.value</code> getter 當相容性介面」在機制上是同一件事——差別全在配套。那次的 getter 加回來就沒有然後了、成為永久的一部分；這次的 facade 在 ticket 系統裡直接 spawn 了九張後續票、逐 Wave 遷移各消費端，deprecated getter 的死期寫在 backlog 上。</p>
<p><strong>facade 的性質由退場計畫決定</strong>：有計畫、它是分期償還的過渡層；沒計畫、它是把重構宣告完成的化妝——新舊兩套 API 永久並存、每個新人都要學「哪個是真的」。判斷一個 codebase 裡的 deprecated 標記是哪一種，看它有沒有對應的遷移工作項、以及 warning 數量的趨勢是降是平。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>核心 entity / 介面要重寫、而「先量 ripple」沒做——grep 出消費端檔數再開會，策略討論會短很多</li>
<li>單一 ticket 的影響檔數超過團隊的認知閾值——它是偽裝成 ticket 的 migration，拆 wave</li>
<li>deprecated getter 存在超過 N 個版本、warning 數量不降——facade 已變永久相容層，補退場計畫或誠實移除 @Deprecated</li>
<li>遷移期新舊集合 / 方法同名或近名——先改名再並行，同名並存的每一天都在累積誤用</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>原則層：<a href="/blog/report/incremental-shipping-criteria/" data-link-title="分批 ship：低風險可見價值先行、結構性下輪" data-link-desc="「一次 ship 全部」的衝動 vs 「分批 ship」的設計：判準三軸（使用者可見性 / 風險暴露面 / 驗證需求）。低風險 &#43; 高可見 = 立刻 ship；高風險 &#43; 需驗證 = 下輪。對抗「完整才完整」的全做衝動、避免一次塞太多 review surface 拖延上線。">#76 分批 ship：低風險可見價值先行</a>——facade + wave 遷移就是分批 ship 在 entity 演化上的形態</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 是假訊號。">read-path 缺口與 fixture 假綠</a>——facade 讓編譯過了、但新結構的資料通路要自己驗證</li>
</ul>
]]></content:encoded></item></channel></rss>