<?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>DDD 領域驅動設計指南 on Tarragon</title><link>https://tarrragon.github.io/blog/ddd/</link><description>Recent content in DDD 領域驅動設計指南 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/ddd/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>建構路徑設計</title><link>https://tarrragon.github.io/blog/ddd/construction-path-design/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/construction-path-design/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a> 展開了建構期不變式（物件建構時必須通過的業務規則檢查）——物件出生的那一刻就必須合法。本章接下來的問題是建構路徑本身的設計：工廠的表達力有沒有覆蓋消費者的正當需求、出口有沒有給原始值一個語意明確的位置。「讓違反規則的路徑走不通」在建構路徑有一個常被忽略的前提——「讓遵守規則的路徑走得通」：正當需求在工廠裡找不到出口時、逃生口就成了預設路徑、規則也跟著失效。&lt;/p>
&lt;h2 id="工廠的表達力與消費者需求">工廠的表達力與消費者需求&lt;/h2>
&lt;p>工廠（建構子、命名工廠、builder）是合法物件的唯一入口。它的職責有兩個：保證出生即合法（不變式檢查）、同時覆蓋消費者的正當需求（給得出消費者想要的合法組合）。兩個職責有張力——保證越嚴、接受的參數越窄；解法是讓工廠的表達力跟上需求、而不是放棄保證。&lt;/p>
&lt;p>一個書籍管理 App 的 &lt;code>createForTest&lt;/code> 工廠只接受 &lt;code>id&lt;/code> / &lt;code>title&lt;/code> / &lt;code>author&lt;/code> / &lt;code>isbn&lt;/code> 四個參數。測試想表達「預設 tags 再追加三個自訂 tag」——這是合法的需求、工廠的表達力沒覆蓋到。需求進不了工廠，就會從別的出口出去——在這個專案，出口是全欄位 copyWith（&lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>）。&lt;/p>
&lt;p>工廠參數列的設計判準：收的是「有語意的需求」（預設 tags 加自訂、指定初始狀態、帶入驗證過的關聯物件），不是「所有欄位的任意值」。後者退化成另一個全欄位建構器，保證也跟著消失——分寸在「覆蓋高頻的合法組合、不開放任意拼裝」——「高頻」的門檻見下一節：同一組合出現第二次就收進工廠。&lt;/p>
&lt;h2 id="逃生口吸收建構路徑的缺陷">逃生口吸收建構路徑的缺陷&lt;/h2>
&lt;p>逃生口吸收是建構路徑缺陷長期不被修好的核心機制：表達力缺口碰上萬能拼裝工具、上游缺陷被下游繞道消化、工廠永遠不會被迫改進。機制分三步。第一步、工廠表達不了需求：上述 &lt;code>createForTest&lt;/code> 不接受 &lt;code>bookTags&lt;/code>。第二步、逃生口接住需求：copyWith 總是能拼、它成為唯一出路。第三步、拼裝現場的最短路徑埋下語意錯誤：在用 copyWith 拼的當下、順手建個臨時物件撈預設值（&lt;code>Book.createForTest(id: 'tmp').bookTags&lt;/code>）是阻力最小的寫法——而 &lt;code>'tmp'&lt;/code> 不滿足 BookId 的長度約束。同型寫法幾天前才在另一個檔案修過一次。&lt;/p>
&lt;p>關鍵在第二步的因果方向：逃生口不只讓錯誤寫法變可能、它讓正確的修法變不必要。沒有逃生口時、工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法；有逃生口時、每個被擋的需求都繞出去了、工廠的缺陷永遠不會痛。同族語意錯誤第二次出現就是結構在選擇行為的證據：兩個作者（或同作者在不同時刻）在同一個表達力缺口前面各自獨立走出同一條最短路徑（&lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>）。&lt;/p>
&lt;p>修法對準上游：讓工廠能表達需求（&lt;code>createForTest&lt;/code> 接受 &lt;code>bookTags&lt;/code>），拼裝的動機就消失了。修每一個拼裝點——把 &lt;code>'tmp'&lt;/code> 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀，下一個測試還是會在同一個表達力缺口前面選擇拼裝。門檻是兩次：第一次拼裝可以當個案修掉——只出現一次的特殊組合用拼裝是合理的、工廠也有參數列膨脹的反向代價。第二次出現就該停止修拼裝點、轉頭找共同面對的表達力缺口。工廠產物的 copyWith 呼叫在覆寫哪些欄位、就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查（&lt;a href="https://tarrragon.github.io/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷&lt;/a>）。&lt;/p>
&lt;h2 id="原始值的官方出口">原始值的官方出口&lt;/h2>
&lt;p>建構路徑的另一面是出口：value object 封裝了原始值之後、基礎設施層（快取 key、資料庫 column、序列化）要怎麼拿到它需要的表示。穩態是封裝任意操作、而非封裝取值本身——基礎設施邊界的需求是正當的、該給語意明確的出口。這個穩態從擺盪中浮出。&lt;/p>
&lt;p>同一個書籍管理 App 的 value object 封裝在三個版本間擺盪。v0.7.6 全移除（裸字串），不變式失去強制點、任何字串冒充任何 ID。隨後 v0.8.10 推向另一極——完全封裝、目標零個 &lt;code>.value&lt;/code> 外部存取——基礎設施層的 176 個編譯錯誤暴露這些消費是正當需求，把它們全逼到 &lt;code>toString()&lt;/code> 上是語意寄生、格式一改快取 key 靜默換一批。最終 v0.8.13 加回 &lt;code>.value&lt;/code> getter、被重新命名成「相容性介面」，實質是理想在依賴現實前退讓（&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪&lt;/a>）。&lt;/p>
&lt;p>穩態的操作化：出口的名字說明用途：&lt;code>toJsonString()&lt;/code>（序列化）、&lt;code>toDbValue()&lt;/code>（持久化）、&lt;code>displayValue&lt;/code>（UI）。「誰在拆封」就變成可 grep 的——一個 POS 專案的 Money 型別用 &lt;code>toDecimal()&lt;/code> 作為官方拆封口、搜尋 &lt;code>toDecimal(&lt;/code> 就是拆封清單。沒有官方出口的世界裡、下游用 &lt;code>toString()&lt;/code> 硬接或把 &lt;code>.value&lt;/code> getter 加回來、拆封處完全不可追蹤。&lt;/p>
&lt;p>擺盪的根治不在選對某一極、在於把邊界寫成決策記錄：哪些出口存在、各自給誰用、為什麼不多不少。出口增長到大多數消費者都有專屬方法時、封裝的值已不成立——此時直接暴露一個語意明確的拆封方法，比維護多個用途近似的出口便宜。沒有這份記錄、每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。判讀徵兆：重構記錄出現「相容性介面」——檢查它是不是理想撤退的重新命名；決策記錄只記贏面——反方向的代價沒被記、下次擺回去的推力還在；封裝重構在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口不是硬改。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>測試 Arrange 段出現「工廠 + copyWith 拼裝」——盤點拼裝在補什麼欄位、高頻欄位收進工廠參數列。工廠參數列長期不變、而它的產物被 copyWith 環繞——表達力已落後需求，原則見 &lt;a href="https://tarrragon.github.io/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223&lt;/a>。&lt;/li>
&lt;li>建一個立刻丟棄的物件、只為了拿它的一個欄位？這是語意錯誤的標記，先找它真正想表達的需求。&lt;/li>
&lt;li>同族語意錯誤第二次出現——停止修個案、找兩個案共同面對的建構路徑缺口。&lt;/li>
&lt;li>&lt;code>toString()&lt;/code> 被當成取值 API 用在快取 key / DB 值——語意寄生、格式一改靜默事故。出口要有語意明確的名字，語意封閉判準見 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a>。&lt;/li>
&lt;li>VO 封裝重構的編譯錯誤在基礎設施層爆量——基礎設施是正當消費者、該給出口。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>物件組好之後、整個系統怎麼被組起來：&lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>&lt;/li>
&lt;li>出生合法的機制：&lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>&lt;/li>
&lt;li>變更路徑收斂：&lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a>&lt;/li>
&lt;li>型別類別的入口判準：&lt;a href="https://tarrragon.github.io/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型&lt;/a>&lt;/li>
&lt;li>value object 的語意封閉判準：&lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a>&lt;/li>
&lt;li>Dart 的語言細節（copyWith 三態缺口、工廠表達力擴充、extension type 的拆封口）：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪&lt;/a>&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 展開了建構期不變式（物件建構時必須通過的業務規則檢查）——物件出生的那一刻就必須合法。本章接下來的問題是建構路徑本身的設計：工廠的表達力有沒有覆蓋消費者的正當需求、出口有沒有給原始值一個語意明確的位置。「讓違反規則的路徑走不通」在建構路徑有一個常被忽略的前提——「讓遵守規則的路徑走得通」：正當需求在工廠裡找不到出口時、逃生口就成了預設路徑、規則也跟著失效。</p>
<h2 id="工廠的表達力與消費者需求">工廠的表達力與消費者需求</h2>
<p>工廠（建構子、命名工廠、builder）是合法物件的唯一入口。它的職責有兩個：保證出生即合法（不變式檢查）、同時覆蓋消費者的正當需求（給得出消費者想要的合法組合）。兩個職責有張力——保證越嚴、接受的參數越窄；解法是讓工廠的表達力跟上需求、而不是放棄保證。</p>
<p>一個書籍管理 App 的 <code>createForTest</code> 工廠只接受 <code>id</code> / <code>title</code> / <code>author</code> / <code>isbn</code> 四個參數。測試想表達「預設 tags 再追加三個自訂 tag」——這是合法的需求、工廠的表達力沒覆蓋到。需求進不了工廠，就會從別的出口出去——在這個專案，出口是全欄位 copyWith（<a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>）。</p>
<p>工廠參數列的設計判準：收的是「有語意的需求」（預設 tags 加自訂、指定初始狀態、帶入驗證過的關聯物件），不是「所有欄位的任意值」。後者退化成另一個全欄位建構器，保證也跟著消失——分寸在「覆蓋高頻的合法組合、不開放任意拼裝」——「高頻」的門檻見下一節：同一組合出現第二次就收進工廠。</p>
<h2 id="逃生口吸收建構路徑的缺陷">逃生口吸收建構路徑的缺陷</h2>
<p>逃生口吸收是建構路徑缺陷長期不被修好的核心機制：表達力缺口碰上萬能拼裝工具、上游缺陷被下游繞道消化、工廠永遠不會被迫改進。機制分三步。第一步、工廠表達不了需求：上述 <code>createForTest</code> 不接受 <code>bookTags</code>。第二步、逃生口接住需求：copyWith 總是能拼、它成為唯一出路。第三步、拼裝現場的最短路徑埋下語意錯誤：在用 copyWith 拼的當下、順手建個臨時物件撈預設值（<code>Book.createForTest(id: 'tmp').bookTags</code>）是阻力最小的寫法——而 <code>'tmp'</code> 不滿足 BookId 的長度約束。同型寫法幾天前才在另一個檔案修過一次。</p>
<p>關鍵在第二步的因果方向：逃生口不只讓錯誤寫法變可能、它讓正確的修法變不必要。沒有逃生口時、工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法；有逃生口時、每個被擋的需求都繞出去了、工廠的缺陷永遠不會痛。同族語意錯誤第二次出現就是結構在選擇行為的證據：兩個作者（或同作者在不同時刻）在同一個表達力缺口前面各自獨立走出同一條最短路徑（<a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>）。</p>
<p>修法對準上游：讓工廠能表達需求（<code>createForTest</code> 接受 <code>bookTags</code>），拼裝的動機就消失了。修每一個拼裝點——把 <code>'tmp'</code> 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀，下一個測試還是會在同一個表達力缺口前面選擇拼裝。門檻是兩次：第一次拼裝可以當個案修掉——只出現一次的特殊組合用拼裝是合理的、工廠也有參數列膨脹的反向代價。第二次出現就該停止修拼裝點、轉頭找共同面對的表達力缺口。工廠產物的 copyWith 呼叫在覆寫哪些欄位、就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查（<a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a>）。</p>
<h2 id="原始值的官方出口">原始值的官方出口</h2>
<p>建構路徑的另一面是出口：value object 封裝了原始值之後、基礎設施層（快取 key、資料庫 column、序列化）要怎麼拿到它需要的表示。穩態是封裝任意操作、而非封裝取值本身——基礎設施邊界的需求是正當的、該給語意明確的出口。這個穩態從擺盪中浮出。</p>
<p>同一個書籍管理 App 的 value object 封裝在三個版本間擺盪。v0.7.6 全移除（裸字串），不變式失去強制點、任何字串冒充任何 ID。隨後 v0.8.10 推向另一極——完全封裝、目標零個 <code>.value</code> 外部存取——基礎設施層的 176 個編譯錯誤暴露這些消費是正當需求，把它們全逼到 <code>toString()</code> 上是語意寄生、格式一改快取 key 靜默換一批。最終 v0.8.13 加回 <code>.value</code> getter、被重新命名成「相容性介面」，實質是理想在依賴現實前退讓（<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪</a>）。</p>
<p>穩態的操作化：出口的名字說明用途：<code>toJsonString()</code>（序列化）、<code>toDbValue()</code>（持久化）、<code>displayValue</code>（UI）。「誰在拆封」就變成可 grep 的——一個 POS 專案的 Money 型別用 <code>toDecimal()</code> 作為官方拆封口、搜尋 <code>toDecimal(</code> 就是拆封清單。沒有官方出口的世界裡、下游用 <code>toString()</code> 硬接或把 <code>.value</code> getter 加回來、拆封處完全不可追蹤。</p>
<p>擺盪的根治不在選對某一極、在於把邊界寫成決策記錄：哪些出口存在、各自給誰用、為什麼不多不少。出口增長到大多數消費者都有專屬方法時、封裝的值已不成立——此時直接暴露一個語意明確的拆封方法，比維護多個用途近似的出口便宜。沒有這份記錄、每一任重構者都會從自己撞到的那一面出發、再推向另一個極端。判讀徵兆：重構記錄出現「相容性介面」——檢查它是不是理想撤退的重新命名；決策記錄只記贏面——反方向的代價沒被記、下次擺回去的推力還在；封裝重構在基礎設施層爆量——訊號是「這些消費是正當的」、該給出口不是硬改。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>測試 Arrange 段出現「工廠 + copyWith 拼裝」——盤點拼裝在補什麼欄位、高頻欄位收進工廠參數列。工廠參數列長期不變、而它的產物被 copyWith 環繞——表達力已落後需求，原則見 <a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223</a>。</li>
<li>建一個立刻丟棄的物件、只為了拿它的一個欄位？這是語意錯誤的標記，先找它真正想表達的需求。</li>
<li>同族語意錯誤第二次出現——停止修個案、找兩個案共同面對的建構路徑缺口。</li>
<li><code>toString()</code> 被當成取值 API 用在快取 key / DB 值——語意寄生、格式一改靜默事故。出口要有語意明確的名字，語意封閉判準見 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a>。</li>
<li>VO 封裝重構的編譯錯誤在基礎設施層爆量——基礎設施是正當消費者、該給出口。</li>
</ul>
<h2 id="下一步">下一步</h2>
<ul>
<li>物件組好之後、整個系統怎麼被組起來：<a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></li>
<li>出生合法的機制：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
<li>變更路徑收斂：<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a></li>
<li>型別類別的入口判準：<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a></li>
<li>value object 的語意封閉判準：<a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a></li>
<li>Dart 的語言細節（copyWith 三態缺口、工廠表達力擴充、extension type 的拆封口）：<a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>、<a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">VO 的封裝擺盪</a></li>
<li>原則層：<a href="/blog/report/escape-hatch-absorbs-construction-gap/" data-link-title="逃生口吸收建構路徑的缺陷：修工廠的表達力、不是修拼裝點" data-link-desc="同族語意錯誤重複出現、或測試 Arrange 段大量用萬能拼裝工具建物件時使用。全欄位 copyWith 這類逃生口總有辦法把物件拼出來，於是建構路徑的表達力缺陷永遠不被迫修好——需求被逃生口吸收、以語意錯誤的形式在別處復發。修上游的表達力、不是修每一個拼裝點。">#223 逃生口吸收建構路徑的缺陷</a></li>
</ul>
]]></content:encoded></item><item><title>組裝層的可達性</title><link>https://tarrragon.github.io/blog/ddd/composition-root-reachability/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/composition-root-reachability/</guid><description>&lt;p>組裝層是應用程式把 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a> 插上 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter&lt;/a> 的地方——port 是 domain 對外宣告的介面、adapter 是介面的具體實作、&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">依賴注入&lt;/a> 是把兩者接起來的機制。組裝發生在三個位置：依賴注入的組裝集中在 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root&lt;/a>（組裝根、依賴注入唯一的組裝起點），路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面。位置不同、同屬組裝層的責任：把功能單元接到使用者按得到的入口上。使用者入口之外、機器觸發的組裝位置（事件訂閱、排程任務）有同一種證言缺口、判準同樣適用；本章聚焦使用者入口。&lt;/p>
&lt;p>本模組（&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>）的源頭句「讓違反規則的路徑走不通」守的是領域內部的規則；本章守它的對偶面——讓正確的路徑確實走得通。domain 模型全對、系統不可用，是分層架構特有的失效形態：每一層各自正確、層與層之間沒接上。&lt;/p>
&lt;h2 id="一個專案的五個問題">一個專案的五個問題&lt;/h2>
&lt;p>這個失效形態先用一個完整案例展開——本章後面的每個判準與工具都從它歸納而來。一個專案的單元測試全數通過、實機測試找出五個問題（完整記錄與 Flutter / Riverpod 的實作細節見 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/" data-link-title="測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口" data-link-desc="113 張票收尾全綠的版本，實機測試找出五個問題：路由指向佔位頁、provider 佔位 throw、按鈕空 callback，加上兩個平台語意差異。單元測試的 mock override 是正當的測試 seam、同時是遮蔽接線斷裂的來源——記下三層共振的機制、反向追溯設計文件的結果、以及修補時規格層／測試層／發版層的分層落點。">測試全綠、功能失聯&lt;/a>）：&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>注入項停在拋「requires override」的佔位、production 一解析就連鎖報錯&lt;/td>
 &lt;td>DI 組裝&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>路由佔位&lt;/td>
 &lt;td>入口按鈕彈出「功能開發中」、路由表把入口指向佔位頁——功能本體已全部完成&lt;/td>
 &lt;td>路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空 callback&lt;/td>
 &lt;td>按鈕的事件處理是空實作、點擊沒有任何反應&lt;/td>
 &lt;td>UI 事件接線&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>一條資料庫設定指令在目標平台的執行語意不同、開啟直接失敗、持久化全面不可用&lt;/td>
 &lt;td>平台語意&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前三個是同一種形狀：功能單元全部存在、對應的測試全部通過，斷的是「把功能接到入口」的那一段——依賴注入沒接上真實實作、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在開發機的測試環境（host 環境）行為正確、在目標平台的語意下失效。兩種形狀的對策不同，平台差異的處置收在「邊界」一節；本章的主線是前三個。&lt;/p>
&lt;h2 id="分層的承諾與它的影子">分層的承諾與它的影子&lt;/h2>
&lt;p>分層架構（以及 ports &amp;amp; adapters、即六角形架構）的核心承諾是 domain 不依賴框架、可以脫離 infrastructure 單獨測試。兌現承諾的機制是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/test-seam/" data-link-title="Test Seam" data-link-desc="測試想把真實依賴換成替身、該從哪裡換時使用。seam 是不修改程式本體就能替換其中一段行為的位置——介面加注入點是物件導向最常見的形式。">測試 seam&lt;/a>：seam 是不修改程式本體就能替換其中一段行為的位置——port 加上注入點、測試時把真實依賴換成 mock。seam 工作得越好、domain 的測試越乾淨，這是 DDD 教材反覆強調的部分。&lt;/p>
&lt;p>承諾有一道影子、而且跟承諾出自同一個機制：mock 換掉的正是組裝。本章用「證言」指一個測試通過時能證明的事——證言的範圍由測試的執行環境決定、mock 環境的綠燈證明的是 mock 環境下的行為。於是「組裝有沒有完成」這件事、在以 seam 為基礎的測試套件裡無從證明。兩類測試各自的證言範圍：&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>mock／override 注入&lt;/td>
 &lt;td>功能邏輯正確&lt;/td>
 &lt;td>零&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>接線測試&lt;/td>
 &lt;td>組裝路徑零 override、真實路由表與依賴&lt;/td>
 &lt;td>port 插上了 adapter&lt;/td>
 &lt;td>測試層唯一來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>行為測試維持 mock 注入、驗的是功能邏輯。上述案例的測試全落在這一類、全綠是誠實的：功能邏輯確實正確、綠燈在它的證言範圍內沒有失真——失真發生在把這個綠燈讀成「功能可用」的那一步。&lt;/p>
&lt;p>接線測試補的正是缺掉的那份證言、下一節展開。&lt;/p>
&lt;h2 id="接線測試組裝證言的來源">接線測試：組裝證言的來源&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/wiring-test/" data-link-title="Wiring Test" data-link-desc="行為測試全綠、還想知道組裝有沒有完成時使用。接線測試在組裝路徑零 override 的環境解析每個注入項、走真實路由表，只驗「port 插上了 adapter」。">接線測試&lt;/a>（wiring test）在組裝路徑零 override 的環境驗證組裝完成：逐一解析每個注入項、用真實路由表逐條導航、對每顆按鈕實際觸發並斷言導航發生。它只驗「插上了沒」、功能邏輯留給行為測試——兩類測試各守自己的證言範圍、彼此替代不了。&lt;/p>
&lt;p>它在既有測試光譜上的位置，可以從兩個熟悉的參照點定出來。第一個參照點是端對端測試：E2E 在目標平台同時驗行為與接線、代價是裝置需求與執行不穩定；接線測試把驗證範圍收窄到「接上了沒」、換取在 host 環境快速且確定地執行。第二個參照點是 DI 容器的自驗證：部分容器提供「啟動時逐一解析全部註冊項」的機制、專門攔沒被提供實作的註冊項；接線測試是這個機制的推廣——解析之外、把路由與 UI callback 的接線納進同一種證言。&lt;/p>
&lt;p>零 override 有明確的邊界：它指的是組裝路徑——domain 到入口之間不替換任何一段。最外圈的 infrastructure（遠端服務這類）可以在邊界替換、不影響組裝的證言。單機的 local-first 應用做得到全量零 override；有遠端依賴的專案、零 override 的範圍就是組裝路徑本身。&lt;/p>
&lt;p>該專案的修復批次先補了這批接線測試、對目標行為斷言、修復前確定性紅燈，修復以測試變綠為驗收點。日常的判讀分界在專案層級：override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析注入項」的測試、組裝層才算沒有證言。&lt;/p>
&lt;h2 id="佔位合法的中間態最靜默的失效">佔位：合法的中間態、最靜默的失效&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/placeholder/" data-link-title="Placeholder" data-link-desc="「開發中」頁、空 callback、拋出例外的注入項該怎麼管理時使用。佔位是先立介面後補實作的合法中間態——它的失效讓測試綠燈、比文件層約束更靜默。">佔位&lt;/a>是「先立介面、實作後補」的合法開發中間態：指向「開發中」頁的路由、空的事件 callback、拋「requires override」的注入項。先把介面立起來、實作後補，這個節奏本身沒有問題；問題在佔位的失效形態。&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> 記過文件層約束的失效是靜默的；佔位的失效比文件層再隱蔽一級——它讓測試綠燈。型別層看佔位是合法構件、行為測試的 override 讓它永遠沒被觸發、只有零 override 的解析會撞上它。文件層約束至少留下「規則寫在那裡、沒人遵守」的對照證據；佔位連對照證據都沒有、驗收讀到的只有通過。&lt;/p>
&lt;p>佔位因此需要一個獨立的攔截點。這個案例的處置是把攔截放進發版前置檢查：出現過的三種形態（佔位路由、佔位注入項、空 callback）都是靜態可掃描的——佔位頁的型別名、「requires override」的拋出語句、空函式體都 grep 得到——掃到即警告、由人判斷是刻意中間態還是漏網。&lt;/p>
&lt;p>回傳假值的佔位是掃描的死角：hardcoded 假資料、回空集合的 stub，在掃描與接線測試眼中都是正常構件——注入項解析得了、導航也會發生。這一類要靠行為斷言（對回傳內容驗證、假資料過不了斷言）或發版前的實機冒煙走查攔截；走查的三類敏感點在發版層一段收束、完整清單的住址在 work-log 記錄。同族的另一個死角是「插錯」：注入項插上的是真實作、只是不該是它（例如測試用實作被註冊進 production 組態）——解析成功、接線測試綠燈。接線測試的保證天花板在這裡很清楚：它證明「有插、插得起來」；插上的實作對不對、屬行為與整合測試的證言。&lt;/p>
&lt;h2 id="可達性作為組裝層的不變式">可達性作為組裝層的不變式&lt;/h2>
&lt;p>「use case 描述的每個入口在 production 環境可達」是一條 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式&lt;/a>。不變式的落點判準（完整推導見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>）把約束分成文件、型別、執行三層：寫在文件裡靠人記得並自律、做進型別讓錯誤的寫法在編譯期被擋下、放在執行層讓違反在 runtime 當場被拒絕。把判準套在可達性上、答案是多層都有位置、單獨任何一層都不夠：&lt;/p></description><content:encoded><![CDATA[<p>組裝層是應用程式把 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 插上 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a> 的地方——port 是 domain 對外宣告的介面、adapter 是介面的具體實作、<a href="/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">依賴注入</a> 是把兩者接起來的機制。組裝發生在三個位置：依賴注入的組裝集中在 <a href="/blog/ddd/knowledge-cards/composition-root/" data-link-title="Composition Root" data-link-desc="依賴組裝、路由註冊該集中在哪、組裝斷裂去哪檢查時使用。composition root 是應用程式唯一的組裝起點——DI、路由、事件接線的集中處。">composition root</a>（組裝根、依賴注入唯一的組裝起點），路由表以單點註冊靠攏它，UI 事件的接線散佈在各畫面。位置不同、同屬組裝層的責任：把功能單元接到使用者按得到的入口上。使用者入口之外、機器觸發的組裝位置（事件訂閱、排程任務）有同一種證言缺口、判準同樣適用；本章聚焦使用者入口。</p>
<p>本模組（<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>）的源頭句「讓違反規則的路徑走不通」守的是領域內部的規則；本章守它的對偶面——讓正確的路徑確實走得通。domain 模型全對、系統不可用，是分層架構特有的失效形態：每一層各自正確、層與層之間沒接上。</p>
<h2 id="一個專案的五個問題">一個專案的五個問題</h2>
<p>這個失效形態先用一個完整案例展開——本章後面的每個判準與工具都從它歸納而來。一個專案的單元測試全數通過、實機測試找出五個問題（完整記錄與 Flutter / Riverpod 的實作細節見 <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>）：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>現象</th>
          <th>斷裂點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入項佔位</td>
          <td>注入項停在拋「requires override」的佔位、production 一解析就連鎖報錯</td>
          <td>DI 組裝</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>入口按鈕彈出「功能開發中」、路由表把入口指向佔位頁——功能本體已全部完成</td>
          <td>路由表</td>
      </tr>
      <tr>
          <td>空 callback</td>
          <td>按鈕的事件處理是空實作、點擊沒有任何反應</td>
          <td>UI 事件接線</td>
      </tr>
      <tr>
          <td>初始化順序</td>
          <td>框架初始化與例外攔截散在不同範圍、非同步例外可能逃出攔截</td>
          <td>平台語意</td>
      </tr>
      <tr>
          <td>資料庫開啟失敗</td>
          <td>一條資料庫設定指令在目標平台的執行語意不同、開啟直接失敗、持久化全面不可用</td>
          <td>平台語意</td>
      </tr>
  </tbody>
</table>
<p>前三個是同一種形狀：功能單元全部存在、對應的測試全部通過，斷的是「把功能接到入口」的那一段——依賴注入沒接上真實實作、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在開發機的測試環境（host 環境）行為正確、在目標平台的語意下失效。兩種形狀的對策不同，平台差異的處置收在「邊界」一節；本章的主線是前三個。</p>
<h2 id="分層的承諾與它的影子">分層的承諾與它的影子</h2>
<p>分層架構（以及 ports &amp; adapters、即六角形架構）的核心承諾是 domain 不依賴框架、可以脫離 infrastructure 單獨測試。兌現承諾的機制是 <a href="/blog/ddd/knowledge-cards/test-seam/" data-link-title="Test Seam" data-link-desc="測試想把真實依賴換成替身、該從哪裡換時使用。seam 是不修改程式本體就能替換其中一段行為的位置——介面加注入點是物件導向最常見的形式。">測試 seam</a>：seam 是不修改程式本體就能替換其中一段行為的位置——port 加上注入點、測試時把真實依賴換成 mock。seam 工作得越好、domain 的測試越乾淨，這是 DDD 教材反覆強調的部分。</p>
<p>承諾有一道影子、而且跟承諾出自同一個機制：mock 換掉的正是組裝。本章用「證言」指一個測試通過時能證明的事——證言的範圍由測試的執行環境決定、mock 環境的綠燈證明的是 mock 環境下的行為。於是「組裝有沒有完成」這件事、在以 seam 為基礎的測試套件裡無從證明。兩類測試各自的證言範圍：</p>
<table>
  <thead>
      <tr>
          <th>測試</th>
          <th>執行環境</th>
          <th>證明什麼</th>
          <th>對組裝的證言</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行為測試</td>
          <td>mock／override 注入</td>
          <td>功能邏輯正確</td>
          <td>零</td>
      </tr>
      <tr>
          <td>接線測試</td>
          <td>組裝路徑零 override、真實路由表與依賴</td>
          <td>port 插上了 adapter</td>
          <td>測試層唯一來源</td>
      </tr>
  </tbody>
</table>
<p>行為測試維持 mock 注入、驗的是功能邏輯。上述案例的測試全落在這一類、全綠是誠實的：功能邏輯確實正確、綠燈在它的證言範圍內沒有失真——失真發生在把這個綠燈讀成「功能可用」的那一步。</p>
<p>接線測試補的正是缺掉的那份證言、下一節展開。</p>
<h2 id="接線測試組裝證言的來源">接線測試：組裝證言的來源</h2>
<p><a href="/blog/ddd/knowledge-cards/wiring-test/" data-link-title="Wiring Test" data-link-desc="行為測試全綠、還想知道組裝有沒有完成時使用。接線測試在組裝路徑零 override 的環境解析每個注入項、走真實路由表，只驗「port 插上了 adapter」。">接線測試</a>（wiring test）在組裝路徑零 override 的環境驗證組裝完成：逐一解析每個注入項、用真實路由表逐條導航、對每顆按鈕實際觸發並斷言導航發生。它只驗「插上了沒」、功能邏輯留給行為測試——兩類測試各守自己的證言範圍、彼此替代不了。</p>
<p>它在既有測試光譜上的位置，可以從兩個熟悉的參照點定出來。第一個參照點是端對端測試：E2E 在目標平台同時驗行為與接線、代價是裝置需求與執行不穩定；接線測試把驗證範圍收窄到「接上了沒」、換取在 host 環境快速且確定地執行。第二個參照點是 DI 容器的自驗證：部分容器提供「啟動時逐一解析全部註冊項」的機制、專門攔沒被提供實作的註冊項；接線測試是這個機制的推廣——解析之外、把路由與 UI callback 的接線納進同一種證言。</p>
<p>零 override 有明確的邊界：它指的是組裝路徑——domain 到入口之間不替換任何一段。最外圈的 infrastructure（遠端服務這類）可以在邊界替換、不影響組裝的證言。單機的 local-first 應用做得到全量零 override；有遠端依賴的專案、零 override 的範圍就是組裝路徑本身。</p>
<p>該專案的修復批次先補了這批接線測試、對目標行為斷言、修復前確定性紅燈，修復以測試變綠為驗收點。日常的判讀分界在專案層級：override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析注入項」的測試、組裝層才算沒有證言。</p>
<h2 id="佔位合法的中間態最靜默的失效">佔位：合法的中間態、最靜默的失效</h2>
<p><a href="/blog/ddd/knowledge-cards/placeholder/" data-link-title="Placeholder" data-link-desc="「開發中」頁、空 callback、拋出例外的注入項該怎麼管理時使用。佔位是先立介面後補實作的合法中間態——它的失效讓測試綠燈、比文件層約束更靜默。">佔位</a>是「先立介面、實作後補」的合法開發中間態：指向「開發中」頁的路由、空的事件 callback、拋「requires override」的注入項。先把介面立起來、實作後補，這個節奏本身沒有問題；問題在佔位的失效形態。</p>
<p><a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a> 記過文件層約束的失效是靜默的；佔位的失效比文件層再隱蔽一級——它讓測試綠燈。型別層看佔位是合法構件、行為測試的 override 讓它永遠沒被觸發、只有零 override 的解析會撞上它。文件層約束至少留下「規則寫在那裡、沒人遵守」的對照證據；佔位連對照證據都沒有、驗收讀到的只有通過。</p>
<p>佔位因此需要一個獨立的攔截點。這個案例的處置是把攔截放進發版前置檢查：出現過的三種形態（佔位路由、佔位注入項、空 callback）都是靜態可掃描的——佔位頁的型別名、「requires override」的拋出語句、空函式體都 grep 得到——掃到即警告、由人判斷是刻意中間態還是漏網。</p>
<p>回傳假值的佔位是掃描的死角：hardcoded 假資料、回空集合的 stub，在掃描與接線測試眼中都是正常構件——注入項解析得了、導航也會發生。這一類要靠行為斷言（對回傳內容驗證、假資料過不了斷言）或發版前的實機冒煙走查攔截；走查的三類敏感點在發版層一段收束、完整清單的住址在 work-log 記錄。同族的另一個死角是「插錯」：注入項插上的是真實作、只是不該是它（例如測試用實作被註冊進 production 組態）——解析成功、接線測試綠燈。接線測試的保證天花板在這裡很清楚：它證明「有插、插得起來」；插上的實作對不對、屬行為與整合測試的證言。</p>
<h2 id="可達性作為組裝層的不變式">可達性作為組裝層的不變式</h2>
<p>「use case 描述的每個入口在 production 環境可達」是一條 <a href="/blog/ddd/knowledge-cards/invariant/" data-link-title="Invariant" data-link-desc="領域模型的約束規則落在哪一層時使用。不變式是在物件整個生命週期都必須為真的業務規則——狀態只能沿流程轉換、被同一條規則綁住的欄位必須一起換。">不變式</a>。不變式的落點判準（完整推導見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）把約束分成文件、型別、執行三層：寫在文件裡靠人記得並自律、做進型別讓錯誤的寫法在編譯期被擋下、放在執行層讓違反在 runtime 當場被拒絕。把判準套在可達性上、答案是多層都有位置、單獨任何一層都不夠：</p>
<p><strong>文件層</strong>：use case 的成功保證補上可達性條款——路由指向真實頁面、callback 已接線、注入項在無 override 環境可解析。這層的作用是把組裝責任寫進驗收範圍：它定義「什麼叫完成」。文件層失效靜默、單獨存在時只是願望；怎麼把這層寫得可檢核、「use case 的完成定義」一節展開。</p>
<p><strong>型別層</strong>：組裝完成性用型別表達的空間有限、而且空間大小由生態決定。在編譯期生成組裝碼的 DI 生態、缺實作是編譯錯誤、型別層接得住這條不變式；runtime 解析的容器把「這個注入項沒人提供實作」推遲到執行期、編譯器看不見。路由表同理：以字串鍵查表的路由生態、斷鏈也是執行期才暴露。用 runtime 容器與字串路由的專案、型別層在這條不變式上是輔助而非主力。</p>
<p><strong>執行層</strong>：接線測試——違反當場紅燈、失效點集中。刻意不可達的入口（feature flag 關閉、分階段發布）不算違反：以旗標開啟的組態跑接線測試、或明列豁免清單——豁免要有記錄、讓「刻意」與「遺漏」在清單上分得開。</p>
<p><strong>發版層</strong>（對應強制層次一章補充的 CI 檢查層）：佔位掃描加實機冒煙走查。前者警示靜態可掃的佔位、由人判讀；後者是發版前在目標平台人工走一輪的清單——啟動健康、use case 主路徑走查、平台敏感點——攔 host 測試構不到的平台語意差異。</p>
<h2 id="use-case-的完成定義">use case 的完成定義</h2>
<p>組裝斷裂的上游在設計文件——這一節是文件層那一列的展開：可達性條款要寫得進 use case、use case 本身先要能回答裝配問題。</p>
<p>修復批次拿五個問題反向追溯提案與 use case 文件、確認每個問題在文件裡的對應條目長什麼樣。結果分成兩型：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>設計文件對應</th>
          <th>缺口型態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入項佔位</td>
          <td>提案定義了完整的功能方法與 UI 形態</td>
          <td>有能力定義、驗收不含可達性</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>use case 寫了「使用者點擊按鈕」</td>
          <td>有行為描述、接線無人認領</td>
      </tr>
      <tr>
          <td>空 callback</td>
          <td>use case 寫了操作步驟</td>
          <td>callback 實作不在驗收內</td>
      </tr>
      <tr>
          <td>兩個平台問題</td>
          <td>全文無對應</td>
          <td>平台層細節、設計文件構不到</td>
      </tr>
  </tbody>
</table>
<p>追溯出一個一致的形狀：提案思考「能力」（系統要有哪些功能）、use case 思考「行為」（使用者做什麼、系統回應什麼），兩者之間的「裝配」——把能力組起來、給行為一個入口的責任——沒有歸屬。三個組裝斷裂全部落在這條縫裡。</p>
<p>把縫補起來的做法是讓 use case 撰寫承擔對提案的壓力測試：寫 use case 時逐一回答四個檢核問句、填不出來的問句直接指出對應的設計缺口。問句從五個問題歸納而來、是起點集、新的斷裂形態出現時往上加。</p>
<p><strong>名詞可定位</strong>——文中每個畫面、按鈕、服務，能填出「位於哪個畫面、經哪條路由、由誰供給」。填不出來代表提案只設計了能力、沒設計裝配：案例裡注入項佔位對應的功能、提案寫滿了功能方法與 UI 形態、按鈕跟頁面由誰放上去沒有一個字。</p>
<p><strong>路徑連通</strong>——步驟一的入口能從應用程式啟動畫面一路追到。追不出這條路、入口接線就無人認領：路由佔位問題的 use case 描述了使用者「點擊按鈕」、按鈕到路由到頁面這條鏈的歸屬、文件裡翻不到。直達入口（web 的 URL、深連結、外部喚起）另立一類：它們繞過啟動鏈、每個直達入口各自追一條可達性。</p>
<p><strong>狀態完備</strong>——涉及的每個畫面、狀態機的每個狀態（空、正常、錯誤、權限與登入態）下入口可見性都有答案。這一問附帶一個操作型式：前置條件反向展開。use case 的前置條件每一條都隱含一個「不滿足時會怎樣」的狀態分支。案例裡一條前置條件寫「存在至少一筆資料」——實機上使用者的資料是空的、對應按鈕只存在於正常狀態分支、使用者的回報是「功能不見了」。把前置條件當成狀態枚舉的線索用、空狀態的畫面在設計期就被逼著給出答案；它與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 談的領域狀態機互為投影——領域的前置條件投影到畫面層、就是「這個狀態下入口在哪」的答案。</p>
<p><strong>環境差異</strong>——涉及平台 API 的步驟、「測試環境與目標平台行為一致嗎」有答案。答案為否或不確定的步驟、標記「需實機驗證」、餵給發版前的冒煙走查清單：兩個平台問題在設計文件裡無處可防、能做的是在設計期把「這一步只有實機能驗」標出來、讓漏網的位置從使用者手上移到發版前的清單上。</p>
<p>任何一問填不出來、代表對應的設計缺口還停在提案層、退回補齊再進實作。</p>
<h2 id="邊界組裝可達之外的失效">邊界：組裝可達之外的失效</h2>
<p>缺口分類先於對策。五個問題的兩種形狀對應兩組修法：組裝斷裂修規格與測試（可達性條款、接線測試）、平台差異修驗證流程（設計期標記、冒煙走查）。混用的代價是把「補測試」當成萬靈丹——平台語意差異是 host 測試補不到的那一類、案例裡那條資料庫設定指令的語意差異、開發機的測試環境重現不出來、要目標平台實際執行才暴露。</p>
<p>本章的作用域同樣要誠實標明：可達性守的是「入口接上了」；接上之後的行為正確性屬行為測試、資料在層間流動的正確性屬整合測試的其他主題。組裝層的證言與六角形內部的證言互補、彼此替代不了：層內的設計判準（<a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>、<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）決定六角形內部的品質、<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a> 收物件怎麼被建出來、本章把同一個問題抬到應用程式層。層內的模型設計得再好、組裝層沒有證言、使用者拿到的仍然可能是一片接不通的綠燈。</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>Flutter / Riverpod 的實作細節（override 判讀分界、接線測試與修復的執行記錄、佔位掃描與冒煙清單）：<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></li>
<li>同一失效形態的畫面層檢查法（路由存在但不可達）：<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查</a></li>
</ul>
]]></content:encoded></item><item><title>觀測出口的職責三分</title><link>https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口&lt;/a>是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 對外提供的「資料變了」持續通知能力——pull 介面（&lt;code>getAllBooks()&lt;/code> 回 &lt;code>Future&lt;/code>）的 push 對應（&lt;code>watchBooks()&lt;/code> 回 &lt;code>Stream&lt;/code>）。UI 框架要 reactive 觀察 domain 資料時，這個能力橫跨三層，職責三分：&lt;strong>契約&lt;/strong>（介面宣告，歸 domain）、&lt;strong>機制&lt;/strong>（變更偵測，歸 infrastructure）、&lt;strong>組裝&lt;/strong>（框架訂閱，歸 DI／presentation 層）。三分的判準只有一條：&lt;strong>每一層的產出用什麼語言表達、就歸屬表達那種語言的層&lt;/strong>。&lt;/p>
&lt;p>這條判準值得成章，因為它跟一個直覺衝突：觀測出口的需求完全來自消費端——是 UI 框架想觀察、domain 自己沒有這個需要。「誰需要就放誰那層」的直覺會把 Stream 出口做進 presentation 的某個 service，讓 domain 保持「純淨」；本章論證這個直覺用錯了判準的位置。&lt;/p>
&lt;h2 id="案例六個視圖兩種補償一個缺口">案例：六個視圖、兩種補償、一個缺口&lt;/h2>
&lt;p>一個書庫管理 App 的 repository 是純 &lt;code>Future&lt;/code> pull 介面。盤點 presentation 層讀書庫資料的六個衍生視圖，只有一個有 reactive 機制（且僅涵蓋部分路徑），其餘全靠命令式載入加補償：&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;code>loadBooks()&lt;/code>、外部觸發&lt;/td>
 &lt;td>高：跨頁加書後無自動刷新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料統計&lt;/td>
 &lt;td>EventBus 全事件監聽 + 導航補償&lt;/td>
 &lt;td>中：未發事件的寫入路徑斷鏈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>待補完列表&lt;/td>
 &lt;td>衍生自一次性 &lt;code>FutureProvider&lt;/code>&lt;/td>
 &lt;td>高：上游無失效機制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>其餘三個視圖&lt;/td>
 &lt;td>各自命令式載入&lt;/td>
 &lt;td>低到中：同頁操作後手動 reload&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>根因是同一個：repository 沒有變更通知出口，六個視圖各自在圖外解「怎麼知道資料變了」這一題，解出兩種補償策略（導航返回點重載、經 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus&lt;/a>——行程內的發布／訂閱事件匯流排——的 domain event 橋接）、職責交叉且涵蓋不完整。補償演進的完整記錄在 &lt;a href="https://tarrragon.github.io/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 圖、不是資料庫&lt;/a>；本章從這個案例抽三層歸屬的判準。&lt;/p>
&lt;h2 id="契約層歸屬由介面語言決定不由需求來源決定">契約層：歸屬由介面語言決定、不由需求來源決定&lt;/h2>
&lt;p>契約是那一行介面宣告：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">Stream&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">watchBooks&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它該放 domain repository 介面、還是該為了「不污染 domain」放外層？判準是逐一檢查簽名裡的型別屬於哪種語言：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>簽名裡的型別&lt;/th>
 &lt;th>語言歸屬&lt;/th>
 &lt;th>洩漏判定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Stream&amp;lt;T&amp;gt;&lt;/code>（&lt;code>dart:async&lt;/code>）&lt;/td>
 &lt;td>語言標準庫&lt;/td>
 &lt;td>不算洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Book&lt;/code>（domain entity）&lt;/td>
 &lt;td>domain 自有語言&lt;/td>
 &lt;td>不算洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>StreamProvider&lt;/code> / &lt;code>Ref&lt;/code>（Riverpod）&lt;/td>
 &lt;td>框架語言&lt;/td>
 &lt;td>洩漏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Database&lt;/code>（SQLite）&lt;/td>
 &lt;td>infrastructure 語言&lt;/td>
 &lt;td>洩漏&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>語言標準庫的非同步原語跟 domain 的關係、和 &lt;code>Future&lt;/code> 完全等價：repository 介面回 &lt;code>Future&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code> 從來沒人覺得是洩漏，&lt;code>Stream&lt;/code> 是同一個標準庫裡「多值版的 Future」、地位等同。&lt;code>watchBooks()&lt;/code> 全句只用標準庫加 domain entity——它說的是 domain 的語言，放 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a> 所在的 domain 介面，跟 &lt;code>getAllBooks()&lt;/code> 形成 pull／push 對稱。&lt;/p>
&lt;p>這裡就是與直覺衝突的位置。&lt;strong>需求從消費者出發、決定介面該不該存在；介面放哪一層、由表達語言決定&lt;/strong>。兩個問題常被壓成一個：&lt;/p>
&lt;ul>
&lt;li>「UI 想觀察資料」是消費端需求——它回答「要不要有 &lt;code>watchBooks()&lt;/code>」。介面設計本來就該從消費者需求出發，port 的形狀由呼叫方的需要定義。&lt;/li>
&lt;li>「&lt;code>watchBooks()&lt;/code> 歸誰」是歸屬問題——它由簽名語言回答。簽名說 domain 的語言，介面就是 domain 的；哪天簽名裡出現 &lt;code>AsyncValue&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code>（框架型別），才是把消費端的語言帶進了 domain、才需要擋。&lt;/li>
&lt;/ul>
&lt;p>用需求來源判歸屬會兩頭錯：把說 domain 語言的介面推到外層，domain 的資料能力（「我管理的資料變了、我能告訴你」）散落到 infrastructure 或 presentation 的 service 裡；或者反過來，以「需求是 domain 相關」為由把帶框架型別的介面塞進 domain。&lt;/p>
&lt;p>契約層還有一個二擇：放既有 repository 介面、還是抽獨立的查詢 port？本案放既有介面——讀需求只有「完整書單流」一條、六個視圖都能從書單流投影，為一個方法抽 port 是介面碎片化。這個「何時該抽」的判準有自己的一章：&lt;a href="https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>。讀 port 一旦抽出，仍屬契約層的延伸——簽名同樣只用 domain 語言，機制層與組裝層的三分模型對它同樣適用。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ddd/knowledge-cards/observation-outlet/" data-link-title="Observation Outlet（觀測出口）" data-link-desc="repository 只有 pull 介面、衍生視圖靠補償刷新，考慮補「資料變了」的推送能力時使用。觀測出口是 pull 介面的 push 對應——能力橫跨三層、歸屬由各層的表達語言決定。">觀測出口</a>是 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 對外提供的「資料變了」持續通知能力——pull 介面（<code>getAllBooks()</code> 回 <code>Future</code>）的 push 對應（<code>watchBooks()</code> 回 <code>Stream</code>）。UI 框架要 reactive 觀察 domain 資料時，這個能力橫跨三層，職責三分：<strong>契約</strong>（介面宣告，歸 domain）、<strong>機制</strong>（變更偵測，歸 infrastructure）、<strong>組裝</strong>（框架訂閱，歸 DI／presentation 層）。三分的判準只有一條：<strong>每一層的產出用什麼語言表達、就歸屬表達那種語言的層</strong>。</p>
<p>這條判準值得成章，因為它跟一個直覺衝突：觀測出口的需求完全來自消費端——是 UI 框架想觀察、domain 自己沒有這個需要。「誰需要就放誰那層」的直覺會把 Stream 出口做進 presentation 的某個 service，讓 domain 保持「純淨」；本章論證這個直覺用錯了判準的位置。</p>
<h2 id="案例六個視圖兩種補償一個缺口">案例：六個視圖、兩種補償、一個缺口</h2>
<p>一個書庫管理 App 的 repository 是純 <code>Future</code> pull 介面。盤點 presentation 層讀書庫資料的六個衍生視圖，只有一個有 reactive 機制（且僅涵蓋部分路徑），其餘全靠命令式載入加補償：</p>
<table>
  <thead>
      <tr>
          <th>視圖</th>
          <th>刷新方式</th>
          <th>過期風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>書庫清單</td>
          <td>命令式 <code>loadBooks()</code>、外部觸發</td>
          <td>高：跨頁加書後無自動刷新</td>
      </tr>
      <tr>
          <td>資料統計</td>
          <td>EventBus 全事件監聽 + 導航補償</td>
          <td>中：未發事件的寫入路徑斷鏈</td>
      </tr>
      <tr>
          <td>待補完列表</td>
          <td>衍生自一次性 <code>FutureProvider</code></td>
          <td>高：上游無失效機制</td>
      </tr>
      <tr>
          <td>其餘三個視圖</td>
          <td>各自命令式載入</td>
          <td>低到中：同頁操作後手動 reload</td>
      </tr>
  </tbody>
</table>
<p>根因是同一個：repository 沒有變更通知出口，六個視圖各自在圖外解「怎麼知道資料變了」這一題，解出兩種補償策略（導航返回點重載、經 <a href="/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus</a>——行程內的發布／訂閱事件匯流排——的 domain event 橋接）、職責交叉且涵蓋不完整。補償演進的完整記錄在 <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>；本章從這個案例抽三層歸屬的判準。</p>
<h2 id="契約層歸屬由介面語言決定不由需求來源決定">契約層：歸屬由介面語言決定、不由需求來源決定</h2>
<p>契約是那一行介面宣告：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">Stream</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">watchBooks</span><span class="p">();</span></span></span></code></pre></div><p>它該放 domain repository 介面、還是該為了「不污染 domain」放外層？判準是逐一檢查簽名裡的型別屬於哪種語言：</p>
<table>
  <thead>
      <tr>
          <th>簽名裡的型別</th>
          <th>語言歸屬</th>
          <th>洩漏判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Stream&lt;T&gt;</code>（<code>dart:async</code>）</td>
          <td>語言標準庫</td>
          <td>不算洩漏</td>
      </tr>
      <tr>
          <td><code>Book</code>（domain entity）</td>
          <td>domain 自有語言</td>
          <td>不算洩漏</td>
      </tr>
      <tr>
          <td><code>StreamProvider</code> / <code>Ref</code>（Riverpod）</td>
          <td>框架語言</td>
          <td>洩漏</td>
      </tr>
      <tr>
          <td><code>Database</code>（SQLite）</td>
          <td>infrastructure 語言</td>
          <td>洩漏</td>
      </tr>
  </tbody>
</table>
<p>語言標準庫的非同步原語跟 domain 的關係、和 <code>Future</code> 完全等價：repository 介面回 <code>Future&lt;List&lt;Book&gt;&gt;</code> 從來沒人覺得是洩漏，<code>Stream</code> 是同一個標準庫裡「多值版的 Future」、地位等同。<code>watchBooks()</code> 全句只用標準庫加 domain entity——它說的是 domain 的語言，放 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a> 所在的 domain 介面，跟 <code>getAllBooks()</code> 形成 pull／push 對稱。</p>
<p>這裡就是與直覺衝突的位置。<strong>需求從消費者出發、決定介面該不該存在；介面放哪一層、由表達語言決定</strong>。兩個問題常被壓成一個：</p>
<ul>
<li>「UI 想觀察資料」是消費端需求——它回答「要不要有 <code>watchBooks()</code>」。介面設計本來就該從消費者需求出發，port 的形狀由呼叫方的需要定義。</li>
<li>「<code>watchBooks()</code> 歸誰」是歸屬問題——它由簽名語言回答。簽名說 domain 的語言，介面就是 domain 的；哪天簽名裡出現 <code>AsyncValue&lt;List&lt;Book&gt;&gt;</code>（框架型別），才是把消費端的語言帶進了 domain、才需要擋。</li>
</ul>
<p>用需求來源判歸屬會兩頭錯：把說 domain 語言的介面推到外層，domain 的資料能力（「我管理的資料變了、我能告訴你」）散落到 infrastructure 或 presentation 的 service 裡；或者反過來，以「需求是 domain 相關」為由把帶框架型別的介面塞進 domain。</p>
<p>契約層還有一個二擇：放既有 repository 介面、還是抽獨立的查詢 port？本案放既有介面——讀需求只有「完整書單流」一條、六個視圖都能從書單流投影，為一個方法抽 port 是介面碎片化。這個「何時該抽」的判準有自己的一章：<a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。讀 port 一旦抽出，仍屬契約層的延伸——簽名同樣只用 domain 語言，機制層與組裝層的三分模型對它同樣適用。</p>
<p>語言歸屬判準處理的是「詞彙是否洩漏」這一種反對意見。DDD 文獻裡存在另一派更根本的立場：即使簽名純淨，Repository 模式在 Evans / Vernon 的原始定義裡是集合式存取，reactive 訂閱能力本質上服務查詢端、屬於讀側的關注點，不論詞彙是否純淨都不該掛在寫側 aggregate 的 repository 介面上。這個立場涉及讀寫分離的組織方式（讀 port 何時值得獨立、<a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a> 階梯的升級判準），跟詞彙判準各自回答不同的問題：詞彙判準回答「語言有沒有洩漏」，讀寫分離判準回答「職責該不該分離」。本章只處理前者；後者見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p>
<h2 id="機制層變更偵測是-adapter-的實作細節">機制層：變更偵測是 adapter 的實作細節</h2>
<p>機制層回答「怎麼知道資料變了」。這層的語言是 controller、資料庫 hook、交易——全是 infrastructure 詞彙，所以歸 <a href="/blog/ddd/knowledge-cards/adapter/" data-link-title="Adapter" data-link-desc="把領域需求翻譯成具體技術操作的實作該放哪、跟領域的邊界在哪時使用。adapter 是 port 的具體實作——技術細節被擋在六角形之外的位置。">adapter</a>。本案的二擇：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>寫入點 emit</th>
          <th>儲存層 update hook</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>確定性</td>
          <td>高：明確知道哪些操作觸發通知</td>
          <td>低：任何 row 變更都觸發、含 migration</td>
      </tr>
      <tr>
          <td>粒度</td>
          <td>精確：只在領域寫入方法尾端</td>
          <td>粗：要再過濾 table 與操作類型</td>
      </tr>
      <tr>
          <td>平台依賴</td>
          <td>無：純標準庫 controller</td>
          <td>有：依賴儲存引擎的 hook API</td>
      </tr>
      <tr>
          <td>裝飾層相容性</td>
          <td>好：decorator 直接轉發 stream</td>
          <td>要處理快取失效與 hook 的時序</td>
      </tr>
  </tbody>
</table>
<p>本案選寫入點 emit：repository 在每個實際執行寫入的方法尾端發出最新書單。它的維護成本（新增寫入方法要記得掛 emit）留在單一類別內部、被測試釘住；update hook 的 false positive 則會流出去變成消費端的雜訊。關鍵的架構事實是：<strong>這整個表格的內容都不出現在契約層</strong>——選哪個、換哪個，介面簽名一個字不動。這正是三分成立的證據：機制可替換、契約穩定。</p>
<h2 id="組裝層框架型別止步的位置">組裝層：框架型別止步的位置</h2>
<p>組裝層把 domain 的 <code>Stream</code> 翻譯成框架的觀察原語。本案是 Riverpod 的 <code>StreamProvider</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="kd">final</span> <span class="n">watchBooksProvider</span> <span class="o">=</span> <span class="n">StreamProvider</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="kd">async</span><span class="o">*</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">repository</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">yield</span> <span class="kd">await</span> <span class="n">repository</span><span class="p">.</span><span class="n">getAllBooks</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="kd">yield</span><span class="o">*</span> <span class="n">repository</span><span class="p">.</span><span class="n">watchBooks</span><span class="p">();</span>       <span class="c1">// 再轉接後續變更
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p><code>StreamProvider</code>、<code>Ref</code>、<code>AsyncValue</code> 這些框架型別在這層第一次出現、也只在這層出現。組裝層同時吸收「呈現需求」性質的行為——訂閱當下要先看到當前值，是消費端的需要、不是「變更通知」語意的一部分，所以那兩行 <code>yield</code> 住在這裡而非 repository 裡。組裝層的其他責任（誰插上誰、插上了沒有的證言）見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>；本案的 Flutter 實作細節見 <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>。</p>
<h2 id="三分總表">三分總表</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>產出</th>
          <th>表達語言</th>
          <th>歸屬</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>契約</td>
          <td><code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code> 宣告</td>
          <td>語言標準庫 + domain entity</td>
          <td>domain repository 介面</td>
      </tr>
      <tr>
          <td>機制</td>
          <td>broadcast controller + 寫入點 emit</td>
          <td>infrastructure 內部詞彙</td>
          <td>adapter（SQLite 實作類）</td>
      </tr>
      <tr>
          <td>組裝</td>
          <td><code>StreamProvider</code> 包裝 + <code>ref.watch</code></td>
          <td>框架語言</td>
          <td>DI／presentation 層</td>
      </tr>
  </tbody>
</table>
<p>三列共用同一條判準：看那一層的產出用什麼語言說話。這條判準的機械性有明確範圍——它機械地回答「一個已寫好的簽名該歸哪一層」：打開簽名、逐型別問「這是誰的詞彙」，答案不依賴對「純淨」的品味爭論。它不回答「一段新行為該寫進哪一層」（如初始值那兩行 <code>yield</code>）——那是語意歸屬決策，要判斷行為屬於誰的語意，跟機制層選 emit 時機一樣是設計判斷、不是型別檢查。把兩者混為一談、以為整篇的歸屬都能靠型別自動判定，正是這條判準最容易被過度推廣的地方。</p>
<h2 id="邊界">邊界</h2>
<p>觀測出口通知的是「資料現在長什麼樣」；domain event 記錄的是「發生過什麼業務事實」。兩者正交、互不取代——本案落地觀測出口時，既有的事件發布點零改動。這個正交性有一個架構前提：狀態存在獨立的資料庫、事件只是旁支通知。event-sourced 架構下狀態由事件重建、沒有獨立持久化的當前值，機制層要生出 <code>watchBooks()</code> 只能訂閱同一條事件流做投影——那時變更偵測與 domain event 不再是兩條獨立管線、「零改動」的論證不成立。把 event 借用成刷新訊號的代價、以及兩種載體的選用判準，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</p>
<h2 id="下一步">下一步</h2>
<p>三分的判準確定之後，下一個問題通常是「要不要抽讀 port」——先讀 <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> 先確認載體選擇，確認要用狀態流再進本章。</p>
<ul>
<li>補償演進與 Riverpod reactive 邊界的案例全文 → <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></li>
<li>機制層與組裝層的實作點（broadcast、初始值、dispose）→ <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></li>
</ul>
]]></content:encoded></item><item><title>讀模型的升級判準</title><link>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/read-model-upgrade-signals/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">讀模型&lt;/a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 回答「&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate&lt;/a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS&lt;/a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：&lt;/p>
&lt;blockquote>
&lt;p>這個查詢回傳的是&lt;strong>讀的形狀&lt;/strong>、還是 &lt;strong>aggregate 的形狀&lt;/strong>？&lt;/p>&lt;/blockquote>
&lt;p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。&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;th>新增代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一&lt;/td>
 &lt;td>repository 查詢方法（pull 或 push、回 aggregate 形狀）&lt;/td>
 &lt;td>消費端（ViewModel／service）自行投影&lt;/td>
 &lt;td>零：用既有介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>二&lt;/td>
 &lt;td>讀 port 抽離（獨立查詢介面、同一儲存）&lt;/td>
 &lt;td>port 實作內&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>CQRS 全套（讀寫獨立儲存、事件同步）&lt;/td>
 &lt;td>事件消費端&lt;/td>
 &lt;td>同步管線 + 最終一致性&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。&lt;/p>
&lt;p>階梯的方向性很重要：&lt;strong>每一階的介面簽名對消費端穩定&lt;/strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。&lt;/p>
&lt;h2 id="五訊號">五訊號&lt;/h2>
&lt;p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。&lt;/p>
&lt;h3 id="訊號一讀需求增生">訊號一：讀需求增生&lt;/h3>
&lt;p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。&lt;/p>
&lt;h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀&lt;/h3>
&lt;p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 &lt;code>map&lt;/code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。&lt;/p>
&lt;h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧&lt;/h3>
&lt;p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。&lt;/p>
&lt;h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同&lt;/h3>
&lt;p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。&lt;/p>
&lt;h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進&lt;/h3>
&lt;p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。&lt;/p>
&lt;h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界&lt;/h2>
&lt;p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&amp;rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。&lt;/p>
&lt;h2 id="階梯的適用範圍">階梯的適用範圍&lt;/h2>
&lt;p>四階假設在單一 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context&lt;/a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。&lt;/p>
&lt;h2 id="案例停在第一階的決策">案例：停在第一階的決策&lt;/h2>
&lt;p>書庫管理 App 為 repository 補了觀測出口 &lt;code>watchBooks()&lt;/code>（背景與三層歸屬見 &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;code>watchBooks()&lt;/code> 放既有 repository 介面、還是抽一個獨立的讀 port？&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;code>watchBooks()&lt;/code> 回 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code>——正是 aggregate 的形狀&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>所有衍生視圖都要即時&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;/tbody>
&lt;/table>
&lt;p>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：&lt;code>watchBooks()&lt;/code> 進既有 repository 介面、與 &lt;code>getAllBooks()&lt;/code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">讀模型</a>（read model）是為讀需求的形狀而建的查詢側模型：它回答「畫面或報表需要什麼形狀的資料」、而 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 回答「<a href="/blog/ddd/knowledge-cards/aggregate-root/" data-link-title="Aggregate Root" data-link-desc="跨物件一致性的邊界設計時使用。聚合根是對外代表一組資料一致性的邊界物件——外部只跟它互動、它保證內部的不變式。">aggregate</a> 長什麼形狀」。讀側的設計是一道階梯、不是「要不要 <a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a>」的開關——多數專案的正確位置在階梯低處，升級由訊號驅動。本章給出階梯的四階、升級的五個訊號、以及一句可機械執行的自檢問句：</p>
<blockquote>
<p>這個查詢回傳的是<strong>讀的形狀</strong>、還是 <strong>aggregate 的形狀</strong>？</p></blockquote>
<p>回傳 aggregate 形狀（entity 或 entity 集合）的查詢屬於 repository；回傳讀的形狀（統計值、扁平列表、跨 aggregate 拼裝）的查詢是讀模型的候選。問句每次新增查詢方法時問一次，答案累積起來就是五訊號的量測值。</p>
<h2 id="階梯四階與每階的代價">階梯：四階與每階的代價</h2>
<table>
  <thead>
      <tr>
          <th>階</th>
          <th>形態</th>
          <th>讀的形狀在哪裡產生</th>
          <th>新增代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一</td>
          <td>repository 查詢方法（pull 或 push、回 aggregate 形狀）</td>
          <td>消費端（ViewModel／service）自行投影</td>
          <td>零：用既有介面</td>
      </tr>
      <tr>
          <td>二</td>
          <td>讀 port 抽離（獨立查詢介面、同一儲存）</td>
          <td>port 實作內</td>
          <td>一個介面 + 注入點</td>
      </tr>
      <tr>
          <td>三</td>
          <td>專用讀模型（獨立投影、可獨立快取）</td>
          <td>投影建構器內</td>
          <td>投影邏輯 + 失效策略</td>
      </tr>
      <tr>
          <td>四</td>
          <td>CQRS 全套（讀寫獨立儲存、事件同步）</td>
          <td>事件消費端</td>
          <td>同步管線 + 最終一致性</td>
      </tr>
  </tbody>
</table>
<p>每一階都比上一階多買一種能力、也多付一種持續成本。第一階的能力是簡單：所有讀需求共用一條資料通路，消費端各自把 aggregate 形狀折成自己要的樣子。第二階買到介面隔離：讀需求多到 repository 介面開始臃腫時，把查詢集合抽成獨立 port，寫側介面回到精簡。第三階買到形狀與效能的自由：讀的形狀在儲存或快取層物化，查詢不再每次從 aggregate 折算。第四階買到讀寫各自極致最佳化，代價是最終一致性進入系統語意——畫面可能短暫顯示舊值、而且這是設計內行為。</p>
<p>階梯的方向性很重要：<strong>每一階的介面簽名對消費端穩定</strong>。從第一階爬到第二階時，查詢方法從 repository 介面遷到讀 port、簽名不變、呼叫端只改注入來源。這讓「先停在低階」是安全決策而非技術債——升級路徑不會被低階選擇堵死。</p>
<h2 id="五訊號">五訊號</h2>
<p>以下五個訊號涵蓋技術與效能維度、各自獨立量測，每個訊號指向階梯上的一個目標階：訊號一指向第二階（介面隔離）、訊號二指向第三階（形狀物化）、訊號三與訊號四指向第三階（獨立快取與新鮮度分級）、訊號五指向第三到第四階（獨立演進延伸到讀寫分離）。命中越多、且指向的階越高，往上爬的理由越強。但爬幾階沒有可套的公式：本章案例只完整走過「零命中、停第一階」這一個決策，多訊號命中時的爬升幅度要按每個訊號背後的業務代價個案衡量，而不是把命中數當分數累加。技術訊號之外還有一類驅動——組織與團隊擁有權邊界——性質與這五個正交，單獨成段在五訊號之後。</p>
<h3 id="訊號一讀需求增生">訊號一：讀需求增生</h3>
<p>專用查詢方法累積到三條以上、且形狀彼此不同（一條回統計、一條回扁平列表、一條回分頁切片），repository 介面開始為讀需求膨脹。這是「第一階 → 第二階」的典型訊號：查詢集合已經大到值得一個自己的介面。三條是硬性起點：達到三條就把「介面裡讀方法與寫方法的比例」列入下次 review 的觀察項。讀方法數量超過寫方法的兩倍時，介面已經在為讀側服務——這是這個訊號的行動門檻。</p>
<h3 id="訊號二讀的形狀偏離-aggregate-形狀">訊號二：讀的形狀偏離 aggregate 形狀</h3>
<p>自檢問句的直接輸出。統計值（總數、分組計數）、跨 aggregate 的拼裝（書 + 借閱人 + 標籤樹的合成畫面）、為排序或搜尋而反正規化的扁平投影——這些形狀讓消費端的「自行投影」從幾行 <code>map</code> 長成一段業務邏輯。投影邏輯值得有自己的家（第三階），而不是散在每個 ViewModel 裡各寫一份。</p>
<h3 id="訊號三讀寫負載特性分歧">訊號三：讀寫負載特性分歧</h3>
<p>讀高頻寫低頻（商品目錄）或寫高頻讀低頻（事件記錄）到同一模型無法同時服務兩邊時，讀側需要自己的快取或儲存策略。分歧要用量測支撐——「感覺查詢很多」不是訊號，慢查詢記錄和快取命中率才是。這個訊號沒有通用閾值：「命中率低到多少該行動」由服務的 SLA 決定（電商結帳頁和內部報表的容忍度差一個數量級），量測工具對了就夠用、門檻要帶服務脈絡才有意義。</p>
<h3 id="訊號四讀側可接受的新鮮度不同">訊號四：讀側可接受的新鮮度不同</h3>
<p>報表可以慢十分鐘、交易畫面必須即時——同一份資料的不同讀者對「多舊算舊」的答案不同時，用一個模型服務所有人會被最嚴格的需求綁架。新鮮度分級是第三、四階才買得到的能力：投影可以有自己的更新節奏。</p>
<h3 id="訊號五讀模型需要獨立演進">訊號五：讀模型需要獨立演進</h3>
<p>不同消費者要不同版本的視圖（對外 API 的公開形狀 vs 內部畫面的完整形狀）、或讀形狀的變更頻率遠高於 aggregate 本身。讀側從此有自己的版本生命週期，跟 aggregate 綁在一起只會互相拖累。</p>
<h2 id="技術訊號之外團隊與交付邊界">技術訊號之外：團隊與交付邊界</h2>
<p>五個訊號涵蓋技術與效能維度。另一類常見且獨立的升級驅動是組織邊界：讀側與寫側由不同團隊擁有、需要獨立部署節奏與獨立資料契約。即使五個技術訊號全部零命中，團隊自治的壓力仍可能合理地把系統推上第三或第四階——讀側的模型、儲存、部署由另一個團隊全權管理，Conway&rsquo;s Law 讓組織結構成為架構的驅動力，跟負載分歧或形狀偏離這類技術維度完全正交。本章不展開組織驅動（涉及團隊拓撲與交付流程），只在此標記它的存在：把五訊號當成升級的全部理由，會漏掉組織維度的命中。</p>
<h2 id="階梯的適用範圍">階梯的適用範圍</h2>
<p>四階假設在單一 <a href="/blog/ddd/knowledge-cards/bounded-context/" data-link-title="Bounded Context" data-link-desc="同一套架構判準跨到另一個服務還站不站得住？bounded context 是模型與詞彙保持一致的邊界——邊界內的推導在邊界外不必然成立。">bounded context</a> 內操作。跨服務聯合讀模型（報表服務訂閱多個 domain 的事件建置共享視圖）引入的關注點——跨服務事件契約穩定性、schema 演進、跨信任邊界的最終一致性——不是「多買一種能力」可概括，超出本階梯的覆蓋範圍。</p>
<h2 id="案例停在第一階的決策">案例：停在第一階的決策</h2>
<p>書庫管理 App 為 repository 補了觀測出口 <code>watchBooks()</code>（背景與三層歸屬見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>）。落地時面對的正是本章的二擇：<code>watchBooks()</code> 放既有 repository 介面、還是抽一個獨立的讀 port？</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><code>watchBooks()</code> 回 <code>Stream&lt;List&lt;Book&gt;&gt;</code>——正是 aggregate 的形狀</td>
          <td>否</td>
      </tr>
      <tr>
          <td>負載分歧</td>
          <td>單人離線書庫、讀寫皆低頻</td>
          <td>否</td>
      </tr>
      <tr>
          <td>新鮮度分級</td>
          <td>所有衍生視圖都要即時</td>
          <td>否</td>
      </tr>
      <tr>
          <td>獨立演進</td>
          <td>消費者全是自家畫面、一個形狀通吃</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>五訊號零命中、自檢問句答「aggregate 的形狀」——結論是停在第一階：<code>watchBooks()</code> 進既有 repository 介面、與 <code>getAllBooks()</code> 形成 pull／push 對稱，各衍生視圖在 ViewModel 層各自投影（統計頁算總數、待補完列表過濾欄位缺漏）。抽讀 port 在此刻是為一個方法建一個介面、買不到任何一階的能力、只付碎片化的成本。</p>
<p>決策同時綁了升級 trigger：推送型讀需求增生（專用統計投影、分頁查詢流之類累積到三條）時再抽讀 port，屆時 <code>watchBooks()</code> 遷入讀 port、簽名不變、呼叫端只改注入來源。「停在低階」與「記錄何時升級」是同一個決策的兩半——少了後半、低階會在訊號早已命中之後仍靠慣性維持。</p>
<h2 id="判準防的兩種錯">判準防的兩種錯</h2>
<p>五訊號同時防兩個方向的失誤，兩邊在真實專案都常見：</p>
<p><strong>太早抽</strong>：還沒有第二條讀需求就先建讀 port、投影層、甚至讀寫分離骨架——每一層都是要餵養的抽象。訊號零命中時的高階結構，日常成本是每個新查詢都要穿過多一層介面、而買到的能力沒有消費者。</p>
<p><strong>太晚抽</strong>：repository 介面長滿 <code>getBooksGroupedByTag()</code>、<code>getMonthlyStatistics()</code>、<code>searchWithPagination()</code>——每條都「只是加一個方法」，直到介面的讀方法數量淹過寫方法、每個 mock 都要 stub 幾十個查詢。訊號早已命中、但沒有量測動作讓命中被看見。自檢問句的價值就在這裡：它把「要不要升級」從一次性的架構辯論、變成每次加方法時的例行量測。</p>
<h2 id="下一步">下一步</h2>
<p>停在第一階之後要落地觀測出口，歸屬判準見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。第四階需要事件作為同步載體——載體選用判準見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。太晚抽的日常代價（介面臃腫、mock 爆炸）有實證：<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/ddd/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="查詢該由 repository 回 aggregate、還是該有自己的查詢側模型時使用。read model 是為讀需求的形狀而建的模型——回答「畫面需要什麼形狀」、與 aggregate 的形狀分離。">Read Model</a>。</p>
]]></content:encoded></item><item><title>domain event 與狀態流</title><link>https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/</guid><description>&lt;p>「資料變了、通知我」有兩種語意不同的載體。&lt;strong>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a>&lt;/strong> 記錄離散事實：一件業務上發生過的事、以過去式命名、發布後不可變、消費者關心「發生了什麼」。**&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流&lt;/a>**發布連續觀測：某份資料的當前值、新值蓋過舊值、錯過中間值無妨、消費者關心「現在是什麼」。選錯載體的系統照樣能動——代價潛伏在演進裡：涵蓋面開始靠枚舉維持、事件語意被消費端綁架。本章的判準一句話：&lt;strong>看消費者問的問題&lt;/strong>。問「發生了什麼」給事件、問「現在是什麼」給狀態流。&lt;/p>
&lt;h2 id="兩種載體的語意對照">兩種載體的語意對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>domain event&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>過去式（BookAdded、OrderPaid）&lt;/td>
 &lt;td>現在式（books 現在是這些）&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>每個事件獨立、順序有意義&lt;/td>
 &lt;td>新值取代舊值、只有最新值有意義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>典型消費者&lt;/td>
 &lt;td>審計日誌、跨 domain 流程、通知&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;/tbody>
&lt;/table>
&lt;p>「錯過的代價」那一列是實務上最好用的分辨器。畫面剛好沒訂閱時漏掉一次書單更新，下一次更新全額補償——狀態流語意。稽核系統漏掉一筆「書籍已刪除」，那筆事實永遠消失——事件語意。同一個資料變更常常兩種消費者都有：刪一本書，審計要那個事實（event）、清單畫面要新的書單（狀態流）——這不是二選一，是兩條正交的出口。&lt;/p>
&lt;p>事件命名的過去式約定本身就是語意宣告（展開見 &lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">Domain Event 命名的過去式&lt;/a>）：過去式承諾「這是已發生的事實」。當事實開始被當成「請刷新」的訊號消費，這個承諾就被消費端改寫了——下面的案例走的正是這條路。&lt;/p>
&lt;h2 id="案例一條斜坡的三步">案例：一條斜坡的三步&lt;/h2>
&lt;p>一個書庫管理 App 的統計頁需要在資料變更後刷新。&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 是純 pull 介面、沒有狀態流出口；但專案有現成的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus&lt;/a>、也有數十個 domain event 發布點。「用既有的事件當刷新訊號」看起來是零成本的選擇——這是斜坡的入口。&lt;/p>
&lt;p>&lt;strong>第一步：橋接&lt;/strong>。用 &lt;code>StreamProvider&lt;/code> 包 EventBus、ViewModel 監聽事件流、任何事件進來就重新查詢統計。上線有效：匯入完成事件、同步事件都正確觸發刷新。&lt;/p>
&lt;p>&lt;strong>第二步：斷鏈暴露枚舉本質&lt;/strong>。搜尋加書與掃描加書路徑不刷新——盤點發現這兩條寫入路徑從未發布任何 domain event。事件的發布時機由業務語意決定（「匯入完成」值得記錄；「使用者加了一本書」當時沒有下游流程需要它），而刷新的涵蓋面要求「每條寫入路徑都有事件」。兩個需求的形狀本質不同：&lt;strong>事件的涵蓋面是業務事實的集合、狀態流的涵蓋面是寫入操作的集合&lt;/strong>。用前者服務後者，缺的部分只能靠枚舉補——為了刷新而補發事件。&lt;/p>
&lt;p>&lt;strong>第三步：語意綁架成形&lt;/strong>。補發事件的修法一旦開始，事件系統的演進被刷新需求綁住：新增寫入路徑的檢查清單多了「記得發事件、否則某頁不刷新」；想移除一個再無業務消費者的事件、要先確認沒有畫面靠它刷新；監聽端則因為「不知道哪些事件代表資料變了」而全事件監聽、任何無關事件都觸發一次重新查詢。每一步都合理、加總起來是兩個系統互相持有對方的隱含契約。&lt;/p>
&lt;p>這個專案在第三步前停下，把問題拉回載體選擇：統計頁的問題是「現在是什麼」（當前書庫統計）、不是「發生了什麼」。正確載體是狀態流——repository 補 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt;&lt;/code> 觀測出口、畫面 &lt;code>ref.watch&lt;/code> 訂閱，涵蓋面天然等於寫入操作的集合（每個寫入方法尾端 emit），沒有「記得發」這個動作。演進全記錄在 &lt;a href="https://tarrragon.github.io/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 圖、不是資料庫&lt;/a>、分層落地在 &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;/p>
&lt;h2 id="正交性加了狀態流事件一個不動">正交性：加了狀態流、事件一個不動&lt;/h2>
&lt;p>載體分清楚之後最有力的驗證是改動面：狀態流落地時，既有事件發布點&lt;strong>零改動&lt;/strong>。事件仍然記錄業務事實、服務審計與跨 domain 通知；狀態流負責「資料變了」的觀測。兩者正交、互不取代：&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>domain event&lt;/td>
 &lt;td>發生了什麼&lt;/td>
 &lt;td>審計、跨 domain 流程&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;/tbody>
&lt;/table>
&lt;p>「結構性涵蓋」與「逐事實設計」的差異就是兩種載體不可互換的原因。狀態流的 emit 掛在寫入方法上、新路徑必然經過、涵蓋不靠任何人記得；事件的發布點是設計決策、每個事件都該有業務理由——被迫「為涵蓋而發」的事件是沒有事實語意的雜訊，反過來稀釋整個事件系統的可信度（事件多到沒人知道哪些重要時，跨 domain 通訊的成本問題見 &lt;a href="https://tarrragon.github.io/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&lt;/a>——同一族的載體誤用、方向相反：那篇是拿事件做請求回應、本篇是拿事件做狀態通知）。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;p>斜坡的每一步都有可觀察的訊號，出現任何一個就回到載體判準重新選：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>出現「為了讓某頁刷新、在某路徑補發事件」的工作項&lt;/td>
 &lt;td>消費者要的是「現在是什麼」、載體卻是事件——該補的是狀態流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>監聽端掛全事件監聽、再考慮加型別過濾白名單&lt;/td>
 &lt;td>消費者不關心事實內容、只把事件當「有東西變了」的鈴聲&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>移除事件前要先查「有沒有畫面靠它刷新」&lt;/td>
 &lt;td>事件語意已被消費端綁架、事實系統失去獨立演進能力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件 payload 開始塞「當前完整狀態」&lt;/td>
 &lt;td>事件被迫模擬快照——狀態流語意穿著事件的衣服&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第四個訊號值得單獨說明：事實的 payload 是「這件事的內容」（哪本書、什麼時間），快照的 payload 是「現在的全貌」。事件開始攜帶全量狀態、通常是因為消費者拿到事件後只想要最新值——那正是狀態流一次 emit 就給完的東西。這個訊號有適用邊界：它假設消費者與事件來源在同一進程、同一信任邊界內。跨服務情境下，刻意讓事件攜帶足量當前狀態是 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer&lt;/a> 這個正當設計——目的是讓下游服務不必回頭查詢來源、避免同步耦合，那時「payload 帶全量狀態」是設計選擇、不是載體錯位。本案是單一 App、同進程，沒有跨服務查詢成本，才適用「該補的是狀態流」的判讀。&lt;/p>
&lt;p>四個訊號量的不是同一種東西。訊號一、二、四直接檢驗「消費者現在問的是哪種問題」；訊號三（移除事件前要先查有沒有畫面靠它刷新）量的是選錯載體之後累積的耦合成本——它是後果訊號（lagging indicator），等綁架已經長出來才觀察得到。把它跟前三個並列，是因為它同樣觸發「回到載體判準」，但它遲到、指向的是已經發生的綁架而非正在發生的誤用。&lt;/p></description><content:encoded><![CDATA[<p>「資料變了、通知我」有兩種語意不同的載體。<strong><a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a></strong> 記錄離散事實：一件業務上發生過的事、以過去式命名、發布後不可變、消費者關心「發生了什麼」。**<a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流</a>**發布連續觀測：某份資料的當前值、新值蓋過舊值、錯過中間值無妨、消費者關心「現在是什麼」。選錯載體的系統照樣能動——代價潛伏在演進裡：涵蓋面開始靠枚舉維持、事件語意被消費端綁架。本章的判準一句話：<strong>看消費者問的問題</strong>。問「發生了什麼」給事件、問「現在是什麼」給狀態流。</p>
<h2 id="兩種載體的語意對照">兩種載體的語意對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>domain event</th>
          <th>狀態流</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>語意單位</td>
          <td>一件發生過的業務事實</td>
          <td>一份資料的當前快照</td>
      </tr>
      <tr>
          <td>時態</td>
          <td>過去式（BookAdded、OrderPaid）</td>
          <td>現在式（books 現在是這些）</td>
      </tr>
      <tr>
          <td>錯過的代價</td>
          <td>事實遺失——審計斷檔、下游流程沒觸發</td>
          <td>零——下一次快照涵蓋一切</td>
      </tr>
      <tr>
          <td>值的關係</td>
          <td>每個事件獨立、順序有意義</td>
          <td>新值取代舊值、只有最新值有意義</td>
      </tr>
      <tr>
          <td>典型消費者</td>
          <td>審計日誌、跨 domain 流程、通知</td>
          <td>畫面、快取、衍生視圖</td>
      </tr>
      <tr>
          <td>消費者的問題</td>
          <td>發生了什麼</td>
          <td>現在是什麼</td>
      </tr>
  </tbody>
</table>
<p>「錯過的代價」那一列是實務上最好用的分辨器。畫面剛好沒訂閱時漏掉一次書單更新，下一次更新全額補償——狀態流語意。稽核系統漏掉一筆「書籍已刪除」，那筆事實永遠消失——事件語意。同一個資料變更常常兩種消費者都有：刪一本書，審計要那個事實（event）、清單畫面要新的書單（狀態流）——這不是二選一，是兩條正交的出口。</p>
<p>事件命名的過去式約定本身就是語意宣告（展開見 <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>）：過去式承諾「這是已發生的事實」。當事實開始被當成「請刷新」的訊號消費，這個承諾就被消費端改寫了——下面的案例走的正是這條路。</p>
<h2 id="案例一條斜坡的三步">案例：一條斜坡的三步</h2>
<p>一個書庫管理 App 的統計頁需要在資料變更後刷新。<a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 是純 pull 介面、沒有狀態流出口；但專案有現成的 <a href="/blog/ddd/knowledge-cards/event-bus/" data-link-title="EventBus" data-link-desc="行程內要把「發生了一件事」廣播給多個訂閱者、或懷疑它被拿去兼職變更通知管道時回來讀。EventBus 是行程內的發布／訂閱事件匯流排——把事件的發布點與訂閱點解耦。">EventBus</a>、也有數十個 domain event 發布點。「用既有的事件當刷新訊號」看起來是零成本的選擇——這是斜坡的入口。</p>
<p><strong>第一步：橋接</strong>。用 <code>StreamProvider</code> 包 EventBus、ViewModel 監聽事件流、任何事件進來就重新查詢統計。上線有效：匯入完成事件、同步事件都正確觸發刷新。</p>
<p><strong>第二步：斷鏈暴露枚舉本質</strong>。搜尋加書與掃描加書路徑不刷新——盤點發現這兩條寫入路徑從未發布任何 domain event。事件的發布時機由業務語意決定（「匯入完成」值得記錄；「使用者加了一本書」當時沒有下游流程需要它），而刷新的涵蓋面要求「每條寫入路徑都有事件」。兩個需求的形狀本質不同：<strong>事件的涵蓋面是業務事實的集合、狀態流的涵蓋面是寫入操作的集合</strong>。用前者服務後者，缺的部分只能靠枚舉補——為了刷新而補發事件。</p>
<p><strong>第三步：語意綁架成形</strong>。補發事件的修法一旦開始，事件系統的演進被刷新需求綁住：新增寫入路徑的檢查清單多了「記得發事件、否則某頁不刷新」；想移除一個再無業務消費者的事件、要先確認沒有畫面靠它刷新；監聽端則因為「不知道哪些事件代表資料變了」而全事件監聽、任何無關事件都觸發一次重新查詢。每一步都合理、加總起來是兩個系統互相持有對方的隱含契約。</p>
<p>這個專案在第三步前停下，把問題拉回載體選擇：統計頁的問題是「現在是什麼」（當前書庫統計）、不是「發生了什麼」。正確載體是狀態流——repository 補 <code>Stream&lt;List&lt;Book&gt;&gt;</code> 觀測出口、畫面 <code>ref.watch</code> 訂閱，涵蓋面天然等於寫入操作的集合（每個寫入方法尾端 emit），沒有「記得發」這個動作。演進全記錄在 <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/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。</p>
<h2 id="正交性加了狀態流事件一個不動">正交性：加了狀態流、事件一個不動</h2>
<p>載體分清楚之後最有力的驗證是改動面：狀態流落地時，既有事件發布點<strong>零改動</strong>。事件仍然記錄業務事實、服務審計與跨 domain 通知；狀態流負責「資料變了」的觀測。兩者正交、互不取代：</p>
<table>
  <thead>
      <tr>
          <th>出口</th>
          <th>回答</th>
          <th>消費者</th>
          <th>涵蓋面的維持方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain event</td>
          <td>發生了什麼</td>
          <td>審計、跨 domain 流程</td>
          <td>業務語意決定發布點、逐事實設計</td>
      </tr>
      <tr>
          <td>狀態流</td>
          <td>現在是什麼</td>
          <td>畫面、衍生視圖、快取</td>
          <td>寫入操作集合、結構性涵蓋</td>
      </tr>
  </tbody>
</table>
<p>「結構性涵蓋」與「逐事實設計」的差異就是兩種載體不可互換的原因。狀態流的 emit 掛在寫入方法上、新路徑必然經過、涵蓋不靠任何人記得；事件的發布點是設計決策、每個事件都該有業務理由——被迫「為涵蓋而發」的事件是沒有事實語意的雜訊，反過來稀釋整個事件系統的可信度（事件多到沒人知道哪些重要時，跨 domain 通訊的成本問題見 <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>——同一族的載體誤用、方向相反：那篇是拿事件做請求回應、本篇是拿事件做狀態通知）。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>斜坡的每一步都有可觀察的訊號，出現任何一個就回到載體判準重新選：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>出現「為了讓某頁刷新、在某路徑補發事件」的工作項</td>
          <td>消費者要的是「現在是什麼」、載體卻是事件——該補的是狀態流</td>
      </tr>
      <tr>
          <td>監聽端掛全事件監聽、再考慮加型別過濾白名單</td>
          <td>消費者不關心事實內容、只把事件當「有東西變了」的鈴聲</td>
      </tr>
      <tr>
          <td>移除事件前要先查「有沒有畫面靠它刷新」</td>
          <td>事件語意已被消費端綁架、事實系統失去獨立演進能力</td>
      </tr>
      <tr>
          <td>事件 payload 開始塞「當前完整狀態」</td>
          <td>事件被迫模擬快照——狀態流語意穿著事件的衣服</td>
      </tr>
  </tbody>
</table>
<p>第四個訊號值得單獨說明：事實的 payload 是「這件事的內容」（哪本書、什麼時間），快照的 payload 是「現在的全貌」。事件開始攜帶全量狀態、通常是因為消費者拿到事件後只想要最新值——那正是狀態流一次 emit 就給完的東西。這個訊號有適用邊界：它假設消費者與事件來源在同一進程、同一信任邊界內。跨服務情境下，刻意讓事件攜帶足量當前狀態是 <a href="/blog/ddd/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer</a> 這個正當設計——目的是讓下游服務不必回頭查詢來源、避免同步耦合，那時「payload 帶全量狀態」是設計選擇、不是載體錯位。本案是單一 App、同進程，沒有跨服務查詢成本，才適用「該補的是狀態流」的判讀。</p>
<p>四個訊號量的不是同一種東西。訊號一、二、四直接檢驗「消費者現在問的是哪種問題」；訊號三（移除事件前要先查有沒有畫面靠它刷新）量的是選錯載體之後累積的耦合成本——它是後果訊號（lagging indicator），等綁架已經長出來才觀察得到。把它跟前三個並列，是因為它同樣觸發「回到載體判準」，但它遲到、指向的是已經發生的綁架而非正在發生的誤用。</p>
<p>以上訊號都指向同一個方向：把事件當成狀態流用。反方向的誤用同樣存在、訊號相反——消費者需要的是逐筆離散事實（每一次庫存調整、每一筆退款），系統卻只給得到最新快照。症狀是稽核回頭找「中間發生過什麼」時只有結果沒有過程、或下游流程因為錯過中間狀態而少觸發。此時該補的是 event、而不是把狀態流的快照硬拆成假事件。兩個方向共用同一條判準：問「發生了什麼」給事件、問「現在是什麼」給狀態流——判準對稱，誤用可以往兩邊倒。</p>
<h2 id="邊界">邊界</h2>
<p>本章的判準處理「資料變更通知」這一種需求的載體選擇。事件對另外兩種離散訊息（命令、查詢）的責任結構分界在 <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>；狀態流的實作選型（broadcast、初始值、生命週期）在 <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>。另外，<a href="/blog/ddd/knowledge-cards/cqrs/" data-link-title="CQRS" data-link-desc="有人提議「上 CQRS」、或想知道讀寫分離該做到多徹底時使用。CQRS 是把讀操作與寫操作的模型拆開的架構決定——寫側守一致性、讀側服務查詢形狀，兩者可以各自有獨立的儲存與更新節奏。">CQRS</a> 階梯頂端「以事件同步讀模型」是事件的合法消費——那裡的消費者問的確實是「發生了什麼」（用事實重建投影），與把事件當刷新鈴聲不同層；階梯全貌見 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。這也標出本章「兩者正交」論述的架構前提：狀態存在獨立儲存、事件只是旁支通知。event-sourced 架構把這個前提拿掉——狀態流成為事件的投影、由同一條事件流重建，兩條管線不再獨立，正交性讓位給「讀模型由事件同步」的第四階語意。</p>
<h2 id="下一步">下一步</h2>
<p>決定用狀態流之後，先讀 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a> 搞清楚契約、機制、組裝分別放哪一層，再進 <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> 看案例從導航補償到狀態流的完整演進。事件命名的過去式約定（為什麼叫 <code>BookAdded</code> 而不是 <code>AddBook</code>）展開在 <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/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a>。</p>
]]></content:encoded></item><item><title>domain event 與命令、查詢的分界</title><link>https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/domain-event-vs-command-and-query/</guid><description>&lt;p>跨模組的訊息有三種責任形狀。&lt;strong>命令&lt;/strong>（command）表達意圖：「去做這件事」，有唯一的處理者、可以拒絕、可以失敗。&lt;strong>&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event&lt;/a>&lt;/strong> 記錄事實：「這件事已經發生」，有任意多的訂閱者、沒有拒絕的位置。&lt;strong>查詢&lt;/strong>（query）是對話：「告訴我這個」，一問一答、呼叫端要等到答案。三者的分界是責任結構、不是命名風格。本章的判準一句話：&lt;strong>看訊息對消費者的要求&lt;/strong>——要求執行、給命令；只供反應、給事件；要等答案、給查詢通道。&lt;/p>
&lt;h2 id="三種訊息的責任結構">三種訊息的責任結構&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>命令&lt;/th>
 &lt;th>domain event&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;code>ImportBook&lt;/code>）&lt;/td>
 &lt;td>過去式（&lt;code>BookImported&lt;/code>）&lt;/td>
 &lt;td>疑問（&lt;code>getBookInfo()&lt;/code>）&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>無——事實不可否認&lt;/td>
 &lt;td>有超時、有錯誤回傳&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發送端等不等&lt;/td>
 &lt;td>視流程——常等處理結果&lt;/td>
 &lt;td>不等、fire and forget&lt;/td>
 &lt;td>必等——答案是流程的前提&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「失敗語意」那一列是三者最深的差異。命令的處理者可以說不：庫存不足、狀態不允許、驗證失敗——失敗是命令語意的一部分、發送端要準備收到拒絕。事件沒有這個位置：&lt;code>BookImported&lt;/code> 發出時匯入已經完成，訂閱者遲到、出錯、根本不存在，都改變不了這個事實——訂閱者的失敗是訂閱者自己的問題、不回流到發布端。查詢的失敗又是另一種：查不到、等太久、通道斷了，每一種都要有回應路徑、因為呼叫端停在那裡等。&lt;/p>
&lt;p>責任結構決定演進自由度：事件的發布端不知道誰在聽、加訂閱者不改發布端；命令的發送端跟處理者是一對一契約、換處理者要重新對齊語意；查詢的兩端綁最緊、簽名一動兩邊都動。&lt;/p>
&lt;h2 id="命名是責任結構的第一道宣告">命名是責任結構的第一道宣告&lt;/h2>
&lt;p>事件用過去式命名、是把責任結構烙在讀者第一眼接觸的地方：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">BookImported&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&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="kd">class&lt;/span> &lt;span class="nc">ImportBook&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">DomainEvent&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">命令的形狀——「去匯入這本書」&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ImportBook&lt;/code> 這個名字的問題超出風格：動詞開頭是命令的形狀，訂閱者會用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式的 &lt;code>Imported&lt;/code> 宣告木已成舟，訂閱者知道自己只能對事實做反應。一個書籍管理 App 的命名規範修正記錄了這條分界的完整推導、以及過去式字尾自動檢查的脆弱性（不規則動詞全數漏接——偵測可以機械化、判定要看語意）：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式是語意類別、不是風格&lt;/a>。&lt;/p>
&lt;h2 id="查詢裝進事件通道手工重建-rpc">查詢裝進事件通道：手工重建 RPC&lt;/h2>
&lt;p>第二種錯位的方向相反：把對話裝進通知通道。同一個 App 的借閱功能要在建立借閱記錄前查書籍資訊，直接呼叫另一個 domain 的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository&lt;/a> 違反依賴方向；修正方案選了事件驅動的 request/response——&lt;code>BookInfoRequested&lt;/code> 發出、&lt;code>BookInfoProvided&lt;/code> 回應。耦合確實解掉了，但設計規格裡跟著出現的每一項機制、都是同步呼叫免費附贈的東西：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id&lt;/a> 配對請求回應&lt;/td>
 &lt;td>呼叫堆疊天然對應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>waitFor + timeout&lt;/td>
 &lt;td>函式 return&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「查無資料」的錯誤事件&lt;/td>
 &lt;td>回傳 null／拋例外&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>超時與通道故障路徑&lt;/td>
 &lt;td>不存在——同步呼叫沒有「沒人接」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的形狀有個名字：用事件通道手工重建 RPC。事件的天然語意是通知——fire and forget、任意多訂閱者、無所謂回應；一問一答的對話硬放上通知通道，RPC 的基礎設施就得自己蓋一遍、而且每個跨 domain 查詢點都要再蓋一遍。名字先洩漏了錯位：&lt;code>BookInfoRequested&lt;/code> 的「Requested」記錄的是「有人想要」這個請求、不是業務世界發生的事實——事件命名的過去式測試在這裡當了偵測器，兩條判準在名字上交會。&lt;/p>
&lt;p>事件真正買到、而更便宜的工具給不了的能力有兩樣：&lt;strong>時間解耦&lt;/strong>（發布者不等回應、雙方可以不同時在線）與&lt;strong>基數解耦&lt;/strong>（一個事件任意多訂閱者）。這個案例的查詢是同進程、一對一、呼叫端必須等到答案——三個特徵全部用不到事件的能力。解依賴方向有便宜一個量級的工具：消費端宣告自己需要的 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port&lt;/a>、對方提供實作、組裝層接線，依賴倒置同樣成立、呼叫還是一次普通的 await。完整比價（含 Port 版的介面設計）見 &lt;a href="https://tarrragon.github.io/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&lt;/a>。&lt;/p>
&lt;p>判準收斂成兩行：&lt;/p>
&lt;ul>
&lt;li>要解的是&lt;strong>依賴方向&lt;/strong>（同進程、一對一、要等答案）→ 消費端 port、付一張介面的價&lt;/li>
&lt;li>要解的是&lt;strong>時間、部署或基數&lt;/strong>（跨進程、離線佇列、多方反應）→ 事件、correlation 與 timeout 是這個量級的合理成本&lt;/li>
&lt;/ul>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>事件類別以動詞開頭（&lt;code>ImportBook&lt;/code>）&lt;/td>
 &lt;td>命令穿著事件的衣服——訂閱者會以為自己能影響流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件成對出現 &lt;code>XxxRequested&lt;/code>／&lt;code>XxxProvided&lt;/code> + correlation id&lt;/td>
 &lt;td>對話穿著事件的衣服——在通知通道上手工重建 RPC&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發布事件後 &lt;code>waitFor&lt;/code>／await 回應才能往下走&lt;/td>
 &lt;td>呼叫端語意是同步查詢、事件只是繞路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>訂閱者不只一個、或處理可以離線延後&lt;/td>
 &lt;td>反向確認：這時事件才是本命、別退回 port&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前三個訊號的修法方向一致：回到責任結構重新分類——是意圖就改命令通道（或直接呼叫 use case）、是對話就改查詢（消費端 port）、確認是事實才留在事件。第四個訊號防反向矯枉：把所有跨 domain 通訊都收回同步呼叫、會把真正需要時間與基數解耦的場景也一起收掉。&lt;/p>
&lt;h2 id="邊界">邊界&lt;/h2>
&lt;p>本章處理事件與命令、查詢的訊息類型分界——三者都是離散訊息、差在責任結構。事件與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流&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/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer&lt;/a> 是分散式情境的正當設計、其適用邊界也在該章。&lt;/p>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;p>事件的另一條分界（對狀態流）在 &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>——兩章合起來把 domain event 對三個鄰居（命令、查詢、狀態流）的邊界畫完。兩個 case 的完整記錄：&lt;a href="https://tarrragon.github.io/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式&lt;/a>、&lt;a href="https://tarrragon.github.io/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&lt;/a>；port 解依賴的實戰在 &lt;a href="https://tarrragon.github.io/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 個&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>跨模組的訊息有三種責任形狀。<strong>命令</strong>（command）表達意圖：「去做這件事」，有唯一的處理者、可以拒絕、可以失敗。<strong><a href="/blog/ddd/knowledge-cards/domain-event/" data-link-title="Domain Event" data-link-desc="系統裡出現「為了讓某頁刷新而補發事件」或「監聽端掛全事件過濾器」時使用。domain event 是已發生的業務事實——過去式命名、發布後不可變、錯過代表事實遺失。">domain event</a></strong> 記錄事實：「這件事已經發生」，有任意多的訂閱者、沒有拒絕的位置。<strong>查詢</strong>（query）是對話：「告訴我這個」，一問一答、呼叫端要等到答案。三者的分界是責任結構、不是命名風格。本章的判準一句話：<strong>看訊息對消費者的要求</strong>——要求執行、給命令；只供反應、給事件；要等答案、給查詢通道。</p>
<h2 id="三種訊息的責任結構">三種訊息的責任結構</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>命令</th>
          <th>domain event</th>
          <th>查詢</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>語意</td>
          <td>去做這件事</td>
          <td>這件事已發生</td>
          <td>告訴我這個</td>
      </tr>
      <tr>
          <td>命名形狀</td>
          <td>祈使（<code>ImportBook</code>）</td>
          <td>過去式（<code>BookImported</code>）</td>
          <td>疑問（<code>getBookInfo()</code>）</td>
      </tr>
      <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>不等、fire and forget</td>
          <td>必等——答案是流程的前提</td>
      </tr>
  </tbody>
</table>
<p>「失敗語意」那一列是三者最深的差異。命令的處理者可以說不：庫存不足、狀態不允許、驗證失敗——失敗是命令語意的一部分、發送端要準備收到拒絕。事件沒有這個位置：<code>BookImported</code> 發出時匯入已經完成，訂閱者遲到、出錯、根本不存在，都改變不了這個事實——訂閱者的失敗是訂閱者自己的問題、不回流到發布端。查詢的失敗又是另一種：查不到、等太久、通道斷了，每一種都要有回應路徑、因為呼叫端停在那裡等。</p>
<p>責任結構決定演進自由度：事件的發布端不知道誰在聽、加訂閱者不改發布端；命令的發送端跟處理者是一對一契約、換處理者要重新對齊語意；查詢的兩端綁最緊、簽名一動兩邊都動。</p>
<h2 id="命名是責任結構的第一道宣告">命名是責任結構的第一道宣告</h2>
<p>事件用過去式命名、是把責任結構烙在讀者第一眼接觸的地方：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">class</span> <span class="nc">BookImported</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</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="kd">class</span> <span class="nc">ImportBook</span> <span class="kd">extends</span> <span class="n">DomainEvent</span> <span class="p">{</span> <span class="p">}</span>     <span class="o">//</span> <span class="err">命令的形狀——「去匯入這本書」</span></span></span></code></pre></div><p><code>ImportBook</code> 這個名字的問題超出風格：動詞開頭是命令的形狀，訂閱者會用命令的心智模型寫處理邏輯——以為自己能影響流程、以為失敗會被重試。過去式的 <code>Imported</code> 宣告木已成舟，訂閱者知道自己只能對事實做反應。一個書籍管理 App 的命名規範修正記錄了這條分界的完整推導、以及過去式字尾自動檢查的脆弱性（不規則動詞全數漏接——偵測可以機械化、判定要看語意）：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式是語意類別、不是風格</a>。</p>
<h2 id="查詢裝進事件通道手工重建-rpc">查詢裝進事件通道：手工重建 RPC</h2>
<p>第二種錯位的方向相反：把對話裝進通知通道。同一個 App 的借閱功能要在建立借閱記錄前查書籍資訊，直接呼叫另一個 domain 的 <a href="/blog/ddd/knowledge-cards/repository/" data-link-title="Repository" data-link-desc="查詢方法該留在 repository、還是該抽成獨立讀模型時使用。repository 是 aggregate 的存取抽象——回傳的形狀是 aggregate 的形狀，不是讀的形狀。">repository</a> 違反依賴方向；修正方案選了事件驅動的 request/response——<code>BookInfoRequested</code> 發出、<code>BookInfoProvided</code> 回應。耦合確實解掉了，但設計規格裡跟著出現的每一項機制、都是同步呼叫免費附贈的東西：</p>
<table>
  <thead>
      <tr>
          <th>事件版要自己蓋的</th>
          <th>同步呼叫的對應物</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id</a> 配對請求回應</td>
          <td>呼叫堆疊天然對應</td>
      </tr>
      <tr>
          <td>waitFor + timeout</td>
          <td>函式 return</td>
      </tr>
      <tr>
          <td>「查無資料」的錯誤事件</td>
          <td>回傳 null／拋例外</td>
      </tr>
      <tr>
          <td>超時與通道故障路徑</td>
          <td>不存在——同步呼叫沒有「沒人接」</td>
      </tr>
  </tbody>
</table>
<p>這張表的形狀有個名字：用事件通道手工重建 RPC。事件的天然語意是通知——fire and forget、任意多訂閱者、無所謂回應；一問一答的對話硬放上通知通道，RPC 的基礎設施就得自己蓋一遍、而且每個跨 domain 查詢點都要再蓋一遍。名字先洩漏了錯位：<code>BookInfoRequested</code> 的「Requested」記錄的是「有人想要」這個請求、不是業務世界發生的事實——事件命名的過去式測試在這裡當了偵測器，兩條判準在名字上交會。</p>
<p>事件真正買到、而更便宜的工具給不了的能力有兩樣：<strong>時間解耦</strong>（發布者不等回應、雙方可以不同時在線）與<strong>基數解耦</strong>（一個事件任意多訂閱者）。這個案例的查詢是同進程、一對一、呼叫端必須等到答案——三個特徵全部用不到事件的能力。解依賴方向有便宜一個量級的工具：消費端宣告自己需要的 <a href="/blog/ddd/knowledge-cards/port/" data-link-title="Port" data-link-desc="判斷介面該宣告在哪一層、依賴方向該朝哪時使用。port 是 domain 對外宣告的介面——需求用領域語言說完、技術細節留在實作端。">port</a>、對方提供實作、組裝層接線，依賴倒置同樣成立、呼叫還是一次普通的 await。完整比價（含 Port 版的介面設計）見 <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>。</p>
<p>判準收斂成兩行：</p>
<ul>
<li>要解的是<strong>依賴方向</strong>（同進程、一對一、要等答案）→ 消費端 port、付一張介面的價</li>
<li>要解的是<strong>時間、部署或基數</strong>（跨進程、離線佇列、多方反應）→ 事件、correlation 與 timeout 是這個量級的合理成本</li>
</ul>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事件類別以動詞開頭（<code>ImportBook</code>）</td>
          <td>命令穿著事件的衣服——訂閱者會以為自己能影響流程</td>
      </tr>
      <tr>
          <td>事件成對出現 <code>XxxRequested</code>／<code>XxxProvided</code> + correlation id</td>
          <td>對話穿著事件的衣服——在通知通道上手工重建 RPC</td>
      </tr>
      <tr>
          <td>發布事件後 <code>waitFor</code>／await 回應才能往下走</td>
          <td>呼叫端語意是同步查詢、事件只是繞路</td>
      </tr>
      <tr>
          <td>訂閱者不只一個、或處理可以離線延後</td>
          <td>反向確認：這時事件才是本命、別退回 port</td>
      </tr>
  </tbody>
</table>
<p>前三個訊號的修法方向一致：回到責任結構重新分類——是意圖就改命令通道（或直接呼叫 use case）、是對話就改查詢（消費端 port）、確認是事實才留在事件。第四個訊號防反向矯枉：把所有跨 domain 通訊都收回同步呼叫、會把真正需要時間與基數解耦的場景也一起收掉。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理事件與命令、查詢的訊息類型分界——三者都是離散訊息、差在責任結構。事件與 <a href="/blog/ddd/knowledge-cards/state-stream/" data-link-title="State Stream（狀態流）" data-link-desc="畫面刷新靠補償、或考慮拿既有事件當刷新訊號時使用。狀態流是持續發布「資料當前值」的觀測載體——新值蓋過舊值、錯過中間值無代價，回答「現在是什麼」。">狀態流</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/knowledge-cards/event-carried-state-transfer/" data-link-title="Event-Carried State Transfer" data-link-desc="跨服務的 domain event payload 開始塞進足量當前狀態、要判斷這是正當設計還是載體誤用時使用。event-carried state transfer 是刻意讓事件攜帶足量狀態、讓下游服務不必回頭查詢來源的設計。">event-carried state transfer</a> 是分散式情境的正當設計、其適用邊界也在該章。</p>
<h2 id="下一步">下一步</h2>
<p>事件的另一條分界（對狀態流）在 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>——兩章合起來把 domain event 對三個鄰居（命令、查詢、狀態流）的邊界畫完。兩個 case 的完整記錄：<a href="/blog/work-log/domain_event_naming_past_tense/" data-link-title="BookImported 不能寫成 ImportBook — 事件命名的過去式是語意類別、不是風格" data-link-desc="domain event 用過去式命名（BookImported）因為事件是已發生的事實；動詞開頭（ImportBook）是命令的形狀、訂閱者會誤讀成指令。字尾清單的自動檢查抓不住不規則動詞——偵測可機械化、判定要看語意。附一次版本終止：工作日誌宣稱的命名問題實際早已不存在、執行前驗前提省下整輪流程。">事件命名的過去式</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>；port 解依賴的實戰在 <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>。</p>
]]></content:encoded></item><item><title>跨邊界參照與狀態所有權</title><link>https://tarrragon.github.io/blog/ddd/cross-boundary-reference-ownership/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/cross-boundary-reference-ownership/</guid><description>&lt;p>領域模型跨越邊界持有的每一個 id，有效性由上游行為決定、持有端控制不了。上游把「更新」實作成「刪除重建」是實作自由，下游用推理猜「它應該保留」擋不住——合法的出處只有契約文件或一次實測取證。這條事實推導出本章三個判準的共同源頭：跨邊界參照的設計，從「對方會做什麼」開始、而不是從「我要存什麼」開始。&lt;/p>
&lt;p>本章承接 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a> 的身份語意討論，延伸到身份跨越系統邊界之後的生存問題；與 &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a> 的凍結端點段落互補——凍結是時間軸上的問題（何時），本章談的是空間軸上的問題（誰的 id、誰決定它活著）。&lt;/p>
&lt;h2 id="參照有效性三問">參照有效性三問&lt;/h2>
&lt;p>下游持有上游資料的 id 時，設計階段逐一回答：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>對方的哪些操作會讓這個 id 死亡？&lt;/strong> 合併、拆分、覆蓋式更新、重建式修復——任何「刪舊建新」的實作都會讓舊 id 失效。盲區集中在業務動詞與實作的語意落差：同一個「合併」，有的實作是搬資料、有的實作是建新刪舊。持有端用前者的假設設計、遇到後者的實作時靜默失效。&lt;/li>
&lt;li>&lt;strong>有沒有一個跨越這些操作仍不變的身份？&lt;/strong> 如果存在——以它為錨、其餘參照動態解析。如果沒有——凍結參照只能當一次性用途、跨操作的功能改走查詢。&lt;/li>
&lt;li>&lt;strong>「不變」的出處是什麼？&lt;/strong> 文件說的、程式碼推理的、實測證實的——只有第三種算數。把實測結果固化為測試資產（&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>），從此不依賴任何人的記憶。&lt;/li>
&lt;/ol>
&lt;p>三個問題的回答決定參照的設計形態。未回答就動工的代價是靜默失效——操作對著死 id 回寫，回傳值看起來正常（目標不存在也沒有噪音），功能在特定操作之後全部失效、直到使用者回報。凍結參照在測試中的結構盲區另見 &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>：由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 死亡」這個狀態在測試裡不可能出現。&lt;/p>
&lt;h2 id="穩定身份為錨解引用延後到使用時">穩定身份為錨、解引用延後到使用時&lt;/h2>
&lt;p>第二題的答案若為「存在」，設計形態收斂為：持有穩定身份、其餘參照在使用時動態解析。&lt;/p>
&lt;p>「reference by identity + 解引用時機延後」把「id → 實體」的解析從持有時延後到使用時，讓解析結果反映上游當前的真相。額外的韌性：即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找到新家。&lt;/p>
&lt;p>查無結果時退回凍結值，搭配存在性檢查兜底——穩定身份的值域（如 uuid）讓失效的凍結值只會被擋下、不會誤中別的資料。退路的成本是一次額外查詢；退路被命中的頻率反映的是同步的及時性——頻率高代表催同步的時機需要調整（見下節）。&lt;/p>
&lt;p>第二題的答案若為「不存在」，凍結參照是唯一手段，但它的有效期等於上游不做重建操作的期間。有效期內凍結合法；跨越有效期的功能需要改走查詢路徑——以業務鍵（訂單號、合約號這類人為穩定的識別碼）重新定位，而非依賴上游的技術 id。&lt;/p>
&lt;h2 id="自持狀態與可導出狀態遷移分工">自持狀態與可導出狀態：遷移分工&lt;/h2>
&lt;p>上游身份轉移（合併、拆分）發生後，下游持有的多份「以上游 id 為 key」的狀態面臨遷移問題。遷移判準一個問題就夠：&lt;strong>這份狀態消失後，能否單靠向上游重新查詢完整重建？&lt;/strong>&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;/tbody>
&lt;/table>
&lt;p>兩類狀態的程式碼形狀差異清楚：自持狀態有一個「身份轉移通知」的入口方法，且它是所有身份轉移操作的必經站；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。&lt;/p>
&lt;p>可導出狀態「等下一輪同步」的延遲若不可接受，正確的加速做法是&lt;strong>催一次既有同步&lt;/strong>——走同一條同步路徑、只是提早跑。催同步與手工搬移的差異在於：催同步後資料來源仍然只有上游這一份，手工搬移會製造第二份——兩份資料的分岔只是時間問題。催同步時注意同步路徑內部的順序依賴（路徑內的過濾機制可能依賴某份前置資料的新鮮度），順序錯了催出來的是空結果。&lt;/p>
&lt;p>自持狀態的每一份都是一筆負債，債主是所有會改變上游身份的操作。盤點自持狀態清單、逐項問「上游為什麼不保存這個」——若上游未來補上這份資料，自持狀態就能降級為投影，遷移程式碼隨之刪除。這個分類判準與 &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;/p>
&lt;h2 id="凍結快照的正當用途">凍結快照的正當用途&lt;/h2>
&lt;p>凍結參照應消滅、活解析應取代——這條原則有一個明確的邊界：呈現歷史事實的快照是凍結的正當用途。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>用途&lt;/th>
 &lt;th>正確形態&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>呈現歷史事實（結帳當下的價格）&lt;/td>
 &lt;td>凍結 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a>——上游變動不該影響它&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>回寫當前狀態（取消、追加）&lt;/td>
 &lt;td>活解析——必須命中上游當前的資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>同一筆記錄可以同時包含兩種欄位。出事的案例往往是把「顯示用的凍結值」順手拿去做「回寫用的定位」——凍結值在這個用途下跨越了它的有效期，失效的時機由上游的重建行為決定、持有端察覺不到。凍結時機的判準在 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡&lt;/a> 各自從身份語意和稽核面到達同一個結論：歷史記錄反映事件發生當下的世界。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;ul>
&lt;li>特定操作之後某些功能靜默失效（回寫目標不存在、查詢結果為空），且單元測試全綠——跨邊界參照的 id 可能已死亡。先確認上游操作的「保留 vs 重建」語意，再看持有端的解析時機。&lt;/li>
&lt;li>修復 bug 只搬了一層 id、遺漏另一層——同一個業務動詞涉及多層資料（單據 + 明細 + 記錄），每層的重建行為要逐層實測、修復的完整性以層數計。&lt;/li>
&lt;li>手工搬移的程式碼與同步邏輯在同一份可導出狀態上各寫一次——搬移是多餘的第二份資料來源，改成催一次同步。&lt;/li>
&lt;li>同步催了卻拿到空結果——同步路徑有內部順序依賴（過濾器讀的前置資料還是舊的），催同步前先確保前置資料已更新。&lt;/li>
&lt;li>自持狀態的數量持續增長——逐項問「上游為什麼不保存」，每消除一項就少一類遷移 bug。&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/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準&lt;/a>&lt;/li>
&lt;li>凍結參照在測試中的結構盲區 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽&lt;/a>&lt;/li>
&lt;li>假後端模擬重建行為 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>參照層的 case → &lt;a href="https://tarrragon.github.io/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期&lt;/a>&lt;/li>
&lt;li>遷移層的 case → &lt;a href="https://tarrragon.github.io/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>領域模型跨越邊界持有的每一個 id，有效性由上游行為決定、持有端控制不了。上游把「更新」實作成「刪除重建」是實作自由，下游用推理猜「它應該保留」擋不住——合法的出處只有契約文件或一次實測取證。這條事實推導出本章三個判準的共同源頭：跨邊界參照的設計，從「對方會做什麼」開始、而不是從「我要存什麼」開始。</p>
<p>本章承接 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 的身份語意討論，延伸到身份跨越系統邊界之後的生存問題；與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 的凍結端點段落互補——凍結是時間軸上的問題（何時），本章談的是空間軸上的問題（誰的 id、誰決定它活著）。</p>
<h2 id="參照有效性三問">參照有效性三問</h2>
<p>下游持有上游資料的 id 時，設計階段逐一回答：</p>
<ol>
<li><strong>對方的哪些操作會讓這個 id 死亡？</strong> 合併、拆分、覆蓋式更新、重建式修復——任何「刪舊建新」的實作都會讓舊 id 失效。盲區集中在業務動詞與實作的語意落差：同一個「合併」，有的實作是搬資料、有的實作是建新刪舊。持有端用前者的假設設計、遇到後者的實作時靜默失效。</li>
<li><strong>有沒有一個跨越這些操作仍不變的身份？</strong> 如果存在——以它為錨、其餘參照動態解析。如果沒有——凍結參照只能當一次性用途、跨操作的功能改走查詢。</li>
<li><strong>「不變」的出處是什麼？</strong> 文件說的、程式碼推理的、實測證實的——只有第三種算數。把實測結果固化為測試資產（<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>、<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>），從此不依賴任何人的記憶。</li>
</ol>
<p>三個問題的回答決定參照的設計形態。未回答就動工的代價是靜默失效——操作對著死 id 回寫，回傳值看起來正常（目標不存在也沒有噪音），功能在特定操作之後全部失效、直到使用者回報。凍結參照在測試中的結構盲區另見 <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</a>：由測試餵資料的 stub 同時控制凍結值與回應資料，讓「id 死亡」這個狀態在測試裡不可能出現。</p>
<h2 id="穩定身份為錨解引用延後到使用時">穩定身份為錨、解引用延後到使用時</h2>
<p>第二題的答案若為「存在」，設計形態收斂為：持有穩定身份、其餘參照在使用時動態解析。</p>
<p>「reference by identity + 解引用時機延後」把「id → 實體」的解析從持有時延後到使用時，讓解析結果反映上游當前的真相。額外的韌性：即使身份轉移發生在本端不知情的時候（另一台裝置觸發合併），使用時解析照樣找到新家。</p>
<p>查無結果時退回凍結值，搭配存在性檢查兜底——穩定身份的值域（如 uuid）讓失效的凍結值只會被擋下、不會誤中別的資料。退路的成本是一次額外查詢；退路被命中的頻率反映的是同步的及時性——頻率高代表催同步的時機需要調整（見下節）。</p>
<p>第二題的答案若為「不存在」，凍結參照是唯一手段，但它的有效期等於上游不做重建操作的期間。有效期內凍結合法；跨越有效期的功能需要改走查詢路徑——以業務鍵（訂單號、合約號這類人為穩定的識別碼）重新定位，而非依賴上游的技術 id。</p>
<h2 id="自持狀態與可導出狀態遷移分工">自持狀態與可導出狀態：遷移分工</h2>
<p>上游身份轉移（合併、拆分）發生後，下游持有的多份「以上游 id 為 key」的狀態面臨遷移問題。遷移判準一個問題就夠：<strong>這份狀態消失後，能否單靠向上游重新查詢完整重建？</strong></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>
  </tbody>
</table>
<p>兩類狀態的程式碼形狀差異清楚：自持狀態有一個「身份轉移通知」的入口方法，且它是所有身份轉移操作的必經站；可導出狀態沒有任何遷移程式碼，只有既有的同步迴圈。</p>
<p>可導出狀態「等下一輪同步」的延遲若不可接受，正確的加速做法是<strong>催一次既有同步</strong>——走同一條同步路徑、只是提早跑。催同步與手工搬移的差異在於：催同步後資料來源仍然只有上游這一份，手工搬移會製造第二份——兩份資料的分岔只是時間問題。催同步時注意同步路徑內部的順序依賴（路徑內的過濾機制可能依賴某份前置資料的新鮮度），順序錯了催出來的是空結果。</p>
<p>自持狀態的每一份都是一筆負債，債主是所有會改變上游身份的操作。盤點自持狀態清單、逐項問「上游為什麼不保存這個」——若上游未來補上這份資料，自持狀態就能降級為投影，遷移程式碼隨之刪除。這個分類判準與 <a href="/blog/ddd/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a> 同構：讀模型問「讀的形狀還是聚合根的形狀」，這裡問「這份狀態的真相在誰手上」。</p>
<h2 id="凍結快照的正當用途">凍結快照的正當用途</h2>
<p>凍結參照應消滅、活解析應取代——這條原則有一個明確的邊界：呈現歷史事實的快照是凍結的正當用途。</p>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th>正確形態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>呈現歷史事實（結帳當下的價格）</td>
          <td>凍結 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a>——上游變動不該影響它</td>
      </tr>
      <tr>
          <td>回寫當前狀態（取消、追加）</td>
          <td>活解析——必須命中上游當前的資料</td>
      </tr>
  </tbody>
</table>
<p>同一筆記錄可以同時包含兩種欄位。出事的案例往往是把「顯示用的凍結值」順手拿去做「回寫用的定位」——凍結值在這個用途下跨越了它的有效期，失效的時機由上游的重建行為決定、持有端察覺不到。凍結時機的判準在 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 與 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a> 各自從身份語意和稽核面到達同一個結論：歷史記錄反映事件發生當下的世界。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<ul>
<li>特定操作之後某些功能靜默失效（回寫目標不存在、查詢結果為空），且單元測試全綠——跨邊界參照的 id 可能已死亡。先確認上游操作的「保留 vs 重建」語意，再看持有端的解析時機。</li>
<li>修復 bug 只搬了一層 id、遺漏另一層——同一個業務動詞涉及多層資料（單據 + 明細 + 記錄），每層的重建行為要逐層實測、修復的完整性以層數計。</li>
<li>手工搬移的程式碼與同步邏輯在同一份可導出狀態上各寫一次——搬移是多餘的第二份資料來源，改成催一次同步。</li>
<li>同步催了卻拿到空結果——同步路徑有內部順序依賴（過濾器讀的前置資料還是舊的），催同步前先確保前置資料已更新。</li>
<li>自持狀態的數量持續增長——逐項問「上游為什麼不保存」，每消除一項就少一類遷移 bug。</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/read-model-upgrade-signals/" data-link-title="讀模型的升級判準" data-link-desc="repository 開始長出畫面專用查詢方法、或有人提議「上 CQRS」時使用。讀側是一道階梯而不是開關：訊號決定該爬到哪一階，自檢問句是「這個查詢回傳的是讀的形狀、還是 aggregate 的形狀」。">讀模型的升級判準</a></li>
<li>凍結參照在測試中的結構盲區 → <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></li>
<li>假後端模擬重建行為 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>參照層的 case → <a href="/blog/work-log/pos_cross_boundary_reference_lifecycle/" data-link-title="跨邊界參照的生命週期：前端凍結的 id，死活由後端決定" data-link-desc="POS App 的前端把後端資料的 id 凍結在本地追蹤記錄裡，後端的「合併」操作會重建資料——舊 id 全部失效，取消與追加功能無聲死亡。判準：跨邊界持有的每一個參照，都要回答「對方的哪些操作會讓它死」；穩定身份是實測出來的事實，不是推理出來的假設。">跨邊界參照的生命週期</a></li>
<li>遷移層的 case → <a href="/blog/work-log/pos_held_vs_derived_state_migration/" data-link-title="自持狀態與可導出狀態：上游身份轉移時，誰要搬家、誰自動對齊" data-link-desc="後端合併操作讓資料換了身份，前端持有的多份「以舊 id 為 key」的狀態怎麼辦？POS App 的答案分兩類：能從上游重新導出的狀態不用管、下一輪同步自動對齊；必須自行持有的狀態（差異比對基準、追蹤記錄）才需要通知搬家。分類錯誤的代價是兩個方向的 bug。">自持狀態與可導出狀態</a></li>
</ul>
]]></content:encoded></item></channel></rss>