<?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>Invariant on Tarragon</title><link>https://tarrragon.github.io/blog/tags/invariant/</link><description>Recent content in Invariant on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 24 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/invariant/index.xml" rel="self" type="application/rss+xml"/><item><title>Invariant</title><link>https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/ddd/knowledge-cards/invariant/</guid><description>&lt;p>不變式是在物件整個生命週期都必須為真的業務規則。狀態只能沿流程轉換、某幾個欄位被同一條規則綁住必須一起換、錯誤代碼必須屬於對應分類——這些條件只要有一條被違反、物件就處於業務上不該存在的狀態。不變式的存在是判定型別為領域模型而非&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/data-bag/" data-link-title="Data Bag" data-link-desc="判斷一個型別要不要投資領域模型設計時使用。資料袋是欄位組合全部合法、沒有不變式要守的型別——DTO、API model、UI state 都屬於這一類。">資料袋&lt;/a>的關鍵依據：有不變式的型別需要領域模型的設計投資，沒有的就是資料袋。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>不變式跟輸入驗證的責任不同。不變式守「這個物件能不能存在」——違反時物件不該被建出來；輸入驗證守「使用者輸入對不對」——違反時要好好告訴使用者。兩者混放會讓格式驗證塞進建構子、或存在條件放進 validator。不變式跟 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot&lt;/a> 有交集——歷史 snapshot 凍結的是過去規則下合法的狀態、回讀時不一定通過新規則的不變式。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;p>型別上出現「請用某方法修改」「此欄位勿直接改」的註解時，不變式已經到場、強制還停在文件層。建構子或方法的 &lt;code>throw&lt;/code> / &lt;code>assert&lt;/code> 是不變式在執行層工作的證據；grep 得到零個對應檢查時，是宣稱約束但未強制。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&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></description><content:encoded><![CDATA[<p>不變式是在物件整個生命週期都必須為真的業務規則。狀態只能沿流程轉換、某幾個欄位被同一條規則綁住必須一起換、錯誤代碼必須屬於對應分類——這些條件只要有一條被違反、物件就處於業務上不該存在的狀態。不變式的存在是判定型別為領域模型而非<a href="/blog/ddd/knowledge-cards/data-bag/" data-link-title="Data Bag" data-link-desc="判斷一個型別要不要投資領域模型設計時使用。資料袋是欄位組合全部合法、沒有不變式要守的型別——DTO、API model、UI state 都屬於這一類。">資料袋</a>的關鍵依據：有不變式的型別需要領域模型的設計投資，沒有的就是資料袋。</p>
<h2 id="概念位置">概念位置</h2>
<p>不變式跟輸入驗證的責任不同。不變式守「這個物件能不能存在」——違反時物件不該被建出來；輸入驗證守「使用者輸入對不對」——違反時要好好告訴使用者。兩者混放會讓格式驗證塞進建構子、或存在條件放進 validator。不變式跟 <a href="/blog/ddd/knowledge-cards/snapshot/" data-link-title="Snapshot" data-link-desc="歷史記錄是否應該凍結當時狀態時使用。Snapshot 是某一時刻的狀態複本——歷史不隨現在的資料漂移。">snapshot</a> 有交集——歷史 snapshot 凍結的是過去規則下合法的狀態、回讀時不一定通過新規則的不變式。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<p>型別上出現「請用某方法修改」「此欄位勿直接改」的註解時，不變式已經到場、強制還停在文件層。建構子或方法的 <code>throw</code> / <code>assert</code> 是不變式在執行層工作的證據；grep 得到零個對應檢查時，是宣稱約束但未強制。</p>
<h2 id="設計責任">設計責任</h2>
<p>同一條不變式可以落在文件層（註解）、型別層（介面簽名）或執行層（建構子檢查），層次決定違反時發生什麼——靜默通過、編譯失敗、當場拒絕。選擇依違反代價與建置成本折算。教學層展開見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</p>
]]></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>Property-Based Testing（性質式測試）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/property-based-testing/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/property-based-testing/</guid><description>&lt;p>一條對所有合法輸入都該成立的性質，就是 property-based testing 拿來當斷言的東西——例如「排序後的長度與原本相同」（守恆）、「編碼再解碼會得到原值」（往返）、「折扣後的金額不會超過原價」（上下界）。框架負責生成大量輸入去試圖推翻它，並在找到反例時自動縮小到最短的那一個。它跟逐例測試的差別在 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">oracle&lt;/a> 的來源——逐例測試要人算出每個輸入對應的答案，性質式測試只要人寫下答案必須滿足的條件，而條件通常比答案好寫。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>不變量是比例子更難被實作污染的一種判準。逐例的預期值可以從實作跑一次抄回來，不變量抄不了——它必須從需求推導。這讓性質式測試在測試與實作&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立&lt;/a>的情況下保有較多驗證力，而生成器的存在也讓它涵蓋到人不會主動想到的輸入組合。&lt;/p>
&lt;p>四類骨幹性質（另有上下界與順序無關兩類，連同適用條件與划算判準在&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/oracle-beyond-examples/" data-link-title="判準寫不下來的時候：性質、變形關係與留給人的部分" data-link-desc="逐例預期值算不出來或跟不上產出速度時，用來決定判準退到哪一層、以及哪些驗證工作交不出去">判準寫不下來的時候&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>編碼後解碼得回原值&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;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>同一個測試被複製貼上多次、只有輸入的數值不同：那組例子背後藏著一條沒被寫出來的性質，把它寫出來就是這類測試的起點。另一個訊號是邊界值列表越補越長：0、1、-1、最大值、空字串、單一元素，每次線上出問題就補一個，代表判準是靠回憶累積的。&lt;/p>
&lt;p>失敗的縮小結果本身是產出。框架回報的最短反例通常直接就是缺陷的最小重現案例，可以原樣固定成一個逐例測試，讓這次的具體失敗有一個穩定的回歸點。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>寫得出來的性質決定了這個測試的射程，所以性質太弱是主要的失效方式。「結果不會是 null」對所有實作都成立，包含錯的那些；把它收緊成「結果的長度等於輸入的長度且每個元素都來自輸入」才開始有鑑別力。判斷方式跟&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變測試&lt;/a>一致——問這條性質擋不擋得住某個具體的錯誤寫法。&lt;/p>
&lt;p>生成器要跟著資料的真實形狀走。預設生成器產出的字串多半是短的隨機字元，而實際輸入可能有多位元組字元、前後空白與極長的內容；生成器沒有涵蓋的形狀，性質再強也驗不到，這與 &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">test data 代表性&lt;/a>是同一個問題。連性質都難以陳述時，判準要再退一層到&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/metamorphic-testing/" data-link-title="Metamorphic Testing（變形測試）" data-link-desc="被測對象沒有已知正確輸出、連不變量都難以陳述時，用兩次執行之間的關係取代預期值">變形關係&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>一條對所有合法輸入都該成立的性質，就是 property-based testing 拿來當斷言的東西——例如「排序後的長度與原本相同」（守恆）、「編碼再解碼會得到原值」（往返）、「折扣後的金額不會超過原價」（上下界）。框架負責生成大量輸入去試圖推翻它，並在找到反例時自動縮小到最短的那一個。它跟逐例測試的差別在 <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">oracle</a> 的來源——逐例測試要人算出每個輸入對應的答案，性質式測試只要人寫下答案必須滿足的條件，而條件通常比答案好寫。</p>
<h2 id="概念位置">概念位置</h2>
<p>不變量是比例子更難被實作污染的一種判準。逐例的預期值可以從實作跑一次抄回來，不變量抄不了——它必須從需求推導。這讓性質式測試在測試與實作<a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立</a>的情況下保有較多驗證力，而生成器的存在也讓它涵蓋到人不會主動想到的輸入組合。</p>
<p>四類骨幹性質（另有上下界與順序無關兩類，連同適用條件與划算判準在<a href="/blog/testing/06-agent-authored-code/oracle-beyond-examples/" data-link-title="判準寫不下來的時候：性質、變形關係與留給人的部分" data-link-desc="逐例預期值算不出來或跟不上產出速度時，用來決定判準退到哪一層、以及哪些驗證工作交不出去">判準寫不下來的時候</a>）：</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>與一份簡單但慢的參照實作結果相同</td>
          <td>最佳化過的演算法</td>
      </tr>
      <tr>
          <td>冪等</td>
          <td>執行兩次與執行一次結果相同</td>
          <td>正規化、去重、部署腳本</td>
      </tr>
  </tbody>
</table>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>同一個測試被複製貼上多次、只有輸入的數值不同：那組例子背後藏著一條沒被寫出來的性質，把它寫出來就是這類測試的起點。另一個訊號是邊界值列表越補越長：0、1、-1、最大值、空字串、單一元素，每次線上出問題就補一個，代表判準是靠回憶累積的。</p>
<p>失敗的縮小結果本身是產出。框架回報的最短反例通常直接就是缺陷的最小重現案例，可以原樣固定成一個逐例測試，讓這次的具體失敗有一個穩定的回歸點。</p>
<h2 id="設計責任">設計責任</h2>
<p>寫得出來的性質決定了這個測試的射程，所以性質太弱是主要的失效方式。「結果不會是 null」對所有實作都成立，包含錯的那些；把它收緊成「結果的長度等於輸入的長度且每個元素都來自輸入」才開始有鑑別力。判斷方式跟<a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變測試</a>一致——問這條性質擋不擋得住某個具體的錯誤寫法。</p>
<p>生成器要跟著資料的真實形狀走。預設生成器產出的字串多半是短的隨機字元，而實際輸入可能有多位元組字元、前後空白與極長的內容；生成器沒有涵蓋的形狀，性質再強也驗不到，這與 <a href="/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">test data 代表性</a>是同一個問題。連性質都難以陳述時，判準要再退一層到<a href="/blog/testing/knowledge-cards/metamorphic-testing/" data-link-title="Metamorphic Testing（變形測試）" data-link-desc="被測對象沒有已知正確輸出、連不變量都難以陳述時，用兩次執行之間的關係取代預期值">變形關係</a>。</p>
]]></content:encoded></item><item><title>單調狀態機與樂觀更新的回滾契約：前台不得顯示後端沒記錄的狀態</title><link>https://tarrragon.github.io/blog/work-log/pos_monotonic_status_optimistic_rollback/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_monotonic_status_optimistic_rollback/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS App 的品項處理進度（未確認 → 已確認 → 已完成）由員工在前端推進、同步到後端。兩個設計決策被測試逼著說清楚：為什麼狀態只能往前？為什麼後端拒絕時一定要回滾樂觀更新？
&lt;strong>疑問來源&lt;/strong>：狀態遞增的守則寫在模型方法裡、回滾寫在服務層——兩條規則的「為什麼」散在註解裡，直到為它們補測試時才發現各自對應一個業務不變式。
&lt;strong>整理目的&lt;/strong>：把「單調狀態機」與「樂觀更新的回滾契約」整理成判準，並記錄它們與防護鏈的依賴關係。
&lt;strong>本文邊界&lt;/strong>：不變式落點的理論層見 &lt;a href="https://tarrragon.github.io/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-單調狀態機不可逆的現實不可逆的模型">1. 單調狀態機：不可逆的現實、不可逆的模型&lt;/h2>
&lt;p>品項處理狀態的變更方法只接受「更高」的狀態值——同值與回退一律拒絕（回傳 false、不改狀態）。理由不是技術潔癖，是&lt;strong>狀態對應的現實動作不可逆&lt;/strong>：餐點端出去就收不回來。模型層的單調守則把「UI 誤觸」「事件亂序抵達」「重複訊息」全部擋在同一個入口。&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;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>不可&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>單調＋終態側分支，是「進度追蹤」類狀態機的常見形狀；把它寫成模型方法的入口守則，比散在各呼叫端的 if 檢查可靠一個量級——這正是&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="2-樂觀更新的回滾契約">2. 樂觀更新的回滾契約&lt;/h2>
&lt;p>推進狀態的流程是樂觀式：先改本地（UI 立即反映）、再同步後端、失敗才回滾。回滾那一步在 code review 裡常被當成「禮貌性的清理」，但這裡它是硬契約，原因在依賴鏈：&lt;/p>
&lt;p>&lt;strong>這個狀態是其他防護規則的資料來源。&lt;/strong>「有品項已完成 → 不可刪除單據」「全部完成 → 才可拆分」——這些守衛讀的就是它。若後端拒絕後前台不回滾，會出現「前台顯示已完成、後端沒有記錄」的分裂狀態：守衛在前台的假象上做防護決策，放行了不該放行的操作，或擋下了不該擋的。&lt;/p>
&lt;p>由此得出樂觀更新的回滾判準：&lt;strong>看這個狀態有沒有下游讀者&lt;/strong>。&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;/tbody>
&lt;/table>
&lt;p>測試也照這個契約寫：後端拒絕 → 斷言狀態回到原值，reason 直接寫後果——「前台不得顯示後端沒記錄的狀態，否則守衛判錯」。&lt;/p>
&lt;h2 id="3-批次回標與防禦性空操作">3. 批次回標與「防禦性空操作」&lt;/h2>
&lt;p>同一個狀態機還有一個批次入口：某個重建式操作（拆分單據）會把兩邊的處理記錄洗回初始狀態，流程約定「操作前提是全部完成」，所以操作後要把所有記錄批次回標。實測發現後端在該操作後根本不留記錄——回標實際上是空操作。&lt;/p>
&lt;p>要不要刪掉這段程式？保留，並把理由寫進方法說明：它防的是&lt;strong>後端行為改變的那一天&lt;/strong>——若未來記錄以未完成狀態重生，缺了回標會讓結帳流程被鎖死。這是「防禦性空操作」的合理場景：成本是幾行程式與一條「呼叫次數為零」的測試斷言，保的是上游行為漂移時的降級路徑。判準：防禦性程式碼要嘛有測試證明它在防的情境（哪怕目前不會發生），要嘛刪除——「留著以防萬一」但沒人指得出防什麼的程式碼才是負債。&lt;/p>
&lt;h2 id="4-可複用的判準">4. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>狀態對應不可逆的現實動作 → 模型入口強制單調，同值與回退一律拒絕；終態側分支明確畫出來。&lt;/li>
&lt;li>樂觀更新是否必須回滾，看狀態的下游讀者：有規則消費它 → 分裂狀態不可容忍。&lt;/li>
&lt;li>回滾測試的 reason 寫依賴鏈的後果，不寫「狀態應該是 X」。&lt;/li>
&lt;li>防禦性空操作要附帶「它在防什麼」的說明與測試錨點，否則刪除。&lt;/li>
&lt;/ol>
&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/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS App 的品項處理進度（未確認 → 已確認 → 已完成）由員工在前端推進、同步到後端。兩個設計決策被測試逼著說清楚：為什麼狀態只能往前？為什麼後端拒絕時一定要回滾樂觀更新？
<strong>疑問來源</strong>：狀態遞增的守則寫在模型方法裡、回滾寫在服務層——兩條規則的「為什麼」散在註解裡，直到為它們補測試時才發現各自對應一個業務不變式。
<strong>整理目的</strong>：把「單調狀態機」與「樂觀更新的回滾契約」整理成判準，並記錄它們與防護鏈的依賴關係。
<strong>本文邊界</strong>：不變式落點的理論層見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>。</p></blockquote>
<hr>
<h2 id="1-單調狀態機不可逆的現實不可逆的模型">1. 單調狀態機：不可逆的現實、不可逆的模型</h2>
<p>品項處理狀態的變更方法只接受「更高」的狀態值——同值與回退一律拒絕（回傳 false、不改狀態）。理由不是技術潔癖，是<strong>狀態對應的現實動作不可逆</strong>：餐點端出去就收不回來。模型層的單調守則把「UI 誤觸」「事件亂序抵達」「重複訊息」全部擋在同一個入口。</p>
<p>終態的側分支另有一格：「已取消」不在遞增序列上，而是從中途狀態岔出的終點——已完成的品項不可取消（現實上已交付）、已取消的不可再推進。整個狀態機小到一張表就能窮舉：</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>不可</td>
          <td>不可（已交付）</td>
      </tr>
      <tr>
          <td>已取消</td>
          <td>不可</td>
          <td>不可</td>
          <td>不可</td>
      </tr>
  </tbody>
</table>
<p>單調＋終態側分支，是「進度追蹤」類狀態機的常見形狀；把它寫成模型方法的入口守則，比散在各呼叫端的 if 檢查可靠一個量級——這正是<a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>講的「領域方法作為唯一變更路徑」。</p>
<h2 id="2-樂觀更新的回滾契約">2. 樂觀更新的回滾契約</h2>
<p>推進狀態的流程是樂觀式：先改本地（UI 立即反映）、再同步後端、失敗才回滾。回滾那一步在 code review 裡常被當成「禮貌性的清理」，但這裡它是硬契約，原因在依賴鏈：</p>
<p><strong>這個狀態是其他防護規則的資料來源。</strong>「有品項已完成 → 不可刪除單據」「全部完成 → 才可拆分」——這些守衛讀的就是它。若後端拒絕後前台不回滾，會出現「前台顯示已完成、後端沒有記錄」的分裂狀態：守衛在前台的假象上做防護決策，放行了不該放行的操作，或擋下了不該擋的。</p>
<p>由此得出樂觀更新的回滾判準：<strong>看這個狀態有沒有下游讀者</strong>。</p>
<table>
  <thead>
      <tr>
          <th>狀態的消費者</th>
          <th>回滾的必要性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只有畫面顯示</td>
          <td>失敗提示＋下次同步自然修正，可接受</td>
      </tr>
      <tr>
          <td>有防護規則、流程分支讀它</td>
          <td>必須立即回滾——分裂狀態會讓規則在錯誤前提上運作</td>
      </tr>
  </tbody>
</table>
<p>測試也照這個契約寫：後端拒絕 → 斷言狀態回到原值，reason 直接寫後果——「前台不得顯示後端沒記錄的狀態，否則守衛判錯」。</p>
<h2 id="3-批次回標與防禦性空操作">3. 批次回標與「防禦性空操作」</h2>
<p>同一個狀態機還有一個批次入口：某個重建式操作（拆分單據）會把兩邊的處理記錄洗回初始狀態，流程約定「操作前提是全部完成」，所以操作後要把所有記錄批次回標。實測發現後端在該操作後根本不留記錄——回標實際上是空操作。</p>
<p>要不要刪掉這段程式？保留，並把理由寫進方法說明：它防的是<strong>後端行為改變的那一天</strong>——若未來記錄以未完成狀態重生，缺了回標會讓結帳流程被鎖死。這是「防禦性空操作」的合理場景：成本是幾行程式與一條「呼叫次數為零」的測試斷言，保的是上游行為漂移時的降級路徑。判準：防禦性程式碼要嘛有測試證明它在防的情境（哪怕目前不會發生），要嘛刪除——「留著以防萬一」但沒人指得出防什麼的程式碼才是負債。</p>
<h2 id="4-可複用的判準">4. 可複用的判準</h2>
<ol>
<li>狀態對應不可逆的現實動作 → 模型入口強制單調，同值與回退一律拒絕；終態側分支明確畫出來。</li>
<li>樂觀更新是否必須回滾，看狀態的下游讀者：有規則消費它 → 分裂狀態不可容忍。</li>
<li>回滾測試的 reason 寫依賴鏈的後果，不寫「狀態應該是 X」。</li>
<li>防禦性空操作要附帶「它在防什麼」的說明與測試錨點，否則刪除。</li>
</ol>
<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/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律</a></li>
</ul>
]]></content:encoded></item><item><title>「978ABC」被拒的理由寫著長度不對 — 驗證的兩層分工與順序陷阱</title><link>https://tarrragon.github.io/blog/work-log/flutter_domain_input_validation_placement/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_domain_input_validation_placement/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的查詢輸入層——&lt;code>BookQueryInput&lt;/code> value object 加 &lt;code>BookInputValidator&lt;/code> 驗證器。實作過程撞了兩個問題：測試想建一個全空的輸入來測 validator、被 VO 的建構驗證擋住建不出來；ISBN 填 &lt;code>978ABC&lt;/code> 被拒、錯誤訊息卻說「長度必須是 10 或 13 位」
&lt;strong>疑問來源&lt;/strong>：驗證邏輯到底該放建構子還是 validator？以及那個張冠李戴的錯誤訊息是怎麼來的？
&lt;strong>整理目的&lt;/strong>：記下驗證的兩層分工判準、以及「先標準化再檢查」的證據銷毀陷阱
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.11.3 的實作記錄（41 個單元測試的 TDD 過程、含三個實作期問題的解法）&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="兩層分工存在條件-vs-輸入品質">兩層分工：存在條件 vs 輸入品質&lt;/h2>
&lt;p>這一層的設計把驗證拆在兩個位置，各守一種性質的規則：&lt;/p>
&lt;p>&lt;strong>&lt;code>BookQueryInput&lt;/code> 的建構期不變式&lt;/strong>：至少一個查詢參數非空。這是&lt;strong>存在條件&lt;/strong>——四個欄位全空的「查詢輸入」在語意上不是一個查詢，這種物件不該存在於系統的任何角落。違反它的處置是拒絕建構：拿到 &lt;code>BookQueryInput&lt;/code> 實例的任何下游、都可以信任它至少有一個參數。&lt;/p>
&lt;p>&lt;strong>&lt;code>BookInputValidator&lt;/code> 的格式驗證&lt;/strong>：ISBN 格式（10 或 13 位、允許連字符與空格）、標題與作者長度（1-255 字元）、附帶標準化（去連字符、收斂空白）。這是&lt;strong>輸入品質&lt;/strong>——使用者打錯很正常，處置不是拒絕存在、是回一個 &lt;code>ValidationResult&lt;/code>：錯誤碼清單、本地化訊息、以及標準化後的值。&lt;/p>
&lt;p>判準收成一句：&lt;strong>違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator&lt;/strong>。前者失敗是程式錯誤（哪段程式碼試圖建一個不合法的物件？）、後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向都會現形：格式驗證塞進建構子，UI 層要 try-catch 例外再翻譯成欄位錯誤、錯誤碼與訊息的結構化全部丟失；存在條件放進 validator，全空的物件能在系統裡流通、每個消費者都要自己防。&lt;/p>
&lt;p>有趣的是這個分工是被測試&lt;strong>撞&lt;/strong>出來的：測試想建全空實例去測 validator 的「至少一個參數」規則、被建構不變式擋住。這個衝突不是誰錯——它暴露了「至少一個參數」同時被兩層宣告。釐清後規則歸建構期（存在條件）、validator 的對應測試改測空白字串等輸入品質情境。測試建不出 fixture、經常就是層次劃分待釐清的訊號。&lt;/p>
&lt;h2 id="順序陷阱先標準化等於先銷毀證據">順序陷阱：先標準化、等於先銷毀證據&lt;/h2>
&lt;p>第二個問題是條精緻的小 bug。ISBN 驗證的原始順序是「先標準化、再檢查」：標準化移除所有非數字字元、然後檢查位數。輸入 &lt;code>978ABC&lt;/code> 走完這條管線：字母被移除、剩 &lt;code>978&lt;/code>、三位數、被拒——錯誤訊息是「長度必須是 10 或 13 位」。&lt;/p>
&lt;p>拒絕是對的、&lt;strong>理由是錯的&lt;/strong>。使用者的實際問題是「ISBN 含字母」，訊息卻叫他去檢查長度——他數了數自己輸入的六個字元、更困惑了。機制上這是&lt;strong>證據銷毀&lt;/strong>：標準化是有損操作，把「含字母」這個診斷所需的證據刪掉了，後面的檢查只能對殘骸做判斷、自然歸錯類。修法是把順序反過來：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">RegExp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">r&amp;#39;^[\d\-\s]+$&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">hasMatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isbn&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;invalid_isbn_format&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 正確的病名
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 通過格式檢查的才標準化、再驗位數
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">normalized&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">normalizeIsbn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isbn&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一般化的規則：&lt;strong>診斷在證據被破壞之前做&lt;/strong>。管線裡任何有損轉換（去除字元、截斷、大小寫合併、去重）之後的檢查，都只能回報轉換後世界的錯誤——想給使用者他輸入層面的錯誤訊息、檢查就得在轉換前。這條規則在錯誤處理鏈上反覆適用：wrap 例外時保留原始例外、log 時保留原始輸入，同一個「別讓下游只看到殘骸」。&lt;/p>
&lt;p>另一個小設計也值得帶走：&lt;code>ValidationResult&lt;/code> 直接攜帶 &lt;code>normalizedIsbn&lt;/code> / &lt;code>normalizedTitle&lt;/code>——驗證跟標準化一次完成、下游拿標準化值繼續用，不會出現「驗證器驗一個版本、查詢用另一個版本」的分裂。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>測試建不出想要的 fixture、被建構驗證擋住——存在條件與輸入品質可能混在同一層、先釐清歸屬&lt;/li>
&lt;li>錯誤訊息與使用者的實際輸入對不上（說長度、其實是字元；說格式、其實是空值）——檢查點在有損轉換之後、往管線上游搬&lt;/li>
&lt;li>UI 層用 try-catch 接建構例外再翻譯成表單錯誤——格式驗證放錯層了、它該回結構化結果不該拋&lt;/li>
&lt;li>驗證器回布林——錯誤碼、訊息、標準化值都沒有位置放，遲早長出第二套平行邏輯&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>不變式住哪一層的全景：&lt;a href="https://tarrragon.github.io/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通&lt;/a>——本文的建構期不變式是「執行層強制」的標準形態&lt;/li>
&lt;li>同族的分類學：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式&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>——建構子與 validator 的分工是建構路徑設計的入口決策；存在條件與輸入品質的分層邊界見 &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></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的查詢輸入層——<code>BookQueryInput</code> value object 加 <code>BookInputValidator</code> 驗證器。實作過程撞了兩個問題：測試想建一個全空的輸入來測 validator、被 VO 的建構驗證擋住建不出來；ISBN 填 <code>978ABC</code> 被拒、錯誤訊息卻說「長度必須是 10 或 13 位」
<strong>疑問來源</strong>：驗證邏輯到底該放建構子還是 validator？以及那個張冠李戴的錯誤訊息是怎麼來的？
<strong>整理目的</strong>：記下驗證的兩層分工判準、以及「先標準化再檢查」的證據銷毀陷阱
<strong>本文邊界</strong>：素材是該專案 v0.11.3 的實作記錄（41 個單元測試的 TDD 過程、含三個實作期問題的解法）</p></blockquote>
<hr>
<h2 id="兩層分工存在條件-vs-輸入品質">兩層分工：存在條件 vs 輸入品質</h2>
<p>這一層的設計把驗證拆在兩個位置，各守一種性質的規則：</p>
<p><strong><code>BookQueryInput</code> 的建構期不變式</strong>：至少一個查詢參數非空。這是<strong>存在條件</strong>——四個欄位全空的「查詢輸入」在語意上不是一個查詢，這種物件不該存在於系統的任何角落。違反它的處置是拒絕建構：拿到 <code>BookQueryInput</code> 實例的任何下游、都可以信任它至少有一個參數。</p>
<p><strong><code>BookInputValidator</code> 的格式驗證</strong>：ISBN 格式（10 或 13 位、允許連字符與空格）、標題與作者長度（1-255 字元）、附帶標準化（去連字符、收斂空白）。這是<strong>輸入品質</strong>——使用者打錯很正常，處置不是拒絕存在、是回一個 <code>ValidationResult</code>：錯誤碼清單、本地化訊息、以及標準化後的值。</p>
<p>判準收成一句：<strong>違反時「這個物件不該存在」的規則進建構子、違反時「要好好告訴使用者」的規則進 validator</strong>。前者失敗是程式錯誤（哪段程式碼試圖建一個不合法的物件？）、後者失敗是日常輸入流程的一個分支。混放的代價在兩個方向都會現形：格式驗證塞進建構子，UI 層要 try-catch 例外再翻譯成欄位錯誤、錯誤碼與訊息的結構化全部丟失；存在條件放進 validator，全空的物件能在系統裡流通、每個消費者都要自己防。</p>
<p>有趣的是這個分工是被測試<strong>撞</strong>出來的：測試想建全空實例去測 validator 的「至少一個參數」規則、被建構不變式擋住。這個衝突不是誰錯——它暴露了「至少一個參數」同時被兩層宣告。釐清後規則歸建構期（存在條件）、validator 的對應測試改測空白字串等輸入品質情境。測試建不出 fixture、經常就是層次劃分待釐清的訊號。</p>
<h2 id="順序陷阱先標準化等於先銷毀證據">順序陷阱：先標準化、等於先銷毀證據</h2>
<p>第二個問題是條精緻的小 bug。ISBN 驗證的原始順序是「先標準化、再檢查」：標準化移除所有非數字字元、然後檢查位數。輸入 <code>978ABC</code> 走完這條管線：字母被移除、剩 <code>978</code>、三位數、被拒——錯誤訊息是「長度必須是 10 或 13 位」。</p>
<p>拒絕是對的、<strong>理由是錯的</strong>。使用者的實際問題是「ISBN 含字母」，訊息卻叫他去檢查長度——他數了數自己輸入的六個字元、更困惑了。機制上這是<strong>證據銷毀</strong>：標準化是有損操作，把「含字母」這個診斷所需的證據刪掉了，後面的檢查只能對殘骸做判斷、自然歸錯類。修法是把順序反過來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 先對原始輸入檢查格式——證據還在
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">RegExp</span><span class="p">(</span><span class="s1">r&#39;^[\d\-\s]+$&#39;</span><span class="p">).</span><span class="n">hasMatch</span><span class="p">(</span><span class="n">isbn</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="s1">&#39;invalid_isbn_format&#39;</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="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 通過格式檢查的才標準化、再驗位數
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">normalized</span> <span class="o">=</span> <span class="n">normalizeIsbn</span><span class="p">(</span><span class="n">isbn</span><span class="p">);</span></span></span></code></pre></div><p>一般化的規則：<strong>診斷在證據被破壞之前做</strong>。管線裡任何有損轉換（去除字元、截斷、大小寫合併、去重）之後的檢查，都只能回報轉換後世界的錯誤——想給使用者他輸入層面的錯誤訊息、檢查就得在轉換前。這條規則在錯誤處理鏈上反覆適用：wrap 例外時保留原始例外、log 時保留原始輸入，同一個「別讓下游只看到殘骸」。</p>
<p>另一個小設計也值得帶走：<code>ValidationResult</code> 直接攜帶 <code>normalizedIsbn</code> / <code>normalizedTitle</code>——驗證跟標準化一次完成、下游拿標準化值繼續用，不會出現「驗證器驗一個版本、查詢用另一個版本」的分裂。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>測試建不出想要的 fixture、被建構驗證擋住——存在條件與輸入品質可能混在同一層、先釐清歸屬</li>
<li>錯誤訊息與使用者的實際輸入對不上（說長度、其實是字元；說格式、其實是空值）——檢查點在有損轉換之後、往管線上游搬</li>
<li>UI 層用 try-catch 接建構例外再翻譯成表單錯誤——格式驗證放錯層了、它該回結構化結果不該拋</li>
<li>驗證器回布林——錯誤碼、訊息、標準化值都沒有位置放，遲早長出第二套平行邏輯</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>不變式住哪一層的全景：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>——本文的建構期不變式是「執行層強制」的標準形態</li>
<li>同族的分類學：<a href="/blog/work-log/flutter_exception_error_category_invariant/" data-link-title="Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻" data-link-desc="把「錯誤代碼必須屬於對應分類」做成建構期不變式，錯誤分類錯亂會變成測試失敗而不是靜默混亂；同一批修復出現三種形態——換對值、換精確值、以及改繼承逃離約束。第三種是分類學本身的訊號：一個 domain 的錯誤天生橫跨技術分類時，分類軸跟階層軸不正交。">Exception 型別綁 ErrorCategory 的建構不變式</a>——那篇是錯誤的分類不變式、本文是輸入的存在不變式</li>
<li>概念地基：<a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>——建構子與 validator 的分工是建構路徑設計的入口決策；存在條件與輸入品質的分層邊界見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a></li>
</ul>
]]></content:encoded></item><item><title>Exception 型別綁 ErrorCategory 的建構不變式 — 以及合法需求撞上不變式的時刻</title><link>https://tarrragon.github.io/blog/work-log/flutter_exception_error_category_invariant/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_exception_error_category_invariant/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的測試修復盤點，六個失敗指向同一個結構：exception 型別對錯誤代碼的分類有建構期要求、而實作塞了跨分類的 code——&lt;code>BusinessException&lt;/code> 家族用了 network 分類的 &lt;code>serverError&lt;/code>、&lt;code>StorageException&lt;/code> 用了 platform 分類的 &lt;code>permissionDenied&lt;/code>
&lt;strong>疑問來源&lt;/strong>：錯誤代碼分錯類、為什麼會讓測試失敗？以及修法裡出現「改繼承」這種大動作、合理嗎？
&lt;strong>整理目的&lt;/strong>：記下「錯誤分類當領域建模」的不變式設計、以及不變式被合法需求撞上時的三種處置與各自的訊號
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.11.15 的測試修復計畫；錯誤分類軸（business / network / storage / platform / validation）是該專案的切法、不是通用標準&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="設計錯誤代碼的分類是建構不變式">設計：錯誤代碼的分類是建構不變式&lt;/h2>
&lt;p>這個專案把錯誤處理建成兩層結構：&lt;code>ErrorCode&lt;/code> 枚舉、每個 code 隸屬一個 &lt;code>ErrorCategory&lt;/code>（business / network / storage / platform / validation）；exception 型別各自綁定分類——&lt;code>BusinessException&lt;/code> 的建構要求 code 屬於 business 分類、&lt;code>StorageException&lt;/code> 要求 storage 分類。&lt;/p>
&lt;p>這是把「錯誤要分對類」從文件約定升到執行層的做法。沒有這層不變式時，分類錯亂是靜默的：&lt;code>ImportException&lt;/code> 帶著 network 的 code 一樣能拋能接，錯亂只在某天有人按分類統計錯誤、或按分類決定重試策略時才以錯誤行為浮現。有不變式，錯亂在建構的當下就炸——這批測試失敗全是不變式在工作的證據，六個失敗六個都指向真實的分類錯誤。&lt;/p>
&lt;h2 id="同一批修復的三種形態">同一批修復的三種形態&lt;/h2>
&lt;p>值得記的是修法不只一種，三種形態對應三種不同的病因：&lt;/p>
&lt;p>&lt;strong>形態一：值選錯了、換對的。&lt;/strong> &lt;code>StorageException.permissionDenied&lt;/code> 用了 platform 分類的 &lt;code>permissionDenied&lt;/code>，而 storage 分類裡有語意等價的 &lt;code>fileAccessDenied&lt;/code>——換過去、不變式滿足、語意不變。病因是選 code 時沒查分類表，最便宜的修法。&lt;/p>
&lt;p>&lt;strong>形態二：值太泛、換精確的。&lt;/strong> 掃描服務把 ISBN 格式錯誤拋成 &lt;code>validationFailed&lt;/code>、把離線與網路錯誤都拋成 &lt;code>serverError&lt;/code>，測試期待的是 &lt;code>invalidIsbn&lt;/code>、&lt;code>networkError&lt;/code>、&lt;code>offlineError&lt;/code>。泛化 code 不違反分類不變式、但淹沒語意——下游想對「離線」跟「伺服器壞了」做不同處置時，兩者在錯誤碼層已經不可區分。這是不變式管不到的精度問題，靠測試斷言把精確度釘住。&lt;/p>
&lt;p>&lt;strong>形態三：約束本身擋住合法需求、改繼承逃離。&lt;/strong> &lt;code>ImportException&lt;/code> 原本繼承 &lt;code>BusinessException&lt;/code>，但匯入流程的錯誤天生橫跨分類——JSON 解析壞（validation）、來源伺服器錯（network）、寫檔失敗（storage）。business 分類裡根本沒有它需要的 code。修法是把父類從 &lt;code>BusinessException&lt;/code> 改成 &lt;code>AppException&lt;/code>（不綁分類的基類），逃離約束。&lt;/p>
&lt;h2 id="形態三是分類學的訊號不是不變式的失敗">形態三是分類學的訊號、不是不變式的失敗&lt;/h2>
&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;strong>合法的&lt;/strong>：匯入錯誤真的橫跨技術分類。撞牆暴露的是兩條分類軸不正交：&lt;/p>
&lt;ul>
&lt;li>&lt;code>ErrorCategory&lt;/code> 的軸是&lt;strong>技術來源&lt;/strong>（網路、儲存、平台）&lt;/li>
&lt;li>exception 階層的軸是&lt;strong>業務流程&lt;/strong>（匯入、掃描、搜尋）&lt;/li>
&lt;/ul>
&lt;p>一個業務流程天生會遭遇多種技術來源的錯誤，把業務流程的 exception 綁死在單一技術分類上，約束跟現實的形狀不合。&lt;code>ImportException&lt;/code> 改繼承是對這個不合的誠實回應；更徹底的修法是承認兩軸各自獨立——exception 型別按業務流程分、&lt;code>ErrorCategory&lt;/code> 作為錯誤的一個屬性自由取值——但那是更大的重構，當下的繼承調整是合比例的處置。&lt;/p>
&lt;p>可操作的判準：&lt;strong>不變式被撞的時候，先分「需求違規」還是「約束錯形」&lt;/strong>。前者的訊號是繞過方在找便利（copyWith 改狀態、省掉領域方法）；後者的訊號是繞過方有無法被現有約束表達的正當語意（匯入錯誤需要 network code）。前者修繞過方、後者修約束。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>exception 建構失敗、訊息指向分類不匹配——先查分類表有沒有語意等價的正確 code（形態一）、再問這個 exception 是不是真的只屬於一個分類（形態三）&lt;/li>
&lt;li>多種不同情境拋同一個泛化 code（&lt;code>serverError&lt;/code> 當萬用垃圾桶）——語意精度在流失、下游的分支處置已經寫不出來&lt;/li>
&lt;li>「改繼承來讓建構通過」的修法出現——停下來判定是逃生還是約束錯形；是後者就把分類學的不合寫成決策記錄，否則下一個橫跨分類的 exception 會重演一次&lt;/li>
&lt;li>錯誤分類只存在於命名慣例（&lt;code>NetworkXxxError&lt;/code>）而沒有建構驗證——分類錯亂正在靜默累積、第一個按分類做統計或重試的功能會揭開它&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>不變式強制層次的原則層：&lt;a href="https://tarrragon.github.io/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通&lt;/a>——本文是「約束做進執行層之後」的下一章：約束會工作、也會被合法需求撞&lt;/li>
&lt;li>決策表矛盾的同構：&lt;a href="https://tarrragon.github.io/blog/report/decision-table-conflict-reveals-missing-dimension/" data-link-title="決策表兩列同時命中且結論相反：缺的是一個上游區分維度" data-link-desc="判讀表 / 決策表的兩列規則被同一個真實案例同時命中、且指向相反結論時、問題通常出在表外：案例承載著兩種身分、而表缺少把身分拆開的上游維度 — 修法是補前置澄清問、把維度抬到表之前；拆不出身分的矛盾才是規則真衝突、回表內改規則。偵測方法是用真實案例 dry-run、不是逐列檢查 — 單列都正確的表仍可能整體矛盾。">#158 決策表兩列同時命中且結論相反：缺的是上游區分維度&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;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的測試修復盤點，六個失敗指向同一個結構：exception 型別對錯誤代碼的分類有建構期要求、而實作塞了跨分類的 code——<code>BusinessException</code> 家族用了 network 分類的 <code>serverError</code>、<code>StorageException</code> 用了 platform 分類的 <code>permissionDenied</code>
<strong>疑問來源</strong>：錯誤代碼分錯類、為什麼會讓測試失敗？以及修法裡出現「改繼承」這種大動作、合理嗎？
<strong>整理目的</strong>：記下「錯誤分類當領域建模」的不變式設計、以及不變式被合法需求撞上時的三種處置與各自的訊號
<strong>本文邊界</strong>：素材是該專案 v0.11.15 的測試修復計畫；錯誤分類軸（business / network / storage / platform / validation）是該專案的切法、不是通用標準</p></blockquote>
<hr>
<h2 id="設計錯誤代碼的分類是建構不變式">設計：錯誤代碼的分類是建構不變式</h2>
<p>這個專案把錯誤處理建成兩層結構：<code>ErrorCode</code> 枚舉、每個 code 隸屬一個 <code>ErrorCategory</code>（business / network / storage / platform / validation）；exception 型別各自綁定分類——<code>BusinessException</code> 的建構要求 code 屬於 business 分類、<code>StorageException</code> 要求 storage 分類。</p>
<p>這是把「錯誤要分對類」從文件約定升到執行層的做法。沒有這層不變式時，分類錯亂是靜默的：<code>ImportException</code> 帶著 network 的 code 一樣能拋能接，錯亂只在某天有人按分類統計錯誤、或按分類決定重試策略時才以錯誤行為浮現。有不變式，錯亂在建構的當下就炸——這批測試失敗全是不變式在工作的證據，六個失敗六個都指向真實的分類錯誤。</p>
<h2 id="同一批修復的三種形態">同一批修復的三種形態</h2>
<p>值得記的是修法不只一種，三種形態對應三種不同的病因：</p>
<p><strong>形態一：值選錯了、換對的。</strong> <code>StorageException.permissionDenied</code> 用了 platform 分類的 <code>permissionDenied</code>，而 storage 分類裡有語意等價的 <code>fileAccessDenied</code>——換過去、不變式滿足、語意不變。病因是選 code 時沒查分類表，最便宜的修法。</p>
<p><strong>形態二：值太泛、換精確的。</strong> 掃描服務把 ISBN 格式錯誤拋成 <code>validationFailed</code>、把離線與網路錯誤都拋成 <code>serverError</code>，測試期待的是 <code>invalidIsbn</code>、<code>networkError</code>、<code>offlineError</code>。泛化 code 不違反分類不變式、但淹沒語意——下游想對「離線」跟「伺服器壞了」做不同處置時，兩者在錯誤碼層已經不可區分。這是不變式管不到的精度問題，靠測試斷言把精確度釘住。</p>
<p><strong>形態三：約束本身擋住合法需求、改繼承逃離。</strong> <code>ImportException</code> 原本繼承 <code>BusinessException</code>，但匯入流程的錯誤天生橫跨分類——JSON 解析壞（validation）、來源伺服器錯（network）、寫檔失敗（storage）。business 分類裡根本沒有它需要的 code。修法是把父類從 <code>BusinessException</code> 改成 <code>AppException</code>（不綁分類的基類），逃離約束。</p>
<h2 id="形態三是分類學的訊號不是不變式的失敗">形態三是分類學的訊號、不是不變式的失敗</h2>
<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>那種「繞過不變式」是不同的事——這裡的需求是<strong>合法的</strong>：匯入錯誤真的橫跨技術分類。撞牆暴露的是兩條分類軸不正交：</p>
<ul>
<li><code>ErrorCategory</code> 的軸是<strong>技術來源</strong>（網路、儲存、平台）</li>
<li>exception 階層的軸是<strong>業務流程</strong>（匯入、掃描、搜尋）</li>
</ul>
<p>一個業務流程天生會遭遇多種技術來源的錯誤，把業務流程的 exception 綁死在單一技術分類上，約束跟現實的形狀不合。<code>ImportException</code> 改繼承是對這個不合的誠實回應；更徹底的修法是承認兩軸各自獨立——exception 型別按業務流程分、<code>ErrorCategory</code> 作為錯誤的一個屬性自由取值——但那是更大的重構，當下的繼承調整是合比例的處置。</p>
<p>可操作的判準：<strong>不變式被撞的時候，先分「需求違規」還是「約束錯形」</strong>。前者的訊號是繞過方在找便利（copyWith 改狀態、省掉領域方法）；後者的訊號是繞過方有無法被現有約束表達的正當語意（匯入錯誤需要 network code）。前者修繞過方、後者修約束。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>exception 建構失敗、訊息指向分類不匹配——先查分類表有沒有語意等價的正確 code（形態一）、再問這個 exception 是不是真的只屬於一個分類（形態三）</li>
<li>多種不同情境拋同一個泛化 code（<code>serverError</code> 當萬用垃圾桶）——語意精度在流失、下游的分支處置已經寫不出來</li>
<li>「改繼承來讓建構通過」的修法出現——停下來判定是逃生還是約束錯形；是後者就把分類學的不合寫成決策記錄，否則下一個橫跨分類的 exception 會重演一次</li>
<li>錯誤分類只存在於命名慣例（<code>NetworkXxxError</code>）而沒有建構驗證——分類錯亂正在靜默累積、第一個按分類做統計或重試的功能會揭開它</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>不變式強制層次的原則層：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>——本文是「約束做進執行層之後」的下一章：約束會工作、也會被合法需求撞</li>
<li>決策表矛盾的同構：<a href="/blog/report/decision-table-conflict-reveals-missing-dimension/" data-link-title="決策表兩列同時命中且結論相反：缺的是一個上游區分維度" data-link-desc="判讀表 / 決策表的兩列規則被同一個真實案例同時命中、且指向相反結論時、問題通常出在表外：案例承載著兩種身分、而表缺少把身分拆開的上游維度 — 修法是補前置澄清問、把維度抬到表之前；拆不出身分的矛盾才是規則真衝突、回表內改規則。偵測方法是用真實案例 dry-run、不是逐列檢查 — 單列都正確的表仍可能整體矛盾。">#158 決策表兩列同時命中且結論相反：缺的是上游區分維度</a>——分類軸不正交跟決策表缺維度是同一個病：單一分類軸承載不了多維的現實</li>
<li>概念地基：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>——本文是執行層建構不變式與「不變式被撞」兩段的主案例</li>
</ul>
]]></content:encoded></item><item><title>會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換</title><link>https://tarrragon.github.io/blog/work-log/pos_member_pricing_payment_atomic_switch/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/pos_member_pricing_payment_atomic_switch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS 結帳流程有一條業務規則：會員用會員價、且只能用會員資產（wallet）支付；非會員用售價、支付方式排除 wallet。追「結帳中登出會員」的實作時，發現這個切換牽動三個欄位跟一個重算順序
&lt;strong>疑問來源&lt;/strong>：&lt;code>updateMember&lt;/code> 為什麼強制要求呼叫端同時提供新的支付方式、放棄了單純 setter 的簡單介面？
&lt;strong>整理目的&lt;/strong>：記下「被同一條業務規則綁住的多個欄位」的切換設計——原子性、順序、以及不變式該住在哪
&lt;strong>本文邊界&lt;/strong>：素材是一個 Flutter POS App 的結帳 model；GetX 的 Rx 是實作載體、原子切換的推導不綁框架&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="三個欄位一條規則">三個欄位、一條規則&lt;/h2>
&lt;p>結帳 context 的商業規則寫在 class 註解上：&lt;/p>
&lt;blockquote>
&lt;ul>
&lt;li>會員：僅能使用會員資產（wallet），因為計價走 memberPrice&lt;/li>
&lt;li>非會員：所有啟用的支付方式，但排除 wallet&lt;/li>
&lt;li>消費者若要改用其他支付方式，員工需先從結帳頁登出會員，讓計價回到 sellingPrice&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>拆開看，「會員身分」牽動兩個下游：&lt;strong>計價&lt;/strong>（&lt;code>subtotal&lt;/code> 依 &lt;code>hasMember&lt;/code> 對每個品項取 &lt;code>memberPrice&lt;/code> 或 &lt;code>sellingPrice&lt;/code>）跟&lt;strong>可用支付方式&lt;/strong>（會員限 wallet、非會員排除 wallet）。三者被同一條規則綁住：任何一個單獨變動，狀態就進入業務上不存在的組合——例如「身分是會員、支付方式停在現金、計價卻走會員價」。&lt;/p>
&lt;h2 id="分開的-setter-是不一致中間態的製造機">分開的 setter 是不一致中間態的製造機&lt;/h2>
&lt;p>如果 model 提供獨立的 &lt;code>setMember()&lt;/code> 跟 &lt;code>setPaymentMethod()&lt;/code>，正確的切換就要靠每個呼叫端自己記得兩個都呼叫、而且順序對。UI 是響應式的（&lt;code>Obx&lt;/code> 監聽狀態流、副屏透過 &lt;code>stateStream&lt;/code> 同步實收找零），兩次分開的狀態更新之間，那個不一致的中間態會真的被渲染出來、也會真的被推到副屏。&lt;/p>
&lt;p>這個專案的做法是把切換收成一個方法、強制原子性：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">updateMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Member&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">newMember&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="kd">required&lt;/span> &lt;span class="n">PaymentMethod&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">targetPaymentMethod&lt;/span>&lt;span class="p">})&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">resolvedPayment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">targetPaymentMethod&lt;/span> &lt;span class="o">??&lt;/span> &lt;span class="n">_state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">paymentMethod&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">_state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nl">member:&lt;/span> &lt;span class="n">newMember&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nl">memberId:&lt;/span> &lt;span class="n">newMember&lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nl">paymentMethod:&lt;/span> &lt;span class="n">resolvedPayment&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 應付金額依更新後的會員身分重算，故實收金額在 member 寫入後才重設
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">resetInputAmount&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個設計點。第一，&lt;code>targetPaymentMethod&lt;/code> 是 &lt;code>required&lt;/code>——呼叫端無法「只換會員、支付方式以後再說」，簽名本身就把「兩者要一起決定」寫死了（這是把約束做進介面、不是寫在文件請大家記得）。第二，member 跟 paymentMethod 在&lt;strong>同一次&lt;/strong> &lt;code>copyWith&lt;/code> 內寫入，狀態流的訂閱者永遠看不到只換了一半的組合。&lt;/p>
&lt;h2 id="順序敏感衍生值要在來源更新之後重算">順序敏感：衍生值要在來源更新之後重算&lt;/h2>
&lt;p>第三個欄位 &lt;code>inputAmount&lt;/code>（員工輸入的實收金額）的處理暴露了一個容易寫錯的順序問題。應付金額 &lt;code>subtotal&lt;/code> 是衍生值——它依「當下的會員身分」對品項逐一取價。登出會員時實收金額要重設為新的應付金額，而這個重設&lt;strong>必須發生在 member 寫入之後&lt;/strong>：先重設的話，&lt;code>subtotal&lt;/code> 還在用舊身分計價、重設進去的是舊金額。&lt;/p>
&lt;p>程式碼裡那行註解（「應付金額依更新後的會員身分重算，故實收金額在 member 寫入後才重設」）就是在守這個順序。characterization test 把行為釘死：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 會員價 90 × 2 = 180
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">subtotal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s1">&amp;#39;180&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">updateMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">null&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">targetPaymentMethod:&lt;/span> &lt;span class="n">PaymentMethod&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">cash&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 售價 100 × 2 = 200、實收同步重設
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">subtotal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s1">&amp;#39;200&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inputAmount&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s1">&amp;#39;200&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一般化的判讀：&lt;strong>耦合欄位群裡若有衍生值，切換順序是「來源先、衍生後」&lt;/strong>；把重算寫在來源更新的同一個方法尾端（而不是交給呼叫端），順序就不會在某個呼叫點被弄反。&lt;/p>
&lt;h2 id="不變式住在-modelui-只問不拼">不變式住在 model：UI 只問、不拼&lt;/h2>
&lt;p>「能不能結帳」的完整判斷也收在同一個 model 裡：&lt;code>canCheckout&lt;/code> 依序檢查空車、金額足夠（wallet 走餘額檢查、現金走輸入金額比對、第三方支付暫時信任後端）、需要會員的支付方式有沒有會員、需要 consumer token 的有沒有有效 token；&lt;code>cannotCheckoutError&lt;/code> 回傳對應的錯誤碼枚舉供 UI 顯示。外層 UI 的職責縮到最小：&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">canCheckout&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">error&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">cannotCheckoutError&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">Popup&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">exception&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">code:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">message:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">messageKey&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tr&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>判斷邏輯集中的價值在演化時顯現：支付方式的種類會長（這個專案的第三方支付跟信用卡驗證都還標著 todo），每長一種只改 model 的判斷、所有 UI 呼叫點不動。反向的做法——每個結帳按鈕自己拼「空車嗎、錢夠嗎、會員登入了嗎」——會讓每次規則變動都要掃全部呼叫點。&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;code>required&lt;/code> 參數與單次狀態更新是「把約束做進介面」的實例&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>——分開的 setter 就是一條沒關的逃生口&lt;/li>
&lt;li>同專案同 model 的另一個切面：&lt;a href="https://tarrragon.github.io/blog/work-log/pos_table_cart_lifecycle_decoupling/" data-link-title="桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦" data-link-desc="兩個業務資源該綁死成一對一、還是解耦成獨立生命週期加綁定關係——判準是有沒有業務操作需要其中一方獨立存活。以 POS 的提前結帳、純佔桌、外賣單推導桌位與購物車的聚合邊界，含組合空間大於業務空間時的非法組合封鎖。">桌子跟購物車是兩個聚合&lt;/a>——那篇談生命週期解耦、本文談耦合欄位的原子性，一個拆、一個綁，判準都來自業務規則本身&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS 結帳流程有一條業務規則：會員用會員價、且只能用會員資產（wallet）支付；非會員用售價、支付方式排除 wallet。追「結帳中登出會員」的實作時，發現這個切換牽動三個欄位跟一個重算順序
<strong>疑問來源</strong>：<code>updateMember</code> 為什麼強制要求呼叫端同時提供新的支付方式、放棄了單純 setter 的簡單介面？
<strong>整理目的</strong>：記下「被同一條業務規則綁住的多個欄位」的切換設計——原子性、順序、以及不變式該住在哪
<strong>本文邊界</strong>：素材是一個 Flutter POS App 的結帳 model；GetX 的 Rx 是實作載體、原子切換的推導不綁框架</p></blockquote>
<hr>
<h2 id="三個欄位一條規則">三個欄位、一條規則</h2>
<p>結帳 context 的商業規則寫在 class 註解上：</p>
<blockquote>
<ul>
<li>會員：僅能使用會員資產（wallet），因為計價走 memberPrice</li>
<li>非會員：所有啟用的支付方式，但排除 wallet</li>
<li>消費者若要改用其他支付方式，員工需先從結帳頁登出會員，讓計價回到 sellingPrice</li>
</ul></blockquote>
<p>拆開看，「會員身分」牽動兩個下游：<strong>計價</strong>（<code>subtotal</code> 依 <code>hasMember</code> 對每個品項取 <code>memberPrice</code> 或 <code>sellingPrice</code>）跟<strong>可用支付方式</strong>（會員限 wallet、非會員排除 wallet）。三者被同一條規則綁住：任何一個單獨變動，狀態就進入業務上不存在的組合——例如「身分是會員、支付方式停在現金、計價卻走會員價」。</p>
<h2 id="分開的-setter-是不一致中間態的製造機">分開的 setter 是不一致中間態的製造機</h2>
<p>如果 model 提供獨立的 <code>setMember()</code> 跟 <code>setPaymentMethod()</code>，正確的切換就要靠每個呼叫端自己記得兩個都呼叫、而且順序對。UI 是響應式的（<code>Obx</code> 監聽狀態流、副屏透過 <code>stateStream</code> 同步實收找零），兩次分開的狀態更新之間，那個不一致的中間態會真的被渲染出來、也會真的被推到副屏。</p>
<p>這個專案的做法是把切換收成一個方法、強制原子性：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kt">void</span> <span class="n">updateMember</span><span class="p">(</span><span class="n">Member</span><span class="o">?</span> <span class="n">newMember</span><span class="p">,</span> <span class="p">{</span><span class="kd">required</span> <span class="n">PaymentMethod</span><span class="o">?</span> <span class="n">targetPaymentMethod</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">final</span> <span class="n">resolvedPayment</span> <span class="o">=</span> <span class="n">targetPaymentMethod</span> <span class="o">??</span> <span class="n">_state</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">paymentMethod</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">_state</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">_state</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nl">member:</span> <span class="n">newMember</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nl">memberId:</span> <span class="n">newMember</span><span class="o">?</span><span class="p">.</span><span class="n">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">paymentMethod:</span> <span class="n">resolvedPayment</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 應付金額依更新後的會員身分重算，故實收金額在 member 寫入後才重設
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="n">resetInputAmount</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩個設計點。第一，<code>targetPaymentMethod</code> 是 <code>required</code>——呼叫端無法「只換會員、支付方式以後再說」，簽名本身就把「兩者要一起決定」寫死了（這是把約束做進介面、不是寫在文件請大家記得）。第二，member 跟 paymentMethod 在<strong>同一次</strong> <code>copyWith</code> 內寫入，狀態流的訂閱者永遠看不到只換了一半的組合。</p>
<h2 id="順序敏感衍生值要在來源更新之後重算">順序敏感：衍生值要在來源更新之後重算</h2>
<p>第三個欄位 <code>inputAmount</code>（員工輸入的實收金額）的處理暴露了一個容易寫錯的順序問題。應付金額 <code>subtotal</code> 是衍生值——它依「當下的會員身分」對品項逐一取價。登出會員時實收金額要重設為新的應付金額，而這個重設<strong>必須發生在 member 寫入之後</strong>：先重設的話，<code>subtotal</code> 還在用舊身分計價、重設進去的是舊金額。</p>
<p>程式碼裡那行註解（「應付金額依更新後的會員身分重算，故實收金額在 member 寫入後才重設」）就是在守這個順序。characterization test 把行為釘死：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 會員價 90 × 2 = 180
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">expect</span><span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">subtotal</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span> <span class="s1">&#39;180&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">ctx</span><span class="p">.</span><span class="n">updateMember</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="nl">targetPaymentMethod:</span> <span class="n">PaymentMethod</span><span class="p">.</span><span class="n">cash</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 售價 100 × 2 = 200、實收同步重設
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="n">expect</span><span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">subtotal</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span> <span class="s1">&#39;200&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">expect</span><span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">inputAmount</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span> <span class="s1">&#39;200&#39;</span><span class="p">);</span></span></span></code></pre></div><p>一般化的判讀：<strong>耦合欄位群裡若有衍生值，切換順序是「來源先、衍生後」</strong>；把重算寫在來源更新的同一個方法尾端（而不是交給呼叫端），順序就不會在某個呼叫點被弄反。</p>
<h2 id="不變式住在-modelui-只問不拼">不變式住在 model：UI 只問、不拼</h2>
<p>「能不能結帳」的完整判斷也收在同一個 model 裡：<code>canCheckout</code> 依序檢查空車、金額足夠（wallet 走餘額檢查、現金走輸入金額比對、第三方支付暫時信任後端）、需要會員的支付方式有沒有會員、需要 consumer token 的有沒有有效 token；<code>cannotCheckoutError</code> 回傳對應的錯誤碼枚舉供 UI 顯示。外層 UI 的職責縮到最小：</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="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">context</span><span class="p">.</span><span class="n">canCheckout</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">final</span> <span class="n">error</span> <span class="o">=</span> <span class="n">context</span><span class="p">.</span><span class="n">cannotCheckoutError</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">Popup</span><span class="p">.</span><span class="n">exception</span><span class="p">(</span><span class="nl">code:</span> <span class="n">error</span><span class="o">!</span><span class="p">.</span><span class="n">code</span><span class="p">,</span> <span class="nl">message:</span> <span class="n">error</span><span class="p">.</span><span class="n">messageKey</span><span class="p">.</span><span class="n">tr</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>判斷邏輯集中的價值在演化時顯現：支付方式的種類會長（這個專案的第三方支付跟信用卡驗證都還標著 todo），每長一種只改 model 的判斷、所有 UI 呼叫點不動。反向的做法——每個結帳按鈕自己拼「空車嗎、錢夠嗎、會員登入了嗎」——會讓每次規則變動都要掃全部呼叫點。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>概念地基：<a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>——本文的 <code>required</code> 參數與單次狀態更新是「把約束做進介面」的實例</li>
<li>原則層：<a href="/blog/report/design-intent-needs-enforcement-layer/" data-link-title="約束要讓違反路徑走不通：只寫在文件層的設計意圖是沒關的逃生口" data-link-desc="設計 entity 的變更路徑、或審查「請走 X」類慣例時使用。約束有文件、型別、執行三個落點；只落在文件層的意圖對繞過路徑沒有任何阻力，而註解宣稱的約束比沒有約束更糟——讓讀者以為有防護。判準是讓違反意圖的路徑走不通、不是寫文件請大家不要走。">#222 約束要讓違反路徑走不通</a>——分開的 setter 就是一條沒關的逃生口</li>
<li>同專案同 model 的另一個切面：<a href="/blog/work-log/pos_table_cart_lifecycle_decoupling/" data-link-title="桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦" data-link-desc="兩個業務資源該綁死成一對一、還是解耦成獨立生命週期加綁定關係——判準是有沒有業務操作需要其中一方獨立存活。以 POS 的提前結帳、純佔桌、外賣單推導桌位與購物車的聚合邊界，含組合空間大於業務空間時的非法組合封鎖。">桌子跟購物車是兩個聚合</a>——那篇談生命週期解耦、本文談耦合欄位的原子性，一個拆、一個綁，判準都來自業務規則本身</li>
</ul>
]]></content:encoded></item></channel></rss>