<?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>Immutable on Tarragon</title><link>https://tarrragon.github.io/blog/tags/immutable/</link><description>Recent content in Immutable 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/immutable/index.xml" rel="self" type="application/rss+xml"/><item><title>只活在結帳流程裡的領域物件 — ephemeral model 與「Rx 外殼、immutable 內核」</title><link>https://tarrragon.github.io/blog/work-log/flutter_ephemeral_domain_object_rx_immutable/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_ephemeral_domain_object_rx_immutable/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：POS 專案的結帳流程有一批只在結帳期間有意義的狀態——輸入金額、選定的支付方式、掃碼取得的 consumer token、結帳模式。常見的歸宿是散落在 controller 的欄位裡、流程結束後逐一重置
&lt;strong>疑問來源&lt;/strong>：這批狀態在這個專案被收進一個叫 &lt;code>CheckoutContext&lt;/code> 的物件、註解明寫「結帳完成或取消後，此物件即被丟棄」——為什麼要為短命狀態建一個正式的 model？
&lt;strong>整理目的&lt;/strong>：記下 ephemeral 領域物件的建模價值、以及「Rx 外殼 + immutable 內核」這個 GetX 生態下的狀態設計形態
&lt;strong>本文邊界&lt;/strong>：素材是該專案現行的 &lt;code>CheckoutContext&lt;/code> 實作；Rx 是 GetX 的機制、「reactive 外殼包 immutable 內核」的形態在其他狀態方案（ValueNotifier、signal）同樣成立&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼短命狀態值得一個正式的-model">為什麼短命狀態值得一個正式的 model&lt;/h2>
&lt;p>「結帳流程」是一個有明確開始與結束的業務概念，它期間的狀態既不屬於任何 entity（輸入到一半的收款金額不是訂單的屬性）、也不該活得比流程久。散落在 controller 欄位的版本有兩個慢性病：狀態的邊界看不見（哪些欄位屬於「這次結帳」、哪些是頁面的，全靠記憶），以及&lt;strong>流程結束後的重置靠人工逐欄清&lt;/strong>——同專案家族裡&lt;a href="https://tarrragon.github.io/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset&lt;/a> 那類 bug 的溫床。&lt;/p>
&lt;p>ephemeral 物件把兩個問題一次解掉：進入結帳時 &lt;code>CheckoutContext.create(...)&lt;/code> 建新物件、結完或取消即丟棄引用。&lt;strong>丟棄就是重置&lt;/strong>——下次結帳拿到的是全新物件，「殘留狀態」在結構上不存在，欄位加再多也不會多出一條「記得清它」的義務。生命週期的語意也直接寫在型別上：看到 &lt;code>CheckoutContext&lt;/code> 就知道這批狀態活多久、誰擁有它。&lt;/p>
&lt;p>建構工廠同時表達了業務情境的分岔：&lt;code>create(cart: ...)&lt;/code> 從遠端購物車建（餐廳模式、有桌位與掛單）、&lt;code>createFromLocalCart(items: ...)&lt;/code> 從本地品項建（零售模式、無遠端 cart）——兩種模式的差異被收在建構路徑、流程中的其餘程式碼對此無感。&lt;/p>
&lt;h2 id="rx-外殼immutable-內核">Rx 外殼、immutable 內核&lt;/h2>
&lt;p>實作形態是兩層各司其職：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">CheckoutContext&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">Rx&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">_CheckoutState&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_state&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// reactive 外殼：私有
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">Money&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">subtotal&lt;/span> &lt;span class="o">=&amp;gt;&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">subtotal&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 讀：委託 getter
&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="kt">bool&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">canCheckout&lt;/span> &lt;span class="o">=&amp;gt;&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">canCheckout&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kt">void&lt;/span> &lt;span class="n">updateInputAmount&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Money&lt;/span> &lt;span class="n">amount&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// 寫：語意化方法
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="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 class="nl">inputAmount:&lt;/span> &lt;span class="n">amount&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">_CheckoutState&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="c1">// immutable 內核：私有 class
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">Money&lt;/span> &lt;span class="n">inputAmount&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">PaymentMethod&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">15&lt;/span>&lt;span class="cl"> &lt;span class="p">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="n">_CheckoutState&lt;/span> &lt;span class="n">copyWith&lt;/span>&lt;span class="p">({...})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_CheckoutState&lt;/span>&lt;span class="p">(...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&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;strong>immutable 內核&lt;/strong>保證每次變更是一次原子的整體替換——&lt;code>_state.value = old.copyWith(...)&lt;/code>，訂閱者永遠看到一致的快照、不會撞見改到一半的狀態（&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;strong>Rx 外殼&lt;/strong>提供響應性——UI 用 &lt;code>Obx(() =&amp;gt; Text('應付: ${context.subtotal}'))&lt;/code> 自動跟著變、副屏透過 &lt;code>stateStream&lt;/code> 訂閱同一個狀態流同步實收與找零。&lt;/p>
&lt;p>同樣重要的是&lt;strong>沒有開放的東西&lt;/strong>：&lt;code>_state&lt;/code> 私有、&lt;code>_CheckoutState&lt;/code> 私有，外部拿不到 Rx 也拿不到內核——變更的唯一入口是 &lt;code>updateInputAmount&lt;/code> 這批語意化方法。對照直接暴露 &lt;code>Rx&amp;lt;CheckoutState&amp;gt;&lt;/code> 讓呼叫端自己 &lt;code>.value = ...&lt;/code> 的寫法：那會把「哪些變更是合法的」交還給每個呼叫端。這是把逃生口關掉的狀態管理版。&lt;/p>
&lt;h2 id="不變式住在內核錯誤是結構化的">不變式住在內核、錯誤是結構化的&lt;/h2>
&lt;p>「能不能結帳」的判斷收在內核的 &lt;code>canCheckout&lt;/code> / &lt;code>cannotCheckoutError&lt;/code>，後者回傳的不是布林也不是字串、是 enum：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">enum&lt;/span> &lt;span class="n">CheckoutErrorCode&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">ErrorCode&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="n">cartEmpty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;CK001&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;shopping_cart_empty&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">balanceInsufficient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;CK002&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;balance_insufficient&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">memberNotLogin&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;CK004&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;member_not_login&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">consumerTokenMissing&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;CK007&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;checkout_error_consumer_token_missing&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="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;/code>&lt;/pre>&lt;/div>&lt;p>每個錯誤碼帶穩定代碼（CK001）跟翻譯 key——UI 拿到直接 &lt;code>error.messageKey.tr&lt;/code> 顯示、log 記代碼可追。這是&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_domain_layer_i18n_hardcoded_text/" data-link-title="Domain 層的 947 處硬編碼中文 — 訊息代碼跟顯示文字的分層責任" data-link-desc="domain 層的 enum 或 getter 直接回傳 UI 顯示字串時，多語言支援與分層原則同時失守。修法是 domain 只回訊息代碼與結構化資料、UI 層用 translator extension 翻譯；遷移排序從最小模組先行驗證模式。適用於盤點「這個字串屬於領域事實還是呈現」的情境。">Domain 層硬編碼中文&lt;/a>那篇分層原則的正面實作：model 回代碼、呈現層翻譯，而且從第一天就長對、不用事後遷移 947 處。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>controller 裡有一批欄位在某個流程結束時要「記得全部重置」——ephemeral 物件的候選，丟棄比重置可靠&lt;/li>
&lt;li>流程狀態的擁有者不明（頁面退出時該不該清？跨頁要不要帶？）——生命週期沒有型別化，先回答「這批狀態跟哪個業務流程同壽命」&lt;/li>
&lt;li>reactive 狀態直接暴露可寫的 &lt;code>.value&lt;/code> / &lt;code>.state&lt;/code> 給呼叫端——變更入口失控的起點，收斂成語意化方法&lt;/li>
&lt;li>狀態變更方法一次只改一個欄位、耦合欄位靠呼叫端連續呼叫——中間態會被訂閱者看到，改成單次 copyWith 的原子替換&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同一個 model 的另外兩個切面：&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;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;li>被結構性消滅的 bug 家族：&lt;a href="https://tarrragon.github.io/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset&lt;/a>——人工重置清單 vs 丟棄即重置&lt;/li>
&lt;li>概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南&lt;/a>——entity 生命週期章節、ephemeral 物件是「生命週期由業務流程定義」的極短端&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：POS 專案的結帳流程有一批只在結帳期間有意義的狀態——輸入金額、選定的支付方式、掃碼取得的 consumer token、結帳模式。常見的歸宿是散落在 controller 的欄位裡、流程結束後逐一重置
<strong>疑問來源</strong>：這批狀態在這個專案被收進一個叫 <code>CheckoutContext</code> 的物件、註解明寫「結帳完成或取消後，此物件即被丟棄」——為什麼要為短命狀態建一個正式的 model？
<strong>整理目的</strong>：記下 ephemeral 領域物件的建模價值、以及「Rx 外殼 + immutable 內核」這個 GetX 生態下的狀態設計形態
<strong>本文邊界</strong>：素材是該專案現行的 <code>CheckoutContext</code> 實作；Rx 是 GetX 的機制、「reactive 外殼包 immutable 內核」的形態在其他狀態方案（ValueNotifier、signal）同樣成立</p></blockquote>
<hr>
<h2 id="為什麼短命狀態值得一個正式的-model">為什麼短命狀態值得一個正式的 model</h2>
<p>「結帳流程」是一個有明確開始與結束的業務概念，它期間的狀態既不屬於任何 entity（輸入到一半的收款金額不是訂單的屬性）、也不該活得比流程久。散落在 controller 欄位的版本有兩個慢性病：狀態的邊界看不見（哪些欄位屬於「這次結帳」、哪些是頁面的，全靠記憶），以及<strong>流程結束後的重置靠人工逐欄清</strong>——同專案家族裡<a href="/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset</a> 那類 bug 的溫床。</p>
<p>ephemeral 物件把兩個問題一次解掉：進入結帳時 <code>CheckoutContext.create(...)</code> 建新物件、結完或取消即丟棄引用。<strong>丟棄就是重置</strong>——下次結帳拿到的是全新物件，「殘留狀態」在結構上不存在，欄位加再多也不會多出一條「記得清它」的義務。生命週期的語意也直接寫在型別上：看到 <code>CheckoutContext</code> 就知道這批狀態活多久、誰擁有它。</p>
<p>建構工廠同時表達了業務情境的分岔：<code>create(cart: ...)</code> 從遠端購物車建（餐廳模式、有桌位與掛單）、<code>createFromLocalCart(items: ...)</code> 從本地品項建（零售模式、無遠端 cart）——兩種模式的差異被收在建構路徑、流程中的其餘程式碼對此無感。</p>
<h2 id="rx-外殼immutable-內核">Rx 外殼、immutable 內核</h2>
<p>實作形態是兩層各司其職：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">CheckoutContext</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">Rx</span><span class="o">&lt;</span><span class="n">_CheckoutState</span><span class="o">&gt;</span> <span class="n">_state</span><span class="p">;</span>          <span class="c1">// reactive 外殼：私有
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">Money</span> <span class="kd">get</span> <span class="n">subtotal</span> <span class="o">=&gt;</span> <span class="n">_state</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">subtotal</span><span class="p">;</span>      <span class="c1">// 讀：委託 getter
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="kt">bool</span> <span class="kd">get</span> <span class="n">canCheckout</span> <span class="o">=&gt;</span> <span class="n">_state</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">canCheckout</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kt">void</span> <span class="n">updateInputAmount</span><span class="p">(</span><span class="n">Money</span> <span class="n">amount</span><span class="p">)</span> <span class="p">{</span>             <span class="c1">// 寫：語意化方法
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>    <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 class="nl">inputAmount:</span> <span class="n">amount</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">class</span> <span class="nc">_CheckoutState</span> <span class="p">{</span>                       <span class="c1">// immutable 內核：私有 class
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">Money</span> <span class="n">inputAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">final</span> <span class="n">PaymentMethod</span> <span class="n">paymentMethod</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="n">_CheckoutState</span> <span class="n">copyWith</span><span class="p">({...})</span> <span class="o">=&gt;</span> <span class="n">_CheckoutState</span><span class="p">(...);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>分工是：<strong>immutable 內核</strong>保證每次變更是一次原子的整體替換——<code>_state.value = old.copyWith(...)</code>，訂閱者永遠看到一致的快照、不會撞見改到一半的狀態（<a href="/blog/work-log/pos_member_pricing_payment_atomic_switch/" data-link-title="會員身分、計價、支付方式必須一起換 — 耦合欄位的原子切換" data-link-desc="多個狀態欄位被同一條業務規則綁住時，分開的 setter 會製造不一致的中間態；把切換收成單一方法、一次狀態更新內同步全部欄位，並注意衍生值重算的順序。以 POS 結帳的會員登出重算為例，含不變式收進 model 的 canCheckout 設計。">會員/計價/支付的原子切換</a>就是靠這層保證成立的）。<strong>Rx 外殼</strong>提供響應性——UI 用 <code>Obx(() =&gt; Text('應付: ${context.subtotal}'))</code> 自動跟著變、副屏透過 <code>stateStream</code> 訂閱同一個狀態流同步實收與找零。</p>
<p>同樣重要的是<strong>沒有開放的東西</strong>：<code>_state</code> 私有、<code>_CheckoutState</code> 私有，外部拿不到 Rx 也拿不到內核——變更的唯一入口是 <code>updateInputAmount</code> 這批語意化方法。對照直接暴露 <code>Rx&lt;CheckoutState&gt;</code> 讓呼叫端自己 <code>.value = ...</code> 的寫法：那會把「哪些變更是合法的」交還給每個呼叫端。這是把逃生口關掉的狀態管理版。</p>
<h2 id="不變式住在內核錯誤是結構化的">不變式住在內核、錯誤是結構化的</h2>
<p>「能不能結帳」的判斷收在內核的 <code>canCheckout</code> / <code>cannotCheckoutError</code>，後者回傳的不是布林也不是字串、是 enum：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">enum</span> <span class="n">CheckoutErrorCode</span> <span class="kd">implements</span> <span class="n">ErrorCode</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">cartEmpty</span><span class="p">(</span><span class="s1">&#39;CK001&#39;</span><span class="p">,</span> <span class="s1">&#39;shopping_cart_empty&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">balanceInsufficient</span><span class="p">(</span><span class="s1">&#39;CK002&#39;</span><span class="p">,</span> <span class="s1">&#39;balance_insufficient&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">memberNotLogin</span><span class="p">(</span><span class="s1">&#39;CK004&#39;</span><span class="p">,</span> <span class="s1">&#39;member_not_login&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">consumerTokenMissing</span><span class="p">(</span><span class="s1">&#39;CK007&#39;</span><span class="p">,</span> <span class="s1">&#39;checkout_error_consumer_token_missing&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">...</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每個錯誤碼帶穩定代碼（CK001）跟翻譯 key——UI 拿到直接 <code>error.messageKey.tr</code> 顯示、log 記代碼可追。這是<a href="/blog/work-log/flutter_domain_layer_i18n_hardcoded_text/" data-link-title="Domain 層的 947 處硬編碼中文 — 訊息代碼跟顯示文字的分層責任" data-link-desc="domain 層的 enum 或 getter 直接回傳 UI 顯示字串時，多語言支援與分層原則同時失守。修法是 domain 只回訊息代碼與結構化資料、UI 層用 translator extension 翻譯；遷移排序從最小模組先行驗證模式。適用於盤點「這個字串屬於領域事實還是呈現」的情境。">Domain 層硬編碼中文</a>那篇分層原則的正面實作：model 回代碼、呈現層翻譯，而且從第一天就長對、不用事後遷移 947 處。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>controller 裡有一批欄位在某個流程結束時要「記得全部重置」——ephemeral 物件的候選，丟棄比重置可靠</li>
<li>流程狀態的擁有者不明（頁面退出時該不該清？跨頁要不要帶？）——生命週期沒有型別化，先回答「這批狀態跟哪個業務流程同壽命」</li>
<li>reactive 狀態直接暴露可寫的 <code>.value</code> / <code>.state</code> 給呼叫端——變更入口失控的起點，收斂成語意化方法</li>
<li>狀態變更方法一次只改一個欄位、耦合欄位靠呼叫端連續呼叫——中間態會被訂閱者看到，改成單次 copyWith 的原子替換</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同一個 model 的另外兩個切面：<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/pos_table_cart_lifecycle_decoupling/" data-link-title="桌子跟購物車是兩個聚合 — 從「提前結帳」推導生命週期解耦" data-link-desc="兩個業務資源該綁死成一對一、還是解耦成獨立生命週期加綁定關係——判準是有沒有業務操作需要其中一方獨立存活。以 POS 的提前結帳、純佔桌、外賣單推導桌位與購物車的聚合邊界，含組合空間大於業務空間時的非法組合封鎖。">桌子跟購物車是兩個聚合</a>（結帳模式兩布林的組合空間）</li>
<li>被結構性消滅的 bug 家族：<a href="/blog/work-log/reset_state_leak_cross_test/" data-link-title="新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因" data-link-desc="測試結果取決於執行順序、看似功能 bug 實為上一個 test case 狀態沒清乾淨。根因是新增 private 欄位時沒同步更新 reset，隱含契約沒被顯性化。">新增欄位忘記同步 reset</a>——人工重置清單 vs 丟棄即重置</li>
<li>概念地基：<a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 領域驅動設計指南</a>——entity 生命週期章節、ephemeral 物件是「生命週期由業務流程定義」的極短端</li>
</ul>
]]></content:encoded></item></channel></rss>