<?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>Domain-Model on Tarragon</title><link>https://tarrragon.github.io/blog/tags/domain-model/</link><description>Recent content in Domain-Model 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/domain-model/index.xml" rel="self" type="application/rss+xml"/><item><title>資料袋與領域模型</title><link>https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/</guid><description>&lt;p>DDD 的源頭精神是把業務規則放進領域模型、讓違反規則的路徑走不通。這句話隱含一個前置判斷：眼前這個型別有沒有業務規則要承擔。有規則要強制的型別、才值得領域模型的設計投資；欄位之間互不約束的型別、一袋欄位就是正確形態。本章建立這條分界——它是本模組其餘判準的入口：先判定型別的類別、後續的 entity 判準與不變式層次才有作用對象。&lt;/p>
&lt;h2 id="兩種型別各自承擔什麼">兩種型別各自承擔什麼&lt;/h2>
&lt;p>資料袋承擔資料的搬運與呈現：DTO、API model、UI state、設定物件都屬於這一類。它的特徵是任何欄位組合都是合法狀態——修改其中一欄、其餘欄位的意義照舊成立。因為組合全部合法，全開放的建構子、逐欄位覆寫工具（copyWith、setter、builder）在這裡語意清晰、沒有代價；各語言生態替 data class 自動生成這些工具，正是建立在「組合全部合法」的前提上。&lt;/p>
&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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>變更方式&lt;/td>
 &lt;td>逐欄位覆寫、語意即「換值」&lt;/td>
 &lt;td>有意圖的領域方法、語意是業務事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相配工具&lt;/td>
 &lt;td>建構子全開放、copyWith、setter&lt;/td>
 &lt;td>收斂的建構路徑、方法內部檢查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格的三個面向是同一件事的三個投影：合法狀態的形狀決定變更方式、變更方式決定工具的開放程度。判斷時從第一列進入——先確認「有沒有不合法的組合」，工具選擇是推論結果、順序反過來（先選了工具再回頭定規則）就會走進下一節的事故。&lt;/p>
&lt;h2 id="判準有沒有不允許任意組合的欄位">判準：有沒有不允許任意組合的欄位&lt;/h2>
&lt;p>分界的可操作判準是一個問題：這個型別有沒有「不允許任意組合的欄位」。一個書籍管理 App 的 &lt;code>Book&lt;/code> 把兩種形狀疊在同一個型別上：它帶著一組有意圖的狀態轉換方法（開始豐富化、完成豐富化、標記可用），每個方法往稽核欄位追加一筆變更紀錄——這是典型的領域模型形狀；但它同時掛著一個 public 的、參數列包含狀態與稽核欄位的 copyWith，事後追查發現工廠層直接用 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>判準的答案對應到行動：&lt;/p>
&lt;ul>
&lt;li>型別存在不允許任意組合的欄位（狀態、稽核紀錄、被規則綁住的欄位群），而且這些欄位會被變更——寫入路徑要收斂到領域方法，逐欄位覆寫工具對它們關閉。&lt;/li>
&lt;li>欄位有約束但沒有變更需求（設定物件的交叉約束、唯讀投影）——收斂到建構路徑就足夠：不可變加建構期驗證、零變更方法，領域方法是變更存在時才需要的載體。&lt;/li>
&lt;li>型別的欄位組合全部合法——資料袋，全開放工具正當，加上領域模型的儀式（工廠、私有建構、變更方法）只會製造沒有規則可守的 boilerplate。&lt;/li>
&lt;/ul>
&lt;p>這條判準也有沉默的地方。它回答「現在有沒有規則」，對「未來會長出什麼規則」沉默——規則還沒到場的型別照資料袋處理，升級時機由本章末段的演化訊號決定。它判定的對象是容器：欄位自身該不該包成 domain type 是另一條正交的軸，資料袋裡照樣可以放 Money 這類語意封閉的欄位型別。規則若以相等性定義或運算集合的形式存在、而不是以欄位組合的形式存在，這條判準同樣看不見——兩者都屬身份語意的範圍，判準見 &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>
&lt;h2 id="判準用錯的代價規則退化成建議">判準用錯的代價：規則退化成建議&lt;/h2>
&lt;p>領域模型掛上資料袋的全開放工具之後，業務規則就從唯一路徑退化成建議路徑。上述專案的三個實證按層次排開：工廠層用 copyWith 直接改狀態，對應的狀態轉換沒有進入稽核紀錄——稽核軌跡出洞、而且是靜默的，沒有任何錯誤或測試失敗會揭露它；狀態轉換方法的註解宣稱了轉換約束、實作裡沒有任何對應檢查（文件層約束的失效機制在 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 展開）；最後連測試作者都把 copyWith 當成業務入口、期待它留下稽核痕跡——兩條路徑（工具方法沒有紀錄、業務方法有紀錄）並存在同一個 public 介面上，每個使用者都要自己記得哪條是哪條。&lt;/p>
&lt;p>執行者會換人、時間會沖淡記憶、便利工具讓繞行毫無阻力——依靠記憶的規則遲早失守，失守的形式是靜默的資料異常、隔著幾層在別處浮現。判準用錯的方向有兩個、代價形狀相反：領域模型配資料袋工具，讓違反規則的路徑走得通；資料袋配領域模型儀式，則是在沒有規則的地方築牆、每次改欄位都要穿過沒有規則可守的方法層。&lt;/p>
&lt;h2 id="分界隨生命週期移動">分界隨生命週期移動&lt;/h2>
&lt;p>資料袋或領域模型的判定作用在概念的一個生命週期階段，而不是概念本身。一個 POS 專案的品項模型把同一個概念沿生命週期換了類別：點餐輸入階段的購物車品項是純需求描述、連 id 欄位都沒有，兩個品項是否同一項靠內容比對——形態上接近資料袋、只多一個相等性定義；而相等性定義開始承載業務決策（改過價的品項算獨立的訂單行）的那一刻，它已經跨進 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>判準要逐生命週期階段重問，答案改變的時刻就是模型該交棒的時刻。POS 品項恰好在兩個軸上同時升級（欄位組合規則從無到有、身份需求從無到有），但這兩個判準是獨立的——一個物件可以有嚴格的欄位組合規則卻不需要身份（純 value object），也可以需要身份但欄位組合全部合法（identity-bearing data holder）。同一個業務概念在輸入階段是內容比對的 value object、進入外部系統後是 entity、成為歷史事實後又凍結成 snapshot（當下內容的複本）——強行用單一模型通吃，每個階段都要為其他階段的需求付代價。&lt;/p>
&lt;p>跨服務傳輸時，一個在來源端是領域模型的概念，到了接收端刻意降級成資料袋（DTO）是正當的設計選擇。規則的擁有者是來源端的 bounded context，搬運端沒有強制規則的責任——在接收端看來，這些欄位的任意組合都是合法的（規則在別人那裡）。身份語意的完整判準在 &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>
&lt;h2 id="資料袋起步訊號出現才升級">資料袋起步、訊號出現才升級&lt;/h2>
&lt;p>規則還沒到場時，資料袋起步是合理的設計，升級時機由演化訊號決定、而由預測決定的升級幾乎都會蓋錯。同一個 POS 專案的商品模型留下一組可對照的時間軸：早期文件記錄的 Product 是扁平結構（一商品、一價、一庫存），並附一張「未來擴展」清單——商品分類、庫存管理、折扣策略、商品圖片。十個月後清單上每一項都發生了，但沒有一項是在扁平模型上加欄位實現的：真實業務帶來「規格」這個變體維度（中杯與大杯各自有條碼、售價、庫存），模型長成雙層結構——商品作為聚合根（對外代表整組資料一致性的邊界物件）、規格作為其下被分化的子層，欄位歸屬的判準是「兩個規格會不會不同」（&lt;a href="https://tarrragon.github.io/blog/work-log/pos_product_model_doc_vs_code_evolution/" data-link-title="文件裡的扁平 Product、程式碼裡的雙層聚合 — 宣稱型文件的半衰期" data-link-desc="refactor 總結文件記的是決策時刻的快照：扁平 Product（一商品一價一庫存）在真實 POS 業務下演化成 Product &amp;#43; ProductSpecification 雙層、價格三種下沉到規格。欄位放聚合根還是子層的判準是「兩個規格會不會不同」；文件預言的需求全中、預言的結構全錯——這正是先蓋結構會蓋錯的實證。">文件裡的扁平 Product、程式碼裡的雙層聚合&lt;/a>）。&lt;/p>
&lt;p>這個案例把 YAGNI（You Aren&amp;rsquo;t Gonna Need It、需求到場前先別蓋）落到模型設計的精確形式：預測「會有什麼需求」可行、預測「結構會怎麼長」幾乎不可能——結構由「變體會沿哪個軸分化」決定，而分化軸是設計當下還沒到場的業務資訊。需求清單可以先列，它是雷達；結構要等需求真正到場才定形：預先蓋的欄位每一個都是將來的遷移債。升級訊號比預測可靠：同一概念出現多個變體需求（規格、方案、版本）、欄位組合開始被業務規則綁住、變更開始需要留痕或走審批——訊號出現的當下再做歸屬判準與拆層，結構是從真需求長出來的。&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/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a>、原則層見 &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;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>。&lt;/li>
&lt;/ul>
&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;/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/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;li>Dart 的語言細節（copyWith 生態、freezed 的預設路徑、哨兵物件）：&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;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;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>DDD 的源頭精神是把業務規則放進領域模型、讓違反規則的路徑走不通。這句話隱含一個前置判斷：眼前這個型別有沒有業務規則要承擔。有規則要強制的型別、才值得領域模型的設計投資；欄位之間互不約束的型別、一袋欄位就是正確形態。本章建立這條分界——它是本模組其餘判準的入口：先判定型別的類別、後續的 entity 判準與不變式層次才有作用對象。</p>
<h2 id="兩種型別各自承擔什麼">兩種型別各自承擔什麼</h2>
<p>資料袋承擔資料的搬運與呈現：DTO、API model、UI state、設定物件都屬於這一類。它的特徵是任何欄位組合都是合法狀態——修改其中一欄、其餘欄位的意義照舊成立。因為組合全部合法，全開放的建構子、逐欄位覆寫工具（copyWith、setter、builder）在這裡語意清晰、沒有代價；各語言生態替 data class 自動生成這些工具，正是建立在「組合全部合法」的前提上。</p>
<p>領域模型承擔業務規則的強制。它的特徵是存在業務上根本不成立的欄位組合、或必須沿特定路徑發生的變更：狀態欄位只能照業務流程轉換、每次變更要留下稽核紀錄、某幾個欄位被同一條規則綁住必須一起換。這些規則就是不變式——在物件整個生命週期都必須為真的條件。領域模型的介面圍繞規則設計：變更收斂成有業務意圖的方法、建構路徑保證出生即合法。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>資料袋</th>
          <th>領域模型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>合法狀態</td>
          <td>任何欄位組合</td>
          <td>部分組合在業務上不存在</td>
      </tr>
      <tr>
          <td>變更方式</td>
          <td>逐欄位覆寫、語意即「換值」</td>
          <td>有意圖的領域方法、語意是業務事件</td>
      </tr>
      <tr>
          <td>相配工具</td>
          <td>建構子全開放、copyWith、setter</td>
          <td>收斂的建構路徑、方法內部檢查</td>
      </tr>
  </tbody>
</table>
<p>表格的三個面向是同一件事的三個投影：合法狀態的形狀決定變更方式、變更方式決定工具的開放程度。判斷時從第一列進入——先確認「有沒有不合法的組合」，工具選擇是推論結果、順序反過來（先選了工具再回頭定規則）就會走進下一節的事故。</p>
<h2 id="判準有沒有不允許任意組合的欄位">判準：有沒有不允許任意組合的欄位</h2>
<p>分界的可操作判準是一個問題：這個型別有沒有「不允許任意組合的欄位」。一個書籍管理 App 的 <code>Book</code> 把兩種形狀疊在同一個型別上：它帶著一組有意圖的狀態轉換方法（開始豐富化、完成豐富化、標記可用），每個方法往稽核欄位追加一筆變更紀錄——這是典型的領域模型形狀；但它同時掛著一個 public 的、參數列包含狀態與稽核欄位的 copyWith，事後追查發現工廠層直接用 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>判準的答案對應到行動：</p>
<ul>
<li>型別存在不允許任意組合的欄位（狀態、稽核紀錄、被規則綁住的欄位群），而且這些欄位會被變更——寫入路徑要收斂到領域方法，逐欄位覆寫工具對它們關閉。</li>
<li>欄位有約束但沒有變更需求（設定物件的交叉約束、唯讀投影）——收斂到建構路徑就足夠：不可變加建構期驗證、零變更方法，領域方法是變更存在時才需要的載體。</li>
<li>型別的欄位組合全部合法——資料袋，全開放工具正當，加上領域模型的儀式（工廠、私有建構、變更方法）只會製造沒有規則可守的 boilerplate。</li>
</ul>
<p>這條判準也有沉默的地方。它回答「現在有沒有規則」，對「未來會長出什麼規則」沉默——規則還沒到場的型別照資料袋處理，升級時機由本章末段的演化訊號決定。它判定的對象是容器：欄位自身該不該包成 domain type 是另一條正交的軸，資料袋裡照樣可以放 Money 這類語意封閉的欄位型別。規則若以相等性定義或運算集合的形式存在、而不是以欄位組合的形式存在，這條判準同樣看不見——兩者都屬身份語意的範圍，判準見 <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>
<h2 id="判準用錯的代價規則退化成建議">判準用錯的代價：規則退化成建議</h2>
<p>領域模型掛上資料袋的全開放工具之後，業務規則就從唯一路徑退化成建議路徑。上述專案的三個實證按層次排開：工廠層用 copyWith 直接改狀態，對應的狀態轉換沒有進入稽核紀錄——稽核軌跡出洞、而且是靜默的，沒有任何錯誤或測試失敗會揭露它；狀態轉換方法的註解宣稱了轉換約束、實作裡沒有任何對應檢查（文件層約束的失效機制在 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 展開）；最後連測試作者都把 copyWith 當成業務入口、期待它留下稽核痕跡——兩條路徑（工具方法沒有紀錄、業務方法有紀錄）並存在同一個 public 介面上，每個使用者都要自己記得哪條是哪條。</p>
<p>執行者會換人、時間會沖淡記憶、便利工具讓繞行毫無阻力——依靠記憶的規則遲早失守，失守的形式是靜默的資料異常、隔著幾層在別處浮現。判準用錯的方向有兩個、代價形狀相反：領域模型配資料袋工具，讓違反規則的路徑走得通；資料袋配領域模型儀式，則是在沒有規則的地方築牆、每次改欄位都要穿過沒有規則可守的方法層。</p>
<h2 id="分界隨生命週期移動">分界隨生命週期移動</h2>
<p>資料袋或領域模型的判定作用在概念的一個生命週期階段，而不是概念本身。一個 POS 專案的品項模型把同一個概念沿生命週期換了類別：點餐輸入階段的購物車品項是純需求描述、連 id 欄位都沒有，兩個品項是否同一項靠內容比對——形態上接近資料袋、只多一個相等性定義；而相等性定義開始承載業務決策（改過價的品項算獨立的訂單行）的那一刻，它已經跨進 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>判準要逐生命週期階段重問，答案改變的時刻就是模型該交棒的時刻。POS 品項恰好在兩個軸上同時升級（欄位組合規則從無到有、身份需求從無到有），但這兩個判準是獨立的——一個物件可以有嚴格的欄位組合規則卻不需要身份（純 value object），也可以需要身份但欄位組合全部合法（identity-bearing data holder）。同一個業務概念在輸入階段是內容比對的 value object、進入外部系統後是 entity、成為歷史事實後又凍結成 snapshot（當下內容的複本）——強行用單一模型通吃，每個階段都要為其他階段的需求付代價。</p>
<p>跨服務傳輸時，一個在來源端是領域模型的概念，到了接收端刻意降級成資料袋（DTO）是正當的設計選擇。規則的擁有者是來源端的 bounded context，搬運端沒有強制規則的責任——在接收端看來，這些欄位的任意組合都是合法的（規則在別人那裡）。身份語意的完整判準在 <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>
<h2 id="資料袋起步訊號出現才升級">資料袋起步、訊號出現才升級</h2>
<p>規則還沒到場時，資料袋起步是合理的設計，升級時機由演化訊號決定、而由預測決定的升級幾乎都會蓋錯。同一個 POS 專案的商品模型留下一組可對照的時間軸：早期文件記錄的 Product 是扁平結構（一商品、一價、一庫存），並附一張「未來擴展」清單——商品分類、庫存管理、折扣策略、商品圖片。十個月後清單上每一項都發生了，但沒有一項是在扁平模型上加欄位實現的：真實業務帶來「規格」這個變體維度（中杯與大杯各自有條碼、售價、庫存），模型長成雙層結構——商品作為聚合根（對外代表整組資料一致性的邊界物件）、規格作為其下被分化的子層，欄位歸屬的判準是「兩個規格會不會不同」（<a href="/blog/work-log/pos_product_model_doc_vs_code_evolution/" data-link-title="文件裡的扁平 Product、程式碼裡的雙層聚合 — 宣稱型文件的半衰期" data-link-desc="refactor 總結文件記的是決策時刻的快照：扁平 Product（一商品一價一庫存）在真實 POS 業務下演化成 Product &#43; ProductSpecification 雙層、價格三種下沉到規格。欄位放聚合根還是子層的判準是「兩個規格會不會不同」；文件預言的需求全中、預言的結構全錯——這正是先蓋結構會蓋錯的實證。">文件裡的扁平 Product、程式碼裡的雙層聚合</a>）。</p>
<p>這個案例把 YAGNI（You Aren&rsquo;t Gonna Need It、需求到場前先別蓋）落到模型設計的精確形式：預測「會有什麼需求」可行、預測「結構會怎麼長」幾乎不可能——結構由「變體會沿哪個軸分化」決定，而分化軸是設計當下還沒到場的業務資訊。需求清單可以先列，它是雷達；結構要等需求真正到場才定形：預先蓋的欄位每一個都是將來的遷移債。升級訊號比預測可靠：同一概念出現多個變體需求（規格、方案、版本）、欄位組合開始被業務規則綁住、變更開始需要留痕或走審批——訊號出現的當下再做歸屬判準與拆層，結構是從真需求長出來的。</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/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>、原則層見 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223</a>。</li>
<li>同一個概念長出多個變體需求：扁平模型已到結構性極限，先做「哪些欄位會被變體分化」的歸屬判準再拆層。</li>
<li>一個型別同時有領域方法與全開放的覆寫工具——兩條變更路徑並存，規則正在退化成建議，收斂方向見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</li>
</ul>
<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></li>
<li>變更路徑的收斂與稽核軌跡：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>規則的落點與代價：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>Dart 的語言細節（copyWith 生態、freezed 的預設路徑、哨兵物件）：<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>
<li>原則層：<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></li>
</ul>
]]></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>不變式的強制層次</title><link>https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/</guid><description>&lt;p>不變式是在物件整個生命週期都必須為真的業務規則：狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換、錯誤代碼必須屬於對應分類。本章的作用域是單一物件的不變式——跨物件的一致性（aggregate 邊界、「交易完成時必須成立、執行中間允許暫時違反」的時點語意）是另一個層次的主題，等 case 累積後另章展開。模組源頭句「讓違反規則的路徑走不通」在本章落到最直接的決策：同一條規則在應用程式碼內可以落在文件層、型別層或執行層，層次決定違反規則時發生什麼——靜默通過、編譯失敗、還是當場拒絕。型別的類別（&lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" 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>
&lt;h2 id="三個層次的差異">三個層次的差異&lt;/h2>
&lt;p>文件層把規則寫成註解、命名、慣例與規範文件，依靠讀者記得並自律。型別層把規則做進介面簽名與型別系統，違反的程式碼無法通過編譯——規則錯的程式根本產不出來。執行層把規則做成建構子與領域方法內的檢查，違反在 runtime 的當下被拒絕，錯誤有明確的發生點與訊息。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層次&lt;/th>
 &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;/td>
 &lt;td>寫下來最便宜、失效最昂貴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>型別層&lt;/td>
 &lt;td>介面簽名、型別系統&lt;/td>
 &lt;td>編譯失敗&lt;/td>
 &lt;td>設計介面要花心思、編譯期攔截無心誤用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>執行層&lt;/td>
 &lt;td>建構子、方法內檢查&lt;/td>
 &lt;td>runtime 當場拒絕&lt;/td>
 &lt;td>要寫檢查與測試、失效點集中&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三層的選擇是「這條規則的違反代價」對「這一層的建置成本」的折算、層次高低本身沒有優劣排序。折算的變數包含團隊規模、人員流動率與專案壽命：小而穩定的團隊靠 code review 攔截誤用是可承受的選擇；人一多、流動一快，同樣的慣例就守不住——違反代價沒變、失效機率變了。狀態轉換與稽核這類違反後靜默出洞的規則，值得推到型別層或執行層；一次性的輸入格式問題留在執行層的驗證流程就足夠；真正只能靠慣例的（命名風格、檔案組織）才留在文件層——文件層是最後的選擇、而不是預設的起點。&lt;/p>
&lt;p>這三層涵蓋的是應用程式碼內的落點，實務上還有兩個常見的層。資料庫約束（NOT NULL、外鍵、unique index）攔得住所有寫入者——含手工 SQL 與其他服務；「email 不得重複」這類跨物件的唯一性規則，任何建構子或簽名都表達不了、併發下的可靠落點只有它，資料庫層的能力屬 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend&lt;/a> 模組的範圍。CI 檢查（architecture test、lint）把慣例類規則升級成「合併前擋下」、強度介於文件層與型別層之間。本章的三層判準作用在單物件規則上；規則跨出單一物件時，先想這兩層。跨到應用程式的組裝層時——「use case 的每個入口在 production 可達」這類不變式——強制層選擇見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>。&lt;/p>
&lt;p>這五個位置沿強度排列，而強度其實是兩件事的合成。第一件是&lt;strong>規則寫在哪&lt;/strong>：註解、介面簽名、建構子檢查、schema 約束都寫進被約束的產物裡，lint 設定與 CI 規則寫在產物外面。第二件是&lt;strong>違反時何時發聲&lt;/strong>：文件層永不發聲，型別層在編譯當下，執行層與資料庫在寫入當下，CI 在合併之前。這兩件事獨立變化——文件層與型別層同樣寫在產物內，發聲能力卻是零與編譯期的差距。&lt;/p>
&lt;p>把兩條軸壓成一條的代價是產物外那一側只剩一格。CI 那格裡的 lint 與 architecture test 都是讀程式文本的檢查：它們掃原始碼長什麼樣，不掃程式跑起來會怎樣。&lt;strong>觀測執行行為的那一種在這份清單裡沒有位置&lt;/strong>，而跨函式的讀寫順序、某個值必須活過某次操作這類約束，在多數主流型別系統裡產物內沒有位置寫得下（Rust 的 lifetime、typestate 生態是例外）。沿刻度往上找會發現每格都塞不進去，於是被送回起點寫一行註解——而它真正的落點是一條普通的行為測試（&lt;a href="https://tarrragon.github.io/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束&lt;/a>）。&lt;/p>
&lt;h2 id="文件層約束的失效模式">文件層約束的失效模式&lt;/h2>
&lt;p>文件層約束的失效是靜默的，而且失效證據會累積在遠離規則文字的地方。一個書籍管理 App 的兩條文件層約束都失效了：entity 的狀態轉換方法註解宣稱只能從特定狀態轉換、以確保狀態流程正確，實作裡沒有任何檢查——grep 計數是零；「狀態轉換請走領域方法」是團隊慣例，public 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>這個案例暴露文件層的兩個結構性弱點。第一、註解宣稱約束會製造假防護感——讀者以為有防護、於是信任了實際上無人看守的路徑。第二、文件層約束跟便利工具並存時，勝出的是工具：規範說走領域方法、生態的預設路徑給出全欄位覆寫、IDE 補全第一個跳出來的就是它。規範與預設衝突時、預設會贏。通用推論：一條規則若違反時靜默、事後才以資料異常浮現，它停在文件層的每一天都在累積無法回溯的洞。&lt;/p>
&lt;h2 id="型別層把約束做進介面">型別層：把約束做進介面&lt;/h2>
&lt;p>型別層強制的形式是讓介面簽名替規則說話：正確的用法寫得出來、錯誤的用法寫不出來。一個 POS 專案的結帳模型把這件事做進了簽名。業務規則要求會員身分、計價、支付方式三者一起換（會員用會員價且限會員資產支付、非會員相反）。模型把切換收成單一方法、把「新的支付方式」設計成必填參數——呼叫端無法「只換會員、支付方式以後再說」，簽名本身就把「兩者要一起決定」寫死了。會員與支付方式在同一次狀態更新內寫入（實收金額的重設接在其後），響應式 UI 的訂閱者永遠看不到只換了一半的組合（&lt;a href="https://tarrragon.github.io/blog/work-log/pos_member_pricing_payment_atomic_switch/" data-link-title="會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換" data-link-desc="多個狀態欄位被同一條業務規則綁住時，分開的 setter 會製造不一致的中間態；把切換收成單一方法、一次狀態更新內同步全部欄位，並注意衍生值重算的順序。以 POS 結帳的會員登出重算為例，含不變式收進 model 的 canCheckout 設計。">會員身分、計價、支付方式必須一起換&lt;/a>）。&lt;/p>
&lt;p>對照組是分開的 setter：規則變成「每個呼叫端自己記得兩個都呼叫、而且順序對」——回到文件層。這條對照給出型別層的可操作模式：被同一條規則綁住的欄位群、對外只暴露一個原子的切換方法；「必須一起提供」的資訊做成必填參數；欄位群裡有衍生值時、重算收在同一個方法尾端（來源先、衍生後）、順序就無法在呼叫點被顛倒。同族的另一個型別層手法是 exhaustive switch：分類完整性交給編譯器、新增成員時遺漏歸類是編譯錯誤（見 &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> 的枚舉分層段）；語意封閉的 domain type（合法運算之外的介面根本沒有）也屬這一層。&lt;/p>
&lt;p>型別層的邊界要誠實標明：它防止的是無心誤用。反射、dynamic、顯式拆封都繞得過去——威脅模型是「讓正確的寫法比錯誤的寫法省力」，防刻意繞過要靠 review 與執行層。&lt;/p>
&lt;h2 id="執行層建構期不變式">執行層：建構期不變式&lt;/h2>
&lt;p>執行層強制的標準形態是建構期不變式：物件在出生的那一刻就必須合法、違反的建構當場失敗。上述書籍管理 App 把錯誤分類建成這個形態：每個錯誤代碼隸屬一個技術分類（business / network / storage / platform / validation）、exception 型別的建構要求代碼屬於對應分類。這層不變式工作的證據是一批測試失敗——六個失敗全部指向真實的分類錯誤：storage 例外用了 platform 分類的代碼、業務例外家族混進了 network 分類的代碼（&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;/p>
&lt;p>對照沒有不變式的世界：分類錯亂靜默流通、要等某天有人按分類統計錯誤或決定重試策略時、才以錯誤行為浮現。建構期不變式把「錯亂發生的時刻」跟「錯亂被發現的時刻」壓成同一刻，這是執行層的核心價值：失效點集中在建構處、錯誤訊息直接指向規則本身。下游拿到實例的任何程式碼、都可以信任不變式已成立——防禦性檢查的需求隨之消失。&lt;/p>
&lt;p>建構期不變式有一條要預先想好的邊界：物件的建構有兩條路徑——新建（走工廠與建構子、驗全部不變式）與持久化回讀（從資料庫或事件流重建已經存在的物件）。不變式收緊之後，存量資料是用舊規則寫入的，回讀路徑套新規則會讓歷史物件建不出來；處置要嘛跑資料遷移、要嘛讓回讀路徑信任已持久化的狀態、跳過新建路徑的驗證。新建路徑的工廠設計在 &lt;a href="https://tarrragon.github.io/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a> 展開；持久化回讀路徑的完整邊界屬 entity 持久化與遷移的主題（模組 backlog）。&lt;/p></description><content:encoded><![CDATA[<p>不變式是在物件整個生命週期都必須為真的業務規則：狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換、錯誤代碼必須屬於對應分類。本章的作用域是單一物件的不變式——跨物件的一致性（aggregate 邊界、「交易完成時必須成立、執行中間允許暫時違反」的時點語意）是另一個層次的主題，等 case 累積後另章展開。模組源頭句「讓違反規則的路徑走不通」在本章落到最直接的決策：同一條規則在應用程式碼內可以落在文件層、型別層或執行層，層次決定違反規則時發生什麼——靜默通過、編譯失敗、還是當場拒絕。型別的類別（<a href="/blog/ddd/data-bag-vs-domain-model/" 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>
<h2 id="三個層次的差異">三個層次的差異</h2>
<p>文件層把規則寫成註解、命名、慣例與規範文件，依靠讀者記得並自律。型別層把規則做進介面簽名與型別系統，違反的程式碼無法通過編譯——規則錯的程式根本產不出來。執行層把規則做成建構子與領域方法內的檢查，違反在 runtime 的當下被拒絕，錯誤有明確的發生點與訊息。</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>載體</th>
          <th>違反時發生什麼</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>文件層</td>
          <td>註解、命名、慣例</td>
          <td>靜默通過、事後在別處浮現</td>
          <td>寫下來最便宜、失效最昂貴</td>
      </tr>
      <tr>
          <td>型別層</td>
          <td>介面簽名、型別系統</td>
          <td>編譯失敗</td>
          <td>設計介面要花心思、編譯期攔截無心誤用</td>
      </tr>
      <tr>
          <td>執行層</td>
          <td>建構子、方法內檢查</td>
          <td>runtime 當場拒絕</td>
          <td>要寫檢查與測試、失效點集中</td>
      </tr>
  </tbody>
</table>
<p>三層的選擇是「這條規則的違反代價」對「這一層的建置成本」的折算、層次高低本身沒有優劣排序。折算的變數包含團隊規模、人員流動率與專案壽命：小而穩定的團隊靠 code review 攔截誤用是可承受的選擇；人一多、流動一快，同樣的慣例就守不住——違反代價沒變、失效機率變了。狀態轉換與稽核這類違反後靜默出洞的規則，值得推到型別層或執行層；一次性的輸入格式問題留在執行層的驗證流程就足夠；真正只能靠慣例的（命名風格、檔案組織）才留在文件層——文件層是最後的選擇、而不是預設的起點。</p>
<p>這三層涵蓋的是應用程式碼內的落點，實務上還有兩個常見的層。資料庫約束（NOT NULL、外鍵、unique index）攔得住所有寫入者——含手工 SQL 與其他服務；「email 不得重複」這類跨物件的唯一性規則，任何建構子或簽名都表達不了、併發下的可靠落點只有它，資料庫層的能力屬 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend</a> 模組的範圍。CI 檢查（architecture test、lint）把慣例類規則升級成「合併前擋下」、強度介於文件層與型別層之間。本章的三層判準作用在單物件規則上；規則跨出單一物件時，先想這兩層。跨到應用程式的組裝層時——「use case 的每個入口在 production 可達」這類不變式——強制層選擇見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。</p>
<p>這五個位置沿強度排列，而強度其實是兩件事的合成。第一件是<strong>規則寫在哪</strong>：註解、介面簽名、建構子檢查、schema 約束都寫進被約束的產物裡，lint 設定與 CI 規則寫在產物外面。第二件是<strong>違反時何時發聲</strong>：文件層永不發聲，型別層在編譯當下，執行層與資料庫在寫入當下，CI 在合併之前。這兩件事獨立變化——文件層與型別層同樣寫在產物內，發聲能力卻是零與編譯期的差距。</p>
<p>把兩條軸壓成一條的代價是產物外那一側只剩一格。CI 那格裡的 lint 與 architecture test 都是讀程式文本的檢查：它們掃原始碼長什麼樣，不掃程式跑起來會怎樣。<strong>觀測執行行為的那一種在這份清單裡沒有位置</strong>，而跨函式的讀寫順序、某個值必須活過某次操作這類約束，在多數主流型別系統裡產物內沒有位置寫得下（Rust 的 lifetime、typestate 生態是例外）。沿刻度往上找會發現每格都塞不進去，於是被送回起點寫一行註解——而它真正的落點是一條普通的行為測試（<a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253 寫註解的動機是怕被改壞時要處理的是那個約束</a>）。</p>
<h2 id="文件層約束的失效模式">文件層約束的失效模式</h2>
<p>文件層約束的失效是靜默的，而且失效證據會累積在遠離規則文字的地方。一個書籍管理 App 的兩條文件層約束都失效了：entity 的狀態轉換方法註解宣稱只能從特定狀態轉換、以確保狀態流程正確，實作裡沒有任何檢查——grep 計數是零；「狀態轉換請走領域方法」是團隊慣例，public 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>這個案例暴露文件層的兩個結構性弱點。第一、註解宣稱約束會製造假防護感——讀者以為有防護、於是信任了實際上無人看守的路徑。第二、文件層約束跟便利工具並存時，勝出的是工具：規範說走領域方法、生態的預設路徑給出全欄位覆寫、IDE 補全第一個跳出來的就是它。規範與預設衝突時、預設會贏。通用推論：一條規則若違反時靜默、事後才以資料異常浮現，它停在文件層的每一天都在累積無法回溯的洞。</p>
<h2 id="型別層把約束做進介面">型別層：把約束做進介面</h2>
<p>型別層強制的形式是讓介面簽名替規則說話：正確的用法寫得出來、錯誤的用法寫不出來。一個 POS 專案的結帳模型把這件事做進了簽名。業務規則要求會員身分、計價、支付方式三者一起換（會員用會員價且限會員資產支付、非會員相反）。模型把切換收成單一方法、把「新的支付方式」設計成必填參數——呼叫端無法「只換會員、支付方式以後再說」，簽名本身就把「兩者要一起決定」寫死了。會員與支付方式在同一次狀態更新內寫入（實收金額的重設接在其後），響應式 UI 的訂閱者永遠看不到只換了一半的組合（<a href="/blog/work-log/pos_member_pricing_payment_atomic_switch/" data-link-title="會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換" data-link-desc="多個狀態欄位被同一條業務規則綁住時，分開的 setter 會製造不一致的中間態；把切換收成單一方法、一次狀態更新內同步全部欄位，並注意衍生值重算的順序。以 POS 結帳的會員登出重算為例，含不變式收進 model 的 canCheckout 設計。">會員身分、計價、支付方式必須一起換</a>）。</p>
<p>對照組是分開的 setter：規則變成「每個呼叫端自己記得兩個都呼叫、而且順序對」——回到文件層。這條對照給出型別層的可操作模式：被同一條規則綁住的欄位群、對外只暴露一個原子的切換方法；「必須一起提供」的資訊做成必填參數；欄位群裡有衍生值時、重算收在同一個方法尾端（來源先、衍生後）、順序就無法在呼叫點被顛倒。同族的另一個型別層手法是 exhaustive switch：分類完整性交給編譯器、新增成員時遺漏歸類是編譯錯誤（見 <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> 的枚舉分層段）；語意封閉的 domain type（合法運算之外的介面根本沒有）也屬這一層。</p>
<p>型別層的邊界要誠實標明：它防止的是無心誤用。反射、dynamic、顯式拆封都繞得過去——威脅模型是「讓正確的寫法比錯誤的寫法省力」，防刻意繞過要靠 review 與執行層。</p>
<h2 id="執行層建構期不變式">執行層：建構期不變式</h2>
<p>執行層強制的標準形態是建構期不變式：物件在出生的那一刻就必須合法、違反的建構當場失敗。上述書籍管理 App 把錯誤分類建成這個形態：每個錯誤代碼隸屬一個技術分類（business / network / storage / platform / validation）、exception 型別的建構要求代碼屬於對應分類。這層不變式工作的證據是一批測試失敗——六個失敗全部指向真實的分類錯誤：storage 例外用了 platform 分類的代碼、業務例外家族混進了 network 分類的代碼（<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式</a>）。</p>
<p>對照沒有不變式的世界：分類錯亂靜默流通、要等某天有人按分類統計錯誤或決定重試策略時、才以錯誤行為浮現。建構期不變式把「錯亂發生的時刻」跟「錯亂被發現的時刻」壓成同一刻，這是執行層的核心價值：失效點集中在建構處、錯誤訊息直接指向規則本身。下游拿到實例的任何程式碼、都可以信任不變式已成立——防禦性檢查的需求隨之消失。</p>
<p>建構期不變式有一條要預先想好的邊界：物件的建構有兩條路徑——新建（走工廠與建構子、驗全部不變式）與持久化回讀（從資料庫或事件流重建已經存在的物件）。不變式收緊之後，存量資料是用舊規則寫入的，回讀路徑套新規則會讓歷史物件建不出來；處置要嘛跑資料遷移、要嘛讓回讀路徑信任已持久化的狀態、跳過新建路徑的驗證。新建路徑的工廠設計在 <a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a> 展開；持久化回讀路徑的完整邊界屬 entity 持久化與遷移的主題（模組 backlog）。</p>
<h2 id="不變式被撞需求違規與約束錯形">不變式被撞：需求違規與約束錯形</h2>
<p>不變式開始工作之後、遲早會被撞，撞上時第一件事是分辨兩種病因：需求違規、還是約束錯形。判準看繞過方的動機——繞過方在找便利（省掉領域方法、直接改狀態），是需求違規、修繞過方；繞過方有現有約束無法表達的正當語意，是約束錯形、修約束。動機不可考時（繞過者已離開、變更沒有留下說明），改看約束的表達力：現有約束內有沒有語意等價的合法選項——有、多半是找便利；沒有、是約束錯形。</p>
<p>上述錯誤分類案例把兩種病因擺在同一批修復裡：一部分失敗是選錯代碼、正確分類裡本來就有語意等價的代碼、換過去就修好——以本章的分辨來看、這是需求違規裡最輕的形態（病因是選碼時沒查分類表、修繞過方的成本極低）；但匯入流程的 exception 遇到的限制不同——匯入錯誤的來源橫跨多種技術分類（解析壞是 validation、來源伺服器錯是 network、寫檔失敗是 storage），它綁定的單一分類裡根本沒有它需要的代碼。這是約束錯形：分類軸（技術來源）跟 exception 階層軸（業務流程）互相獨立，把業務流程的 exception 綁死在單一技術分類上、約束跟現實的形狀不合。當下合比例的處置是讓該 exception 改掛不綁分類的基類、並把分類學的不合寫成決策記錄。</p>
<p>分辨錯了、兩邊都要付出代價。把約束錯形當需求違規最傷：正當需求被迫用越來越彆扭的方式繞行、每次繞行再被當成新的違規；反向的誤判則讓約束被逐次放寬到失去意義。被撞是不變式的正常生命週期事件——約束會工作、也會被合法需求撞，設計時就要預留「這條約束錯了怎麼改」的路徑。上述案例的處置就是這條路徑的現成形態：一個不綁分類的基類作為合法的逃生位置、加一份決策記錄讓下一個遇到同樣限制的人知道分類學的缺口在哪。</p>
<h2 id="強制的邊界存在條件與輸入品質">強制的邊界：存在條件與輸入品質</h2>
<p>執行層的建構不變式有一條精確的邊界：它守「這個物件能不能存在」、而使用者輸入的品質問題屬於另一層。同一個 App 的查詢輸入實作把這條邊界暴露了出來：value object 的建構不變式要求至少一個查詢參數非空（四個欄位全空的「查詢」在語意上根本不是查詢、這種物件不該存在）；ISBN 格式、欄位長度這類規則放在無狀態的 validator、回傳結構化的驗證結果——錯誤碼、本地化訊息、標準化後的值（<a href="/blog/work-log/flutter_domain_input_validation_placement/" data-link-title="「978ABC」被拒的理由寫著長度不對 — 驗證的兩層分工與順序陷阱" data-link-desc="輸入驗證有兩層職責：建構期不變式守「這個物件能不能存在」、無狀態 validator 守「使用者輸入對不對」，混在一起會讓測試建不出 fixture、錯誤訊息歸錯類。順序陷阱：先標準化再檢查等於先銷毀證據再診斷——含字母的 ISBN 被削成三位數、錯誤訊息說長度不對。">驗證的兩層分工與順序陷阱</a>）。</p>
<p>分工判準收成一句：違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator。前者失敗是程式錯誤——哪段程式碼試圖建一個不合法的物件；後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向現形：格式驗證塞進建構子、UI 層要 try-catch 例外再翻譯成欄位錯誤、結構化的錯誤資訊全部丟失；存在條件放進 validator、全空的物件能在系統裡流通、每個消費者都要自己防。實作上的訊號明確：測試建不出想要的 fixture、被建構驗證擋住——通常就是兩層規則混在同一層的時刻。</p>
<p>這條邊界補完三層選擇的最後一塊：把約束推向型別層與執行層的原則、作用對象是領域規則；面向使用者的輸入品質要的是好的錯誤回報、而不是走不通的路徑——對它套用建構期不變式反而毀掉回報能力。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>註解或規範宣稱一條約束、實作裡 grep 得到零個對應檢查——文件層約束正在靜默失效，按違反代價決定上移到哪一層。</li>
<li>寫下那條註解的動機是「怕有人改壞它」——防護需求送錯了窗口，先問這個約束能不能被消除、再問誰來守（<a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253</a>）。</li>
<li>當一條規則的正確執行依賴「每個呼叫端記得做兩件事、而且順序對」，它實際上停在文件層：收成單一原子方法、必要資訊做成必填參數。</li>
<li>分類、狀態這類規則只存在於命名慣例——錯亂正在靜默累積，第一個按分類做統計或分支處置的功能會揭開它。</li>
<li>「改繼承（或改型別、放寬約束）來讓建構通過」的修法出現——先分辨需求違規還是約束錯形、再決定修哪一方，是後者就把約束的錯形寫成決策記錄。</li>
<li>測試建不出想測的 fixture、被建構驗證擋住：先釐清是存在條件與輸入品質混在同一層、還是工廠表達力不足逼測試繞道（後者的機制見 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>）。</li>
<li>不變式收緊後、持久化回讀開始拋建構錯誤——存量資料與新規則的落差沒被處理，先分資料遷移還是回讀路徑放行。</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>規則落點之前的兩個判定：<a href="/blog/ddd/data-bag-vs-domain-model/" 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></li>
<li>變更路徑的收斂：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>建構路徑的設計：<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a></li>
<li>規則跨出單一物件、抬到應用程式的組裝層：<a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></li>
<li>原則層：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a></li>
<li>Dart / Flutter 的實作細節（required 參數與 Rx 狀態流、exception 階層、validator 結構）：<a href="/blog/work-log/pos_member_pricing_payment_atomic_switch/" data-link-title="會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換" data-link-desc="多個狀態欄位被同一條業務規則綁住時，分開的 setter 會製造不一致的中間態；把切換收成單一方法、一次狀態更新內同步全部欄位，並注意衍生值重算的順序。以 POS 結帳的會員登出重算為例，含不變式收進 model 的 canCheckout 設計。">會員身分、計價、支付方式必須一起換</a>、<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式</a>、<a href="/blog/work-log/flutter_domain_input_validation_placement/" data-link-title="「978ABC」被拒的理由寫著長度不對 — 驗證的兩層分工與順序陷阱" data-link-desc="輸入驗證有兩層職責：建構期不變式守「這個物件能不能存在」、無狀態 validator 守「使用者輸入對不對」，混在一起會讓測試建不出 fixture、錯誤訊息歸錯類。順序陷阱：先標準化再檢查等於先銷毀證據再診斷——含字母的 ISBN 被削成三位數、錯誤訊息說長度不對。">驗證的兩層分工與順序陷阱</a></li>
</ul>
]]></content:encoded></item><item><title>狀態轉換與稽核軌跡</title><link>https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/</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>），但物件出生之後還會被變更。DDD 的核心精神——讓違反規則的路徑走不通——在變更路徑上折算成一個問題：狀態欄位有沒有流程約束、變更有沒有需要留痕的伴隨動作？有，變更就要收斂到領域方法，其餘路徑關閉。本章的作用域跟 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 同在單一物件內：跨物件一致性（aggregate 邊界、事務語意）是另一個層次的主題。&lt;/p>
&lt;h2 id="領域方法承擔什麼">領域方法承擔什麼&lt;/h2>
&lt;p>領域方法在一次呼叫裡承擔三件事：表達業務意圖（方法名本身是業務事件的動詞）、檢查轉換條件（當前狀態是否允許這次轉換）、寫入稽核紀錄（這次變更的內容、時間、來源）。三件事原子完成——呼叫端只表達「做這件事」，方法自己保證條件成立且紀錄同步寫入。&lt;/p>
&lt;p>一個書籍管理 App 的 &lt;code>Book&lt;/code> entity 帶一組狀態轉換方法（開始豐富化、完成豐富化、標記可用），每個方法往 &lt;code>modificationHistory&lt;/code> 追加一筆變更紀錄。這個專案的方法完成了其中兩件（意圖表達與紀錄寫入），第三件（轉換條件檢查）只停在註解——方法體內 grep 不到任何對應檢查，是 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 展開的文件層失效案例（&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;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>對一個受規則約束的欄位，變更只有兩種可能的強度。唯一路徑：領域方法之外沒有 public 介面可以改這個欄位、變更只能走方法。建議路徑：方法之外有其他途徑可以改（逐欄位覆寫工具、public setter）、規則靠慣例說「請走方法」。前者是型別層或執行層的強制（&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 的分層判準），後者停在文件層。兩者之間有一個常見的折衷：保留覆寫工具但從參數列移除受約束的欄位——比完全唯一的改造成本低、比建議路徑的保護強。&lt;/p>
&lt;p>判準是一個問題：這個欄位的變更有沒有需要一起完成的伴隨動作？常見的伴隨動作包含稽核紀錄寫入、衍生值重算、狀態流程條件檢查——任何需要隨變更同步完成的動作都算。有任何一種——變更路徑收進領域方法、欄位的 public 寫入介面關閉。沒有——逐欄位覆寫工具是正當的便利、加上領域方法的儀式只會製造沒有伴隨動作可做的 boilerplate。&lt;/p>
&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>領域方法承擔轉換條件檢查——其中一類常見的條件是&lt;strong>方向約束&lt;/strong>：狀態對應的現實動作不可逆時，模型層的入口守則強制單調（同值與回退一律拒絕）。餐點端出去收不回來、貨物已出庫不可反向入庫——這類狀態機的形狀是遞增序列加上從中途岔出的終態側分支，小到一張表就能窮舉（&lt;a href="https://tarrragon.github.io/blog/work-log/pos_monotonic_status_optimistic_rollback/" data-link-title="單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態" data-link-desc="POS App 的品項處理狀態只能遞增——現實世界的動作不可逆，狀態機跟著不可逆。樂觀更新讓 UI 先行，但後端拒絕時必須回滾：因為這個狀態是其他防護規則的資料來源，前台多顯示一格進度，防護就會在錯誤的前提上放行。">單調狀態機與樂觀更新回滾&lt;/a>）。&lt;/p>
&lt;p>方向約束的設計責任集中在一個入口方法裡：同值拒絕（防重複訊息）、回退拒絕（防事件亂序與 UI 誤觸）、終態後禁入（防已交付的品項被取消）。把這些守則散在各呼叫端的 if 檢查，就回到文件層——跟上一節的變更路徑判準同一個推導：有伴隨動作（方向檢查）的欄位，變更收進領域方法。&lt;/p>
&lt;p>方向約束還有一個延伸場景：樂觀更新。前端先改本地狀態（UI 立即反映）、再同步後端、後端拒絕才回滾。回滾是否必須立即執行，判準在於&lt;strong>這個狀態有沒有下游讀者&lt;/strong>。狀態只供畫面顯示——失敗提示加下次同步自然修正即可；狀態被防護規則或流程分支讀取——回滾是硬契約，分裂狀態（前台顯示已完成、後端沒有記錄）會讓規則在錯誤前提上做決策。回滾測試的斷言要寫依賴鏈的後果（「前台不得顯示後端沒記錄的狀態，否則守衛判錯」），把契約的動機放進測試資產。&lt;/p>
&lt;h2 id="稽核軌跡怎麼出洞">稽核軌跡怎麼出洞&lt;/h2>
&lt;p>兩條變更路徑並存——領域方法有紀錄、工具方法沒紀錄——是稽核軌跡出洞的機制、而出洞是靜默的：沒有任何錯誤、警告或測試失敗會告訴你紀錄缺了一段。&lt;/p>
&lt;p>上述書籍管理 App 暴露了完整的失效路徑。&lt;code>Book&lt;/code> 同時有領域方法與一個 public 的 copyWith、而 copyWith 的參數列包含 &lt;code>status&lt;/code> 和 &lt;code>modificationHistory&lt;/code>。工廠層直接用 &lt;code>copyWith(status: BookStatus.available)&lt;/code> 改狀態、繞過了 &lt;code>markAsAvailable()&lt;/code> 方法——這些狀態轉換沒有進入稽核紀錄。更具體的證據：同專案的一個測試用 copyWith 改 readingStatus、期待 modificationHistory 出現兩條紀錄——實際只有一條。連寫測試的人都把 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>兩條路徑（領域方法有紀錄、工具方法沒紀錄）並存在同一個 public 介面上。每個呼叫端必須自己記得走哪條——而「要記得」是文件層的強度。失效只是時間問題、失效的形式是稽核紀錄上的洞：某段狀態變化沒有留下任何紀錄，而這個事實要在事故回溯、或有人按歷史紀錄做報表的那一天才浮現——通常距離寫入已經很久。稽核軌跡出洞跟型別安全出洞有一個關鍵差異：型別錯誤在編譯期或 runtime 報錯、有明確的發生時刻；稽核缺口在洞產生的當下完全沒有訊號。&lt;/p>
&lt;p>收斂的操作化：稽核紀錄或狀態流程欄位出現在任何 public 寫入介面的參數列——從參數列移除、收進領域方法。這也是 &lt;a href="https://tarrragon.github.io/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">約束要讓違反路徑走不通&lt;/a> 的一個具體形態：稽核欄位出現在 public 寫入介面就是一致性不受保護的訊號。領域方法成為唯一路徑之後、每一條稽核紀錄都有一個業務動詞作為來源、紀錄的完整性由介面的形狀保證而不是由呼叫端的記憶保證。&lt;/p>
&lt;h2 id="凍結作為稽核的端點">凍結作為稽核的端點&lt;/h2>
&lt;p>稽核不只是記錄「狀態怎麼變的」，還要記錄「變更當時的世界長什麼樣」。歷史事實持有 live 參照會漂移——稽核紀錄寫的是「下單時買了商品 A」、而商品後續改了名、歷史訂單顯示的就不再是事實。&lt;/p>
&lt;p>一個 POS 專案的品項模型在結帳完成後凍結商品與價格 &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/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;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> 展開；本節從稽核面到達同一個結論：歷史記錄反映事件發生當下的世界、不是現在的世界。凍結時機由「這筆資料何時成為事實」決定——進行中的購物車品項持 live 參照是正確的（會員身分改變、價格即時跟著變）；成為歷史訂單的那一刻凍結。業務流程有多個確認階段（冷靜期、取消窗口）時，凍結時機取決於哪個階段之後的漂移對下游不可接受。&lt;/p></description><content:encoded><![CDATA[<p>不變式讓物件出生合法（<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>），但物件出生之後還會被變更。DDD 的核心精神——讓違反規則的路徑走不通——在變更路徑上折算成一個問題：狀態欄位有沒有流程約束、變更有沒有需要留痕的伴隨動作？有，變更就要收斂到領域方法，其餘路徑關閉。本章的作用域跟 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 同在單一物件內：跨物件一致性（aggregate 邊界、事務語意）是另一個層次的主題。</p>
<h2 id="領域方法承擔什麼">領域方法承擔什麼</h2>
<p>領域方法在一次呼叫裡承擔三件事：表達業務意圖（方法名本身是業務事件的動詞）、檢查轉換條件（當前狀態是否允許這次轉換）、寫入稽核紀錄（這次變更的內容、時間、來源）。三件事原子完成——呼叫端只表達「做這件事」，方法自己保證條件成立且紀錄同步寫入。</p>
<p>一個書籍管理 App 的 <code>Book</code> entity 帶一組狀態轉換方法（開始豐富化、完成豐富化、標記可用），每個方法往 <code>modificationHistory</code> 追加一筆變更紀錄。這個專案的方法完成了其中兩件（意圖表達與紀錄寫入），第三件（轉換條件檢查）只停在註解——方法體內 grep 不到任何對應檢查，是 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 展開的文件層失效案例（<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>三件事如果拆開給不同入口做——一個方法改狀態、另一處補紀錄——一致性就回到文件層，靠每個呼叫端記得兩者都做、且順序正確。<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 展開過同一個模式：被同一條規則綁住的欄位群對外只暴露一個原子的切換方法、必要資訊做成必填參數。把變更路徑合併到單一方法是同一原則在時間軸上的延伸——不只是「欄位一起換」，而是「狀態轉換、條件檢查、稽核紀錄一起完成」。</p>
<h2 id="唯一路徑與建議路徑">唯一路徑與建議路徑</h2>
<p>對一個受規則約束的欄位，變更只有兩種可能的強度。唯一路徑：領域方法之外沒有 public 介面可以改這個欄位、變更只能走方法。建議路徑：方法之外有其他途徑可以改（逐欄位覆寫工具、public setter）、規則靠慣例說「請走方法」。前者是型別層或執行層的強制（<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 的分層判準），後者停在文件層。兩者之間有一個常見的折衷：保留覆寫工具但從參數列移除受約束的欄位——比完全唯一的改造成本低、比建議路徑的保護強。</p>
<p>判準是一個問題：這個欄位的變更有沒有需要一起完成的伴隨動作？常見的伴隨動作包含稽核紀錄寫入、衍生值重算、狀態流程條件檢查——任何需要隨變更同步完成的動作都算。有任何一種——變更路徑收進領域方法、欄位的 public 寫入介面關閉。沒有——逐欄位覆寫工具是正當的便利、加上領域方法的儀式只會製造沒有伴隨動作可做的 boilerplate。</p>
<p>判準用錯的兩個方向代價相反。把唯一路徑設計成建議路徑：規則退化成慣例、稽核軌跡開始出洞（下一節展開）。反過來、把沒有伴隨動作的欄位硬收進領域方法：每次改值都要穿過一層沒有意義的方法呼叫、而且方法名要替一個純粹的換值操作擠出業務動詞。<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a> 判定型別整體是資料袋還是領域模型的判準在這裡有回聲——判定為資料袋的型別、每個欄位都沒有伴隨動作、限縮變更路徑是不必要的。</p>
<h2 id="單向狀態約束與樂觀更新的回滾">單向狀態約束與樂觀更新的回滾</h2>
<p>領域方法承擔轉換條件檢查——其中一類常見的條件是<strong>方向約束</strong>：狀態對應的現實動作不可逆時，模型層的入口守則強制單調（同值與回退一律拒絕）。餐點端出去收不回來、貨物已出庫不可反向入庫——這類狀態機的形狀是遞增序列加上從中途岔出的終態側分支，小到一張表就能窮舉（<a href="/blog/work-log/pos_monotonic_status_optimistic_rollback/" data-link-title="單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態" data-link-desc="POS App 的品項處理狀態只能遞增——現實世界的動作不可逆，狀態機跟著不可逆。樂觀更新讓 UI 先行，但後端拒絕時必須回滾：因為這個狀態是其他防護規則的資料來源，前台多顯示一格進度，防護就會在錯誤的前提上放行。">單調狀態機與樂觀更新回滾</a>）。</p>
<p>方向約束的設計責任集中在一個入口方法裡：同值拒絕（防重複訊息）、回退拒絕（防事件亂序與 UI 誤觸）、終態後禁入（防已交付的品項被取消）。把這些守則散在各呼叫端的 if 檢查，就回到文件層——跟上一節的變更路徑判準同一個推導：有伴隨動作（方向檢查）的欄位，變更收進領域方法。</p>
<p>方向約束還有一個延伸場景：樂觀更新。前端先改本地狀態（UI 立即反映）、再同步後端、後端拒絕才回滾。回滾是否必須立即執行，判準在於<strong>這個狀態有沒有下游讀者</strong>。狀態只供畫面顯示——失敗提示加下次同步自然修正即可；狀態被防護規則或流程分支讀取——回滾是硬契約，分裂狀態（前台顯示已完成、後端沒有記錄）會讓規則在錯誤前提上做決策。回滾測試的斷言要寫依賴鏈的後果（「前台不得顯示後端沒記錄的狀態，否則守衛判錯」），把契約的動機放進測試資產。</p>
<h2 id="稽核軌跡怎麼出洞">稽核軌跡怎麼出洞</h2>
<p>兩條變更路徑並存——領域方法有紀錄、工具方法沒紀錄——是稽核軌跡出洞的機制、而出洞是靜默的：沒有任何錯誤、警告或測試失敗會告訴你紀錄缺了一段。</p>
<p>上述書籍管理 App 暴露了完整的失效路徑。<code>Book</code> 同時有領域方法與一個 public 的 copyWith、而 copyWith 的參數列包含 <code>status</code> 和 <code>modificationHistory</code>。工廠層直接用 <code>copyWith(status: BookStatus.available)</code> 改狀態、繞過了 <code>markAsAvailable()</code> 方法——這些狀態轉換沒有進入稽核紀錄。更具體的證據：同專案的一個測試用 copyWith 改 readingStatus、期待 modificationHistory 出現兩條紀錄——實際只有一條。連寫測試的人都把 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>兩條路徑（領域方法有紀錄、工具方法沒紀錄）並存在同一個 public 介面上。每個呼叫端必須自己記得走哪條——而「要記得」是文件層的強度。失效只是時間問題、失效的形式是稽核紀錄上的洞：某段狀態變化沒有留下任何紀錄，而這個事實要在事故回溯、或有人按歷史紀錄做報表的那一天才浮現——通常距離寫入已經很久。稽核軌跡出洞跟型別安全出洞有一個關鍵差異：型別錯誤在編譯期或 runtime 報錯、有明確的發生時刻；稽核缺口在洞產生的當下完全沒有訊號。</p>
<p>收斂的操作化：稽核紀錄或狀態流程欄位出現在任何 public 寫入介面的參數列——從參數列移除、收進領域方法。這也是 <a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">約束要讓違反路徑走不通</a> 的一個具體形態：稽核欄位出現在 public 寫入介面就是一致性不受保護的訊號。領域方法成為唯一路徑之後、每一條稽核紀錄都有一個業務動詞作為來源、紀錄的完整性由介面的形狀保證而不是由呼叫端的記憶保證。</p>
<h2 id="凍結作為稽核的端點">凍結作為稽核的端點</h2>
<p>稽核不只是記錄「狀態怎麼變的」，還要記錄「變更當時的世界長什麼樣」。歷史事實持有 live 參照會漂移——稽核紀錄寫的是「下單時買了商品 A」、而商品後續改了名、歷史訂單顯示的就不再是事實。</p>
<p>一個 POS 專案的品項模型在結帳完成後凍結商品與價格 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a>——商品後續改名、下架、調價，訂單仍顯示當時購買的內容（<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>）。身份語意的完整凍結判準在 <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> 展開；本節從稽核面到達同一個結論：歷史記錄反映事件發生當下的世界、不是現在的世界。凍結時機由「這筆資料何時成為事實」決定——進行中的購物車品項持 live 參照是正確的（會員身分改變、價格即時跟著變）；成為歷史訂單的那一刻凍結。業務流程有多個確認階段（冷靜期、取消窗口）時，凍結時機取決於哪個階段之後的漂移對下游不可接受。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>型別同時有 public 領域方法與 public 的逐欄位覆寫工具、且覆寫範圍涵蓋狀態或稽核欄位——兩條路徑並存、稽核軌跡正在累積洞。收斂方向：從覆寫工具的參數列移除這些欄位、現有繞過呼叫點改走領域方法。</li>
<li>如果同一個變更操作有時有稽核紀錄有時沒有，先追工廠層或測試 Arrange 段有沒有繞過領域方法的呼叫點。</li>
<li>狀態轉換方法的註解宣稱轉換條件、方法內 grep 不到對應檢查——文件層約束正在靜默失效，判讀見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</li>
<li>多個領域方法各自重複相同的前置檢查或紀錄寫入邏輯——共用的橫切面該抽出來，遺漏一處就是新的洞。</li>
<li>狀態對應不可逆的現實動作、但變更方法沒有方向檢查——同值與回退可以走通、單調約束停在文件層。</li>
<li>樂觀更新後端拒絕後、前台狀態沒有回滾——如果該狀態有下游規則消費它，分裂狀態會讓規則在錯誤前提上運作。</li>
<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>。</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>變更路徑收斂之後、建構路徑本身的設計：<a href="/blog/ddd/construction-path-design/" 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>規則落點的三層選擇：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>領域狀態機投影到畫面入口可見性：<a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></li>
<li>單調狀態機與樂觀更新回滾的完整 case：<a href="/blog/work-log/pos_monotonic_status_optimistic_rollback/" data-link-title="單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態" data-link-desc="POS App 的品項處理狀態只能遞增——現實世界的動作不可逆，狀態機跟著不可逆。樂觀更新讓 UI 先行，但後端拒絕時必須回滾：因為這個狀態是其他防護規則的資料來源，前台多顯示一格進度，防護就會在錯誤的前提上放行。">單調狀態機與樂觀更新回滾</a></li>
<li>跨物件一致性（aggregate 邊界）：模組 backlog</li>
<li>Dart 的語言細節（copyWith 參數列收窄、private 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></li>
<li>原則層：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a></li>
</ul>
]]></content:encoded></item><item><title>DDD 領域驅動設計指南</title><link>https://tarrragon.github.io/blog/ddd/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/</guid><description>&lt;p>DDD 是一種設計精神：把業務規則放進領域模型、讓違反規則的路徑走不通，而不是寫在文件裡請大家遵守。這句話是本模組的源頭句——各章的判準都要能折算回它。這個精神在每種語言會碰到不同的實作限制——Dart 的 copyWith 生態、Go 的零值與組合、TypeScript 的 structural typing——所以本模組只承擔理論與判準層，語言特定的實作細節放在各語言模組，章節末路由過去。判準的敘述以物件導向語言為主要載體；函數式生態的對應形態（opaque type、smart constructor、module 可見性）判準相同、強制的載體不同。&lt;/p>
&lt;h2 id="與其他教材的分工">與其他教材的分工&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend&lt;/a>&lt;/td>
 &lt;td>服務能力層：資料庫、快取、佇列等跨語言後端能力&lt;/td>
 &lt;td>DDD 談模型設計、Backend 談選型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/flutter/" data-link-title="Flutter 實戰指南" data-link-desc="Flutter 與 Dart 的實作層教材：型別設計與語言機制、狀態與渲染、測試策略、工具鏈，從實際專案 case 抽出判準。">Flutter&lt;/a>&lt;/td>
 &lt;td>Dart / Flutter 的語言與框架實作限制&lt;/td>
 &lt;td>本模組理論的 Dart 實作對照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go&lt;/a>&lt;/td>
 &lt;td>Go 語言精神與工程實踐&lt;/td>
 &lt;td>本模組理論的 Go 實作對照&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design&lt;/a>&lt;/td>
 &lt;td>畫面狀態設計&lt;/td>
 &lt;td>畫面狀態機與領域狀態機的邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>路由方向是單向的：本模組的理論不依賴任何語言實作作為理解前提；語言模組引用本模組建立概念地基。&lt;/p>
&lt;h2 id="學習路線">學習路線&lt;/h2>
&lt;p>已成章的部分有一條主梯：型別的入口判準 → 身份與內容 → 規則落點 → 變更路徑 → 建構路徑 → 組裝層；讀側、觀測與事件另成一支：觀測出口 → 讀模型 → 事件與狀態流 → 事件與命令、查詢。這一支四章共用同一條 meta 判準——歸屬由事物自身的本質決定（介面看表達語言、查詢看回傳形狀、通知看時態語意、訊息看責任結構），不由需求來源或現成系統的方便性決定。依目的四條路線：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>路線&lt;/th>
 &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;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" 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;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&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/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>&lt;/td>
 &lt;td>能判定一個型別要不要模型化、規則落哪層、變更與建構路徑怎麼收斂、組裝怎麼驗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>讀側與觀測&lt;/td>
 &lt;td>畫面刷新靠補償、repository 長滿查詢方法、事件被當刷新訊號&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢&lt;/a>&lt;/td>
 &lt;td>能判定變更通知的三層歸屬、讀側該停在階梯哪一階、事件對狀態流／命令／查詢的邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試證言&lt;/td>
 &lt;td>測試全綠但功能失聯、驗收條款設計&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a> → &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> → 各章「下一步」對應的 work-log case&lt;/td>
 &lt;td>能區分行為測試與接線測試的證言範圍、把可達性寫進 use case 完成定義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>術語地基&lt;/td>
 &lt;td>讀章節前先補共同語言&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="DDD 教學章節引用的領域建模術語：用原子化卡片建立共同語言">knowledge cards&lt;/a> → 回模型設計主梯&lt;/td>
 &lt;td>能用卡片語言描述模型設計的責任分佈&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="backlog">Backlog&lt;/h2>
&lt;p>目前沒有待辦項。上一項（Shared Mutable State 卡）已於 2026-08-07 完成——建卡動機來自 &lt;a href="https://tarrragon.github.io/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253&lt;/a> 的「這個約束能不能被消除」那一步整個承重在這個術語上，而全站四處提及它的地方沒有一處定義它。&lt;/p></description><content:encoded><![CDATA[<p>DDD 是一種設計精神：把業務規則放進領域模型、讓違反規則的路徑走不通，而不是寫在文件裡請大家遵守。這句話是本模組的源頭句——各章的判準都要能折算回它。這個精神在每種語言會碰到不同的實作限制——Dart 的 copyWith 生態、Go 的零值與組合、TypeScript 的 structural typing——所以本模組只承擔理論與判準層，語言特定的實作細節放在各語言模組，章節末路由過去。判準的敘述以物件導向語言為主要載體；函數式生態的對應形態（opaque type、smart constructor、module 可見性）判準相同、強制的載體不同。</p>
<h2 id="與其他教材的分工">與其他教材的分工</h2>
<table>
  <thead>
      <tr>
          <th>教材</th>
          <th>承擔什麼</th>
          <th>本模組的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend</a></td>
          <td>服務能力層：資料庫、快取、佇列等跨語言後端能力</td>
          <td>DDD 談模型設計、Backend 談選型</td>
      </tr>
      <tr>
          <td><a href="/blog/flutter/" data-link-title="Flutter 實戰指南" data-link-desc="Flutter 與 Dart 的實作層教材：型別設計與語言機制、狀態與渲染、測試策略、工具鏈，從實際專案 case 抽出判準。">Flutter</a></td>
          <td>Dart / Flutter 的語言與框架實作限制</td>
          <td>本模組理論的 Dart 實作對照</td>
      </tr>
      <tr>
          <td><a href="/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go</a></td>
          <td>Go 語言精神與工程實踐</td>
          <td>本模組理論的 Go 實作對照</td>
      </tr>
      <tr>
          <td><a href="/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design</a></td>
          <td>畫面狀態設計</td>
          <td>畫面狀態機與領域狀態機的邊界</td>
      </tr>
  </tbody>
</table>
<p>路由方向是單向的：本模組的理論不依賴任何語言實作作為理解前提；語言模組引用本模組建立概念地基。</p>
<h2 id="學習路線">學習路線</h2>
<p>已成章的部分有一條主梯：型別的入口判準 → 身份與內容 → 規則落點 → 變更路徑 → 建構路徑 → 組裝層；讀側、觀測與事件另成一支：觀測出口 → 讀模型 → 事件與狀態流 → 事件與命令、查詢。這一支四章共用同一條 meta 判準——歸屬由事物自身的本質決定（介面看表達語言、查詢看回傳形狀、通知看時態語意、訊息看責任結構），不由需求來源或現成系統的方便性決定。依目的四條路線：</p>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>適合情境</th>
          <th>建議順序</th>
          <th>讀完能做什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模型設計主梯</td>
          <td>從零建立領域模型的設計判準</td>
          <td><a href="/blog/ddd/data-bag-vs-domain-model/" 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> → <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> → <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></td>
          <td>能判定一個型別要不要模型化、規則落哪層、變更與建構路徑怎麼收斂、組裝怎麼驗</td>
      </tr>
      <tr>
          <td>讀側與觀測</td>
          <td>畫面刷新靠補償、repository 長滿查詢方法、事件被當刷新訊號</td>
          <td><a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a> → <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> → <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a> → <a href="/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢</a></td>
          <td>能判定變更通知的三層歸屬、讀側該停在階梯哪一階、事件對狀態流／命令／查詢的邊界</td>
      </tr>
      <tr>
          <td>測試證言</td>
          <td>測試全綠但功能失聯、驗收條款設計</td>
          <td><a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a> → <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> → 各章「下一步」對應的 work-log case</td>
          <td>能區分行為測試與接線測試的證言範圍、把可達性寫進 use case 完成定義</td>
      </tr>
      <tr>
          <td>術語地基</td>
          <td>讀章節前先補共同語言</td>
          <td><a href="/blog/ddd/knowledge-cards/" data-link-title="Knowledge Cards" data-link-desc="DDD 教學章節引用的領域建模術語：用原子化卡片建立共同語言">knowledge cards</a> → 回模型設計主梯</td>
          <td>能用卡片語言描述模型設計的責任分佈</td>
      </tr>
  </tbody>
</table>
<h2 id="backlog">Backlog</h2>
<p>目前沒有待辦項。上一項（Shared Mutable State 卡）已於 2026-08-07 完成——建卡動機來自 <a href="/blog/report/protective-comment-signals-missing-enforcement/" data-link-title="寫註解的動機是怕被改壞時，要處理的是那個約束、不是那行文字" data-link-desc="準備為一段程式寫註解、而動機是怕有人改壞它時使用。註解不參與執行、改壞的當下不產生訊號；防護需求要先問這個約束能不能被消除，不能消除才交給會發聲的機制，而判定靠當場破壞。">#253</a> 的「這個約束能不能被消除」那一步整個承重在這個術語上，而全站四處提及它的地方沒有一處定義它。</p>
<h3 id="章節大綱">章節大綱</h3>
<p>大綱是 backlog、不是承諾清單：章節邊界會隨 case 回補調整。候選章節從兩個專案的 case（書籍管理 App 的開發日誌、POS App 的領域模型）浮現：</p>
<table>
  <thead>
      <tr>
          <th>候選章節</th>
          <th>核心問題</th>
          <th>已有素材</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a></td>
          <td>什麼時候一袋欄位就夠、什麼時候需要有行為的模型</td>
          <td><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/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>、<a href="/blog/work-log/pos_product_model_doc_vs_code_evolution/" data-link-title="文件裡的扁平 Product、程式碼裡的雙層聚合 — 宣稱型文件的半衰期" data-link-desc="refactor 總結文件記的是決策時刻的快照：扁平 Product（一商品一價一庫存）在真實 POS 業務下演化成 Product &#43; ProductSpecification 雙層、價格三種下沉到規格。欄位放聚合根還是子層的判準是「兩個規格會不會不同」；文件預言的需求全中、預言的結構全錯——這正是先蓋結構會蓋錯的實證。">扁平 Product 到雙層聚合</a></td>
      </tr>
      <tr>
          <td><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></td>
          <td>同一個概念的「同一個」由什麼定義——操作需不需要 identity-based 回寫</td>
          <td><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>（實作層整合）、<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>、<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 鎖行為的做法。">Money 三段遷移</a>、<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>、<a href="/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></td>
          <td>約束落在文件層、型別層、執行層的差異與代價</td>
          <td><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/pos_member_pricing_payment_atomic_switch/" data-link-title="會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換" data-link-desc="多個狀態欄位被同一條業務規則綁住時，分開的 setter 會製造不一致的中間態；把切換收成單一方法、一次狀態更新內同步全部欄位，並注意衍生值重算的順序。以 POS 結帳的會員登出重算為例，含不變式收進 model 的 canCheckout 設計。">會員/計價/支付原子切換</a>、<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 分類不變式</a>、<a href="/blog/work-log/flutter_domain_input_validation_placement/" data-link-title="「978ABC」被拒的理由寫著長度不對 — 驗證的兩層分工與順序陷阱" data-link-desc="輸入驗證有兩層職責：建構期不變式守「這個物件能不能存在」、無狀態 validator 守「使用者輸入對不對」，混在一起會讓測試建不出 fixture、錯誤訊息歸錯類。順序陷阱：先標準化再檢查等於先銷毀證據再診斷——含字母的 ISBN 被削成三位數、錯誤訊息說長度不對。">驗證的兩層分工</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></td>
          <td>領域方法作為唯一變更路徑、稽核軌跡出洞的靜默機制與凍結端點</td>
          <td><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/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>、<a href="/blog/work-log/pos_monotonic_status_optimistic_rollback/" data-link-title="單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態" data-link-desc="POS App 的品項處理狀態只能遞增——現實世界的動作不可逆，狀態機跟著不可逆。樂觀更新讓 UI 先行，但後端拒絕時必須回滾：因為這個狀態是其他防護規則的資料來源，前台多顯示一格進度，防護就會在錯誤的前提上放行。">單調狀態機與樂觀更新回滾</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a></td>
          <td>工廠表達力不足時缺陷如何被逃生口吸收、原始值的官方出口</td>
          <td><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></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></td>
          <td>domain 全對、系統不可用——composition root 的測試證言與可達性強制</td>
          <td><a href="/blog/work-log/flutter_composition_root_wiring_gap/" data-link-title="測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口" data-link-desc="113 張票收尾全綠的版本，實機測試找出五個問題：路由指向佔位頁、provider 佔位 throw、按鈕空 callback，加上兩個平台語意差異。單元測試的 mock override 是正當的測試 seam、同時是遮蔽接線斷裂的來源——記下三層共振的機制、反向追溯設計文件的結果、以及修補時規格層／測試層／發版層的分層落點。">測試全綠、功能失聯</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a></td>
          <td>repository 的變更通知橫跨三層時、契約/機制/組裝各歸誰——歸屬由介面語言決定、非需求來源</td>
          <td><a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖</a>、<a href="/blog/work-log/flutter_streamprovider_wraps_repository_watch/" data-link-title="StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點" data-link-desc="repository 要補 Stream 觀測出口、接給 Riverpod 消費時使用。訂閱模型選 broadcast 還是單訂閱、新訂閱者拿不拿得到當下狀態、controller 誰負責關——每個問題各有一個會靜默失效的預設答案。">StreamProvider 包 repository watch stream</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a></td>
          <td>讀側階梯該爬到哪一階——五訊號與自檢問句「讀的形狀還是 aggregate 的形狀」</td>
          <td><a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">觀測出口案例停在第一階的決策</a>、<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">mock 55 個方法只用 5 個</a>、<a href="/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a></td>
          <td>離散事實與連續觀測的載體分界——消費者問「發生了什麼」還是「現在是什麼」</td>
          <td><a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖</a>、<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a></td>
      </tr>
      <tr>
          <td>從操作推導領域</td>
          <td>使用者操作 → domain / event → 邊界切分的推導流程</td>
          <td><a href="/blog/work-log/pos_table_cart_lifecycle_decoupling/" data-link-title="桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦" data-link-desc="兩個業務資源該綁死成一對一、還是解耦成獨立生命週期加綁定關係——判準是有沒有業務操作需要其中一方獨立存活。以 POS 的提前結帳、純佔桌、外賣單推導桌位與購物車的聚合邊界，含組合空間大於業務空間時的非法組合封鎖。">桌子跟購物車是兩個聚合</a>、<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">過度設計震盪</a>、<a href="/blog/work-log/dart_unsettled_cart_pure_function/" data-link-title="「該收多少錢」抽成 pure function — IO 在邊界、領域計算在核心" data-link-desc="多個畫面都要顯示「未結帳的份數與金額」時，把計算抽成無 IO 的 pure function：資料由 caller 從 repository 拿好傳入、函式只做合併 / 扣減 / 折扣運算。含合併鍵要跟同一性定義同維度的陷阱、兩層折扣各自 clamp 的邊界、以及用註解預留擴充點讓未來規則接入不動本體。">pure function 領域計算</a></td>
      </tr>
      <tr>
          <td>分層責任與基礎設施歸位</td>
          <td>domain 對呈現與技術細節無知、共用能力歸哪層</td>
          <td><a href="/blog/work-log/flutter_domain_layer_i18n_hardcoded_text/" data-link-title="Domain 層的 947 處硬編碼中文 — 訊息代碼跟顯示文字的分層責任" data-link-desc="domain 層的 enum 或 getter 直接回傳 UI 顯示字串時，多語言支援與分層原則同時失守。修法是 domain 只回訊息代碼與結構化資料、UI 層用 translator extension 翻譯；遷移排序從最小模組先行驗證模式。適用於盤點「這個字串屬於領域事實還是呈現」的情境。">Domain 層的 947 處硬編碼中文</a>、<a href="/blog/work-log/flutter_duplicate_service_fake_coverage/" data-link-title="兩個 domain 各自實作同一個 API service — 100% 覆蓋率的假象" data-link-desc="同名 service 在多個 domain 各自實作時，覆蓋率數字會失去意義：每份實作各測各的、mock 各有介面，統一的行為從未被測過。重複實作是上游訊號——規劃文件沒抽出跨 domain 的共同技術需求；單檔品質審查看不到跨檔重複。">重複 service 假覆蓋率</a>、<a href="/blog/work-log/flutter_port_interface_mock_hell_isp/" data-link-title="mock 要配置 55 個方法、實際只用 5 個 — 測試痛是介面設計痛的探針" data-link-desc="service 測試的 mock 負擔正比於它依賴的介面寬度：依賴四個大介面共 55 個方法、實際呼叫 5 個，91% 的 mock 配置是純浪費、還會炸 MissingStubError。修法是介面隔離——抽出只含實際使用方法的 Port，讓 mock 縮到跟真實依賴一樣窄；為未來預留的方法用 TODO 標記啟用時機。">Port 介面與 mock 地獄</a></td>
      </tr>
      <tr>
          <td>entity 的持久化邊界</td>
          <td>完成定義要含持久化迴圈、entity 欄位與 schema 的差集</td>
          <td><a href="/blog/work-log/flutter_feature_complete_never_persisted/" data-link-title="功能「完成」、測試全過、資料從未落地 — 持久化迴圈是驗收的盲區" data-link-desc="domain 功能的測試可以全綠、而它的資料從未被序列化、資料庫沒有對應的表——單元測試都在記憶體內驗證行為、沒有一條測試走「存進去、重建、讀出來」的迴圈。驗收定義要含 roundtrip；entity 欄位與 schema 欄位的差集是靜默資料失真的清單。">功能完成卻從未持久化</a>、<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 VO 序列化邊界</a></td>
      </tr>
      <tr>
          <td>entity 的演化與遷移</td>
          <td>大 ripple 的重寫策略、遷移通路三段齊（寫入 / 讀出 / 消費）</td>
          <td><a href="/blog/work-log/flutter_deprecated_getter_facade_entity_migration/" data-link-title="核心 entity 重寫、140&#43; 檔消費端不動 — Deprecated Getter Facade 的過渡設計" data-link-desc="重寫被百餘檔引用的核心 entity 時，直接改會同時打爆全部消費端、長期分支的 merge 成本隨時間暴漲。第三條路是 facade：舊欄位保留為 deprecated getter、內部從新結構回讀，消費端零修改編譯通過、@Deprecated 讓編譯器自動列出遷移清單、再逐波清償。facade 要配退場計畫、否則就是永久相容層。">Deprecated Getter Facade</a>、<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></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/cross-boundary-reference-ownership/" data-link-title="跨邊界參照與狀態所有權" data-link-desc="下游持有上游資料的 id、操作靠這個 id 回寫時：上游的哪些操作會讓 id 死亡、有沒有跨操作不變的穩定身份、身份轉移後本端的狀態搬不搬家——參照設計的判準與遷移分工">跨邊界參照與狀態所有權</a></td>
          <td>下游持有上游 id 時有效性由誰決定——穩定身份、解引用時機、自持與可導出狀態的遷移分工</td>
          <td><a href="/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期</a>、<a href="/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態</a>、<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽</a></td>
      </tr>
      <tr>
          <td>entity 的生命週期</td>
          <td>生命週期由業務流程定義、ephemeral 物件的丟棄即重置</td>
          <td><a href="/blog/work-log/flutter_ephemeral_domain_object_rx_immutable/" data-link-title="只活在結帳流程裡的領域物件 — ephemeral model 與「Rx 外殼、immutable 內核」" data-link-desc="流程型狀態（結帳中的輸入金額、支付方式、會員）建模成生命週期等於流程的 ephemeral 物件：結完即丟、下次全新，殘留狀態忘記重置的 bug 被結構性消滅。實作形態是 reactive 外殼包 immutable 內核——對外只開語意化變更方法、每次變更是原子的狀態替換。">只活在結帳流程裡的領域物件</a>、<a href="/blog/work-log/pos_table_cart_lifecycle_decoupling/" data-link-title="桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦" data-link-desc="兩個業務資源該綁死成一對一、還是解耦成獨立生命週期加綁定關係——判準是有沒有業務操作需要其中一方獨立存活。以 POS 的提前結帳、純佔桌、外賣單推導桌位與購物車的聚合邊界，含組合空間大於業務空間時的非法組合封鎖。">桌子跟購物車是兩個聚合</a></td>
      </tr>
      <tr>
          <td><a href="/blog/ddd/domain-event-vs-command-and-query/" data-link-title="domain event 與命令、查詢的分界" data-link-desc="事件類別以動詞開頭、或事件成對出現 Requested/Provided 帶 correlation id 時使用。事件只承載已發生的事實——命令有唯一處理者與成敗、查詢是一問一答的對話；把意圖或對話裝進事件通道、責任結構會跟著錯位。">domain event 與命令、查詢</a></td>
          <td>訊息的責任結構分界——事件只承載已發生的事實、意圖與對話各有自己的通道</td>
          <td><a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式</a>、<a href="/blog/work-log/cross_domain_event_request_response_cost/" data-link-title="用事件做同步查詢、等於手工重建 RPC — 跨 domain 解耦的完整帳單" data-link-desc="domain 直接查另一個 domain 的 repository 違反依賴方向；改事件驅動的 request/response 解了耦、但帳單具體：correlation id 配對並發、timeout 機制、三條錯誤路徑——都是同步呼叫免費附贈的東西。判準是需要解耦「依賴方向」還是「時間與部署」：前者用消費端 Port 就夠、後者才值得付事件的價。">用事件做同步查詢等於手工重建 RPC</a></td>
      </tr>
  </tbody>
</table>
<p>Aggregate 生命週期已有第一個 case（桌位與購物車）；bounded context、repository 邊界等主題等 case 累積後再進大綱——本模組的章節由 case 驅動，理論陳述要有實際踩過的專案情境支撐。</p>
<h2 id="case-回補">Case 回補</h2>
<p>本模組的 case 來源是實際專案的開發記錄與 ticket：過去專案內處理掉的設計討論陸續回補成 <a href="/blog/work-log/" data-link-title="工作筆記" data-link-desc="工作場景觸發的技術紀錄 — git 操作、build 工具、框架行為、環境設定與架構觀念">work-log</a> 文章（兩個專案），模組章節再引用這些 case。目前的 case 基底集中在兩個 Dart / Flutter 專案，章節的判準以敘事層抽象維持語言無關；跨生態的驗證隨其他語言模組的 case 累積補強。</p>
]]></content:encoded></item><item><title>copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞</title><link>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：修一個效能基準測試的 &lt;code>InvalidBookIdException&lt;/code>，追根因時發現它是同族語意錯誤的第二起
&lt;strong>疑問來源&lt;/strong>：「copyWith 是方便的做法，但通常不是最好的設計」——這個直覺是否成立？
&lt;strong>整理目的&lt;/strong>：把「copyWith 什麼時候是對的工具、什麼時候是逃生口」的判斷邊界記下來，連同這個專案實際踩的三個坑
&lt;strong>本文邊界&lt;/strong>：這是一篇 work-log，回溯一次具體專案的設計檢視；它不主張消滅 copyWith——結論恰恰相反，問題從來不在 copyWith 本身&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事件起點一個-3-字元的-id">事件起點：一個 3 字元的 ID&lt;/h2>
&lt;p>書籍管理 App 專案的效能基準測試炸了一個例外：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">static</span> <span class="kd">const</span> <span class="n">_sentinel</span> <span class="o">=</span> <span class="kt">Object</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">State</span> <span class="n">copyWith</span><span class="p">({</span><span class="kt">Object</span><span class="o">?</span> <span class="n">member</span> <span class="o">=</span> <span class="n">_sentinel</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="n">State</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nl">member:</span> <span class="n">member</span> <span class="o">==</span> <span class="n">_sentinel</span> <span class="o">?</span> <span class="k">this</span><span class="p">.</span><span class="n">member</span> <span class="o">:</span> <span class="n">member</span> <span class="o">as</span> <span class="n">Member</span><span class="o">?</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>有了哨兵，「登出會員（明確設 null）」這類操作才能經由 copyWith 表達。freezed 生成的 copyWith 內部就是用同樣的哨兵技巧處理這件事——手寫 immutable state 時這是要自己補的部分，漏掉的症狀是「清空欄位的操作靜默變成保留原值」。</p>
<h2 id="收束三個坑的共同結構">收束：三個坑的共同結構</h2>
<p>這次追出來的三個坑——語意錯誤的測試拼裝、被繞過的領域方法、從未強制的註解約束——共同結構是同一個：</p>
<h3 id="設計意圖只寫在文件層沒有落在型別層或執行層">設計意圖只寫在文件層，沒有落在型別層或執行層</h3>
<p>「請走領域方法」是慣例，copyWith 不擋你；「只能從 enriching 轉換」是註解，實作不查你；「測試該用工廠」是期望，工廠沒能力你就繞。每一個「請、應該、建議」都是一個沒關上的逃生口，而逃生口的使用者不是壞人——他們只是走了阻力最小的路。</p>
<p>要讓意圖成立，就得讓違反意圖的路徑<strong>走不通</strong>，而不是寫文件請大家不要走。</p>
<p>這次追出來的兩個可重用原則各自抽成 report 卡：意圖的強制層次在 <a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>、缺陷的轉移機制在 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>。概念地基在 DDD 模組：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>（copyWith 該不該掛的判準入口）、<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>（文件層失效機制的教學層展開）、<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>（變更路徑收斂與稽核出洞機制）、<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>（工廠表達力不足的缺陷轉移）。</p>
]]></content:encoded></item><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>桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦</title><link>https://tarrragon.github.io/blog/work-log/pos_table_cart_lifecycle_decoupling/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_table_cart_lifecycle_decoupling/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜
&lt;strong>疑問來源&lt;/strong>：直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源？
&lt;strong>整理目的&lt;/strong>：記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺
&lt;strong>本文邊界&lt;/strong>：素材是一個 Flutter POS App 的現行實作與 changelog；推導方式可遷移、具體切法是餐飲 domain 的結果&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="直覺建模在三個操作前撐不住">直覺建模在三個操作前撐不住&lt;/h2>
&lt;p>「一桌一單」的一對一建模，遇到這個專案實際支援的操作就露出縫：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>提前結帳&lt;/strong>：客人中途先結一次帳但不離桌，之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束&lt;/li>
&lt;li>&lt;strong>純佔桌&lt;/strong>：客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始&lt;/li>
&lt;li>&lt;strong>外賣單&lt;/strong>：沒有桌的訂單。購物車存在、桌位從頭到尾缺席&lt;/li>
&lt;/ul>
&lt;p>三個操作各自證明一件事：桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作，只能塞特例旗標，而特例會隨操作數量增生。&lt;/p>
&lt;h2 id="解耦後的模型獨立資源--綁定關係">解耦後的模型：獨立資源 + 綁定關係&lt;/h2>
&lt;p>這個專案的購物車 model 註解直接寫明了關係設計：&lt;/p>
&lt;blockquote>
&lt;ul>
&lt;li>桌子和購物車是獨立資源，透過綁定關係關聯&lt;/li>
&lt;li>開桌（occupyTable）同時建立購物車並綁定桌子&lt;/li>
&lt;li>釋放桌子（releaseTable）只解除綁定，購物車仍保留&lt;/li>
&lt;li>當購物車名下沒有任何桌子時，變成臨時購物車（外賣單）&lt;/li>
&lt;li>提前結帳且不釋放桌子時，品項被清空但購物車和桌子綁定仍在，可繼續追加點餐&lt;/li>
&lt;li>純佔桌（reserveTable）不建立購物車，要下單需另行開桌&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合，而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」，自然落在模型的表達範圍內。&lt;/p>
&lt;h2 id="結帳模式兩個布林組合出生命週期決策">結帳模式：兩個布林組合出生命週期決策&lt;/h2>
&lt;p>結帳流程用兩個欄位決定結帳後兩個資源各自的命運：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模式&lt;/th>
 &lt;th>&lt;code>releaseTable&lt;/code>&lt;/th>
 &lt;th>&lt;code>deleteShoppingCart&lt;/code>&lt;/th>
 &lt;th>結帳後&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>完整結帳&lt;/td>
 &lt;td>true&lt;/td>
 &lt;td>true&lt;/td>
 &lt;td>釋放桌位、刪除掛單、畫面回購物頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>提前結帳&lt;/td>
 &lt;td>false&lt;/td>
 &lt;td>false&lt;/td>
 &lt;td>保留桌位與 cart 殼、同桌直接繼續追加點餐&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>結帳後的兩條路徑也各自獨立：完整結帳走 &lt;code>afterCheckout&lt;/code>（回購物頁）、提前結帳走 &lt;code>afterPartialCheckout&lt;/code>——後者的註解記了一個容易被清掉的細節：「刻意不回到購物頁、不清本機暫存品項：員工提前結完仍在服務同一位客人，若員工先前已在本機累積了尚未送出的追加品項，會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。&lt;/p>
&lt;h2 id="組合空間大於業務空間非法組合要顯式封鎖">組合空間大於業務空間：非法組合要顯式封鎖&lt;/h2>
&lt;p>兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄：&lt;/p>
&lt;blockquote>
&lt;p>fix: 禁止在提前結帳的時候釋放桌子&lt;/p>&lt;/blockquote>
&lt;p>「提前結帳 + 釋放桌子」在模型上可以表達（兩個獨立欄位、各自可設），在業務上是矛盾的（客人還在桌上、桌卻被釋放給下一組客人）。解耦買到表達力的同時，也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失，要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者，而更早關掉這個縫的做法是讓結帳模式成為一個 enum（&lt;code>fullCheckout&lt;/code> / &lt;code>partialCheckout&lt;/code>）、由模式推導兩個布林，非法組合從一開始就不可表達。&lt;/p>
&lt;h2 id="契約的另一半details-只含未結帳品項">契約的另一半：details 只含未結帳品項&lt;/h2>
&lt;p>提前結帳還牽動購物車內容的契約。購物車 model 註明 &lt;code>details&lt;/code> 代表「當下未結帳的活動品項」，已結帳品項由後端移除；把 details 轉成結帳清單的方法直接把違約後果寫在註解裡：&lt;/p>
&lt;blockquote>
&lt;p>依賴 ShoppingCart 的 details 契約：假設 details 不含已結帳品項。若契約被破壞，已結帳的舊單金額會被重複算進下一輪結帳。&lt;/p>&lt;/blockquote>
&lt;p>這是「重複收錢」等級的後果，靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上，是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。&lt;/p>
&lt;h2 id="判準有沒有操作需要其中一方獨立存活">判準：有沒有操作需要其中一方獨立存活&lt;/h2>
&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/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a> 的「從操作推導領域」章節&lt;/li>
&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/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>——桌位與購物車是資源層的生命週期、品項是資料層的生命週期，同一個 domain 的兩個切面&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS 專案有一個「提前結帳」需求——客人中途先結一次帳、但人還沒離桌、要能繼續加點。追這個功能的實作時發現桌位跟購物車的關係設計比直覺版本複雜
<strong>疑問來源</strong>：直覺的建模是「一桌一單」、桌子跟訂單一對一。這個專案為什麼把它們拆成兩個獨立資源？
<strong>整理目的</strong>：記下「兩個資源的生命週期何時該解耦」的推導方式——從業務操作反推、不從名詞直覺
<strong>本文邊界</strong>：素材是一個 Flutter POS App 的現行實作與 changelog；推導方式可遷移、具體切法是餐飲 domain 的結果</p></blockquote>
<hr>
<h2 id="直覺建模在三個操作前撐不住">直覺建模在三個操作前撐不住</h2>
<p>「一桌一單」的一對一建模，遇到這個專案實際支援的操作就露出縫：</p>
<ul>
<li><strong>提前結帳</strong>：客人中途先結一次帳但不離桌，之後繼續加點。訂單結掉了、桌還在用——訂單的生命週期先於桌位結束</li>
<li><strong>純佔桌</strong>：客人入座還沒點餐。桌被占用、購物車還不存在——桌位的生命週期先於購物車開始</li>
<li><strong>外賣單</strong>：沒有桌的訂單。購物車存在、桌位從頭到尾缺席</li>
</ul>
<p>三個操作各自證明一件事：桌位跟購物車的生命週期在真實業務裡會錯開。一對一綁死的 model 要支援這些操作，只能塞特例旗標，而特例會隨操作數量增生。</p>
<h2 id="解耦後的模型獨立資源--綁定關係">解耦後的模型：獨立資源 + 綁定關係</h2>
<p>這個專案的購物車 model 註解直接寫明了關係設計：</p>
<blockquote>
<ul>
<li>桌子和購物車是獨立資源，透過綁定關係關聯</li>
<li>開桌（occupyTable）同時建立購物車並綁定桌子</li>
<li>釋放桌子（releaseTable）只解除綁定，購物車仍保留</li>
<li>當購物車名下沒有任何桌子時，變成臨時購物車（外賣單）</li>
<li>提前結帳且不釋放桌子時，品項被清空但購物車和桌子綁定仍在，可繼續追加點餐</li>
<li>純佔桌（reserveTable）不建立購物車，要下單需另行開桌</li>
</ul></blockquote>
<p>每個業務操作對應到「建立 / 解除綁定」跟「建立 / 保留資源」的不同組合，而不是對單一聚合的特例處理。外賣單在這個模型裡甚至不是特例——它就是「綁定數為零的購物車」，自然落在模型的表達範圍內。</p>
<h2 id="結帳模式兩個布林組合出生命週期決策">結帳模式：兩個布林組合出生命週期決策</h2>
<p>結帳流程用兩個欄位決定結帳後兩個資源各自的命運：</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th><code>releaseTable</code></th>
          <th><code>deleteShoppingCart</code></th>
          <th>結帳後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>完整結帳</td>
          <td>true</td>
          <td>true</td>
          <td>釋放桌位、刪除掛單、畫面回購物頁</td>
      </tr>
      <tr>
          <td>提前結帳</td>
          <td>false</td>
          <td>false</td>
          <td>保留桌位與 cart 殼、同桌直接繼續追加點餐</td>
      </tr>
  </tbody>
</table>
<p>結帳後的兩條路徑也各自獨立：完整結帳走 <code>afterCheckout</code>（回購物頁）、提前結帳走 <code>afterPartialCheckout</code>——後者的註解記了一個容易被清掉的細節：「刻意不回到購物頁、不清本機暫存品項：員工提前結完仍在服務同一位客人，若員工先前已在本機累積了尚未送出的追加品項，會保留下來」。提前結帳的語意一路貫穿到本機暫存的處理。</p>
<h2 id="組合空間大於業務空間非法組合要顯式封鎖">組合空間大於業務空間：非法組合要顯式封鎖</h2>
<p>兩個布林有四種組合、業務上只定義了兩種。這個縫隙真的被踩過——changelog 的修正記錄：</p>
<blockquote>
<p>fix: 禁止在提前結帳的時候釋放桌子</p></blockquote>
<p>「提前結帳 + 釋放桌子」在模型上可以表達（兩個獨立欄位、各自可設），在業務上是矛盾的（客人還在桌上、桌卻被釋放給下一組客人）。解耦買到表達力的同時，也把「組合空間大於業務空間」的問題帶進來——多出來的組合不會自己消失，要嘛在 UI 層擋住操作、要嘛在 model 層宣告不變式。這個專案選了前者，而更早關掉這個縫的做法是讓結帳模式成為一個 enum（<code>fullCheckout</code> / <code>partialCheckout</code>）、由模式推導兩個布林，非法組合從一開始就不可表達。</p>
<h2 id="契約的另一半details-只含未結帳品項">契約的另一半：details 只含未結帳品項</h2>
<p>提前結帳還牽動購物車內容的契約。購物車 model 註明 <code>details</code> 代表「當下未結帳的活動品項」，已結帳品項由後端移除；把 details 轉成結帳清單的方法直接把違約後果寫在註解裡：</p>
<blockquote>
<p>依賴 ShoppingCart 的 details 契約：假設 details 不含已結帳品項。若契約被破壞，已結帳的舊單金額會被重複算進下一輪結帳。</p></blockquote>
<p>這是「重複收錢」等級的後果，靠一條跨前後端的資料契約撐住。契約寫在消費端的註解上，是因為違約的症狀會在消費端爆炸、但成因在資料來源端——除錯的人會先找到這裡。</p>
<h2 id="判準有沒有操作需要其中一方獨立存活">判準：有沒有操作需要其中一方獨立存活</h2>
<p>把推導收束成一句：兩個業務資源該不該共用生命週期，看<strong>有沒有業務操作需要其中一方在另一方缺席時存活</strong>。有——佔桌不點、提前結帳、外賣——就解耦成獨立資源加綁定；沒有，一對一的簡單模型是正確選擇、解耦反而引入要管理的組合空間。這是「從操作推導領域」的實例：聚合邊界不是從名詞關係（桌子「有」訂單）推出來的，是從操作對生命週期的要求推出來的。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a> 的「從操作推導領域」章節</li>
<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/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>——桌位與購物車是資源層的生命週期、品項是資料層的生命週期，同一個 domain 的兩個切面</li>
</ul>
]]></content:encoded></item></channel></rss>