<?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>State-Transition on Tarragon</title><link>https://tarrragon.github.io/blog/tags/state-transition/</link><description>Recent content in State-Transition on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/state-transition/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>