觸發場景:POS 專案的結帳流程有一批只在結帳期間有意義的狀態——輸入金額、選定的支付方式、掃碼取得的 consumer token、結帳模式。常見的歸宿是散落在 controller 的欄位裡、流程結束後逐一重置 疑問來源:這批狀態在這個專案被收進一個叫 CheckoutContext 的物件、註解明寫「結帳完成或取消後,此物件即被丟棄」——為什麼要為短命狀態建一個正式的 model? 整理目的:記下 ephemeral 領域物件的建模價值、以及「Rx 外殼 + immutable 內核」這個 GetX 生態下的狀態設計形態 本文邊界:素材是該專案現行的 CheckoutContext 實作;Rx 是 GetX 的機制、「reactive 外殼包 immutable 內核」的形態在其他狀態方案(ValueNotifier、signal)同樣成立


為什麼短命狀態值得一個正式的 model

「結帳流程」是一個有明確開始與結束的業務概念,它期間的狀態既不屬於任何 entity(輸入到一半的收款金額不是訂單的屬性)、也不該活得比流程久。散落在 controller 欄位的版本有兩個慢性病:狀態的邊界看不見(哪些欄位屬於「這次結帳」、哪些是頁面的,全靠記憶),以及流程結束後的重置靠人工逐欄清——同專案家族裡新增欄位忘記同步 reset 那類 bug 的溫床。

ephemeral 物件把兩個問題一次解掉:進入結帳時 CheckoutContext.create(...) 建新物件、結完或取消即丟棄引用。丟棄就是重置——下次結帳拿到的是全新物件,「殘留狀態」在結構上不存在,欄位加再多也不會多出一條「記得清它」的義務。生命週期的語意也直接寫在型別上:看到 CheckoutContext 就知道這批狀態活多久、誰擁有它。

建構工廠同時表達了業務情境的分岔:create(cart: ...) 從遠端購物車建(餐廳模式、有桌位與掛單)、createFromLocalCart(items: ...) 從本地品項建(零售模式、無遠端 cart)——兩種模式的差異被收在建構路徑、流程中的其餘程式碼對此無感。

Rx 外殼、immutable 內核

實作形態是兩層各司其職:

 1class CheckoutContext {
 2  final Rx<_CheckoutState> _state;          // reactive 外殼:私有
 3
 4  Money get subtotal => _state.value.subtotal;      // 讀:委託 getter
 5  bool get canCheckout => _state.value.canCheckout;
 6
 7  void updateInputAmount(Money amount) {             // 寫:語意化方法
 8    _state.value = _state.value.copyWith(inputAmount: amount);
 9  }
10}
11
12class _CheckoutState {                       // immutable 內核:私有 class
13  final Money inputAmount;
14  final PaymentMethod paymentMethod;
15  ...
16  _CheckoutState copyWith({...}) => _CheckoutState(...);
17}

分工是:immutable 內核保證每次變更是一次原子的整體替換——_state.value = old.copyWith(...),訂閱者永遠看到一致的快照、不會撞見改到一半的狀態(會員/計價/支付的原子切換就是靠這層保證成立的)。Rx 外殼提供響應性——UI 用 Obx(() => Text('應付: ${context.subtotal}')) 自動跟著變、副屏透過 stateStream 訂閱同一個狀態流同步實收與找零。

同樣重要的是沒有開放的東西_state 私有、_CheckoutState 私有,外部拿不到 Rx 也拿不到內核——變更的唯一入口是 updateInputAmount 這批語意化方法。對照直接暴露 Rx<CheckoutState> 讓呼叫端自己 .value = ... 的寫法:那會把「哪些變更是合法的」交還給每個呼叫端。這是把逃生口關掉的狀態管理版。

不變式住在內核、錯誤是結構化的

「能不能結帳」的判斷收在內核的 canCheckout / cannotCheckoutError,後者回傳的不是布林也不是字串、是 enum:

1enum CheckoutErrorCode implements ErrorCode {
2  cartEmpty('CK001', 'shopping_cart_empty'),
3  balanceInsufficient('CK002', 'balance_insufficient'),
4  memberNotLogin('CK004', 'member_not_login'),
5  consumerTokenMissing('CK007', 'checkout_error_consumer_token_missing'),
6  ...
7}

每個錯誤碼帶穩定代碼(CK001)跟翻譯 key——UI 拿到直接 error.messageKey.tr 顯示、log 記代碼可追。這是Domain 層硬編碼中文那篇分層原則的正面實作:model 回代碼、呈現層翻譯,而且從第一天就長對、不用事後遷移 947 處。

判讀徵兆

  • controller 裡有一批欄位在某個流程結束時要「記得全部重置」——ephemeral 物件的候選,丟棄比重置可靠
  • 流程狀態的擁有者不明(頁面退出時該不該清?跨頁要不要帶?)——生命週期沒有型別化,先回答「這批狀態跟哪個業務流程同壽命」
  • reactive 狀態直接暴露可寫的 .value / .state 給呼叫端——變更入口失控的起點,收斂成語意化方法
  • 狀態變更方法一次只改一個欄位、耦合欄位靠呼叫端連續呼叫——中間態會被訂閱者看到,改成單次 copyWith 的原子替換

相關閱讀