<?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>Data-Class on Tarragon</title><link>https://tarrragon.github.io/blog/tags/data-class/</link><description>Recent content in Data-Class 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/data-class/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></channel></rss>