觸發場景:整理 POS 專案的金額處理時,發現 git 歷史上金額型別換過兩次——doubleDecimal、再從 Decimal 到自訂的 Money。第二次遷移乍看多餘:精度問題 Decimal 已經解掉了 疑問來源Decimal 哪裡不夠?第二次遷移買到的是什麼? 整理目的:記下「精度」跟「語意」是金額型別的兩個獨立問題、以及 Dart extension type 在第二個問題上的實作手法 本文邊界:以該專案的實際遷移軌跡為素材;extension type 是 Dart 3 的機制、其他語言的對應手法(newtype / value class)思路相同但細節不同


第一段:double 的精度問題

最初所有金額欄位是 double,JSON 轉換器把後端的 number 或 string 統一存成 double。浮點數處理金額的問題是經典的:0.1 + 0.2 != 0.3,累加訂單明細時誤差會累積到分位。

第一次遷移(單一 PR 內兩個 commit)把所有 model 的金額欄位換成 Decimal,API 接入層用 jsonToDecimal 統一處理後端可能回 number 或 string 的格式差異。精度問題到此解決。

第二段:Decimal 解決了精度、沒解決語意

換完 Decimal 之後,金額仍然是一個裸的通用數字型別。任何拿到 Decimal 的程式碼都能對它做任意運算:兩個金額相乘(語意上不存在的運算)、金額跟折扣率直接相加、拿金額當數量用。型別系統對這些錯誤全部放行,因為它們在 Decimal 的世界都是合法運算。

這是 primitive obsession 的標準形態:值的表示對了、值的語意邊界還是沒有。三個月後的第二次遷移把金額包進 Money

1extension type const Money._(Decimal _raw) implements Object {
2  Money operator +(Money other) => Money._(_raw + other._raw);
3  Money operator -(Money other) => Money._(_raw - other._raw);
4  Money operator -() => Money._(-_raw);              // 退款 / 折讓
5  Money operator *(int quantity) => ...;             // 金額 × 數量
6  Money multiplyByRate(Decimal rate) => ...;         // 會員價率、服務費率
7  Money clamp(Money min, Money max) => ...;
8  ...
9}

運算列表本身就是領域規則的宣告:金額加金額可以、金額乘整數(數量)可以、金額乘倍率(Decimal,刻意跟數量分開簽名)可以——金額乘金額不存在,因為介面沒開放。想對 MoneyDecimal 的任意運算,得先顯式呼叫 toDecimal() 拆封,那一行拆封程式碼就是 code review 的攔截點。

implements Object 的取捨:要當 Object、不當 Decimal

extension type 宣告 implements Object 而只有這個,是一個精確的 subtype 決策:

  • Object 的 subtype:既有的格式化入口 formatAmount(Object) 可以直接吃 Money、不用改簽名
  • 不是 Decimal 的 subtype:如果宣告 implements DecimalMoney 就能被傳進任何收 Decimal 的參數、所有裸運算又回來了——包裝等於白做

extension type 在 runtime 是零開銷的(編譯後就是底層的 Decimal),所有約束都活在編譯期。這也意味著它的保護是編譯期的:反射或 dynamic 繞得過去,威脅模型是「防止無心的誤用」而不是「防止刻意拆封」。

遷移安全網:characterization test 先鎖行為

第二次遷移動的是全專案的金額欄位,怎麼確認換型別沒改行為?這個專案在遷移前先寫了一批 characterization test,測試檔開頭直接註明用途:

Characterization test —— 鎖住 CheckoutContext 結帳金額計算的現有行為。在 Money value object 遷移(階段 5)前建立。涵蓋應付金額 fold、現金找零(含負數歸零分支)、金額足夠判斷。

characterization test 跟一般測試的差別在斷言的性質:它不驗證「行為正確」、驗證「行為不變」。遷移前對著舊實作寫、鎖住當前輸出(包含當前的邊界行為,例如找零算出負數時歸零),遷移後全綠就證明型別替換沒有帶入行為變化。正確性是另一個問題、留給另一批測試——把兩個問題混在同一批測試裡,遷移期間的紅燈就分不清是「換壞了」還是「本來就錯」。

收束:兩個問題、兩次遷移

金額型別有兩個獨立的問題,這個專案的軌跡恰好一段解一個:

問題症狀解法
精度浮點誤差累積到分位doubleDecimal
語意任何人都能對金額做任意運算Decimal 包成 Money

第一段遷移完成時「金額用 Decimal」看起來已經是終點,語意問題要等到夠多「拿金額亂算」的路徑存在後才顯形。判讀訊號是:一個領域概念的合法運算集合、明顯小於它底層型別的運算集合時,包一層 domain type 的價值就成立——差集裡的每個運算都是一個等著被誤用的 API。

相關閱讀

  • 概念地基:entity 與 value object 的判準 的語意封閉段(本文是 primitive obsession 到 domain type 的實機案例)
  • 同族判準:copyWith 是逃生口,不是設計——那篇的判準「有沒有不允許任意組合的欄位」在型別層的對應是「有沒有不允許的運算」,兩篇都是把約束從慣例層上移到型別層