entity 與 value object 的判準
型別判定為領域模型之後(判定方式見 資料袋與領域模型),下一個決策是身份語意:這個概念的「同一個」由什麼定義。身份語意決定業務規則作用在什麼對象上——判錯的後果直接撞上模組的源頭句「把業務規則放進領域模型、讓違反規則的路徑走不通」:規則以為自己守住了「那一筆」,實際作用在「內容相同的隨便一筆」上,違反規則的路徑照樣走得通。
同一性是兩者的分界
entity 的同一性由身份定義:欄位可以全部改變、只要身份參照不變就是同一個;兩個欄位完全相同的 entity 仍然是兩個。value object 的同一性由內容定義:內容相等就是同一個,替換一個內容相同的實例對系統沒有任何影響。這條分界推導出兩者相反的設計形狀——entity 有生命週期、狀態沿業務流程演進、變更要有路徑;value object 不可變、要「改」就是造一個新值換上去。
相等性定義本身可以承載業務規則。一個 POS 專案的購物車品項用內容比對判定同一項、而折扣參與比對——手動改過價的品項被視為獨立的訂單行,合併購物車時只有規格、折扣、口味全部相同的品項才累加數量。「什麼算同一個」在這裡是業務決策寫進相等性定義的例子,而這正是 value object 的表達力所在:同一性規則集中在一個定義裡、所有比對點共用。
判準:操作需不需要 identity-based 定位
判準是對這個物件的操作、需不需要精確指到某一個實體——概念重不重要、有沒有 id 欄位可以填,都不參與判斷。上述 POS 專案把這條判準踩出完整的階段軌跡:點餐階段的品項操作是「加一份」「換口味」,內容相等就是同一個、value object 的內容比對足夠;品項被掛單系統接受後獲得後端身份,操作變成「取消那一筆」「改那一筆的量」——同商品同口味的三筆明細內容完全相同,取消其中一筆時內容比對無法指定是哪一筆,此刻模型必須升級成持有身份參照的形態(同一個品項、四個 model)。
操作形態對應三種模型選擇:
- 操作以內容為對象(累加、合併、比對、替換)——value object,內容相等性就是全部所需。
- 操作要指到特定實體(取消那一筆、改那一筆的回寫,或讀取側的關聯、去重、生命週期追蹤)——entity,或至少是持有身份參照的包裝。
- 操作只剩查閱與退貨這類對既成事實的處置——live 內容參照凍結成 snapshot、身份保留作退貨與取消的鍵,見下一節。
判準的常見誤用是拿概念重要性代替操作分析:「訂單很重要所以是 entity」推不出正確結論,訂單行在輸入階段就是純內容比對;反方向「它有 id 欄位所以是 entity」同樣失效,id 可以只是序列化需要的欄位、與同一性判定無關。判準的作用對象是操作清單,操作清單來自業務流程——這也是為什麼身份語意的判定要等操作盤點之後才能做。
判準隨生命週期重問
同一個業務概念的身份語意會在生命週期的轉折點改變,每個轉折點都要重問一次判準。上述品項模型的完整軌跡是四個模型接力:純需求描述(無 id、內容比對)、後端實體(後端 id)、訂單行(把多筆後端明細收攏成一行、持有它們的身份集合)、歷史明細(id 加全欄位 snapshot)。每一次交棒都對應身份狀態的真實變化,四個模型是身份語意在三個轉折點上改變的結果、而不是重複建模。
概念成為歷史事實之後,live 內容參照要凍結、身份繼續承重。歷史訂單明細保存下單當時的商品與價格 snapshot——商品後續改名、下架、調價,訂單仍顯示當時購買的內容;身份參照在這個階段轉而承擔退貨與取消操作的鍵。凍結時機的判準是業務對「過去」的要求:歷史記錄反映事件發生當下的世界,持有 live 參照的歷史會跟著現在的資料漂移。反過來,還在進行中的購物車品項持有 live 參照是正確的——會員身分改變、價格即時跟著變,這是進行中狀態的業務需求。同一個「參照要不要凍結」的問題,答案由生命週期階段決定。
單一模型通吃全生命週期的代價在每個階段各自浮現:改量操作靠內容比對會誤中同內容的其他筆、歷史訂單持 live 參照會跟著商品改名漂移。拆分自己也有帳要付——層間 mapping、交棒處的同步成本、模型數量的認知負擔;轉折點少、各階段操作集合幾乎重合的概念,單一模型加階段旗標反而便宜。四個模型是這個 domain 有三個真實轉折點的結果、而不是通用配方——模型的邊界跟著身份語意的轉折點切,每段模型只服務自己階段的操作。
value object 的價值:語意封閉
value object 的第二個價值獨立於同一性判定(也獨立於容器型別的類別判定——資料袋裡照樣可以放語意封閉的欄位型別):把一個領域概念的合法運算限縮成封閉集合。判讀訊號是一個領域概念的合法運算集合、明顯小於它底層型別的運算集合——差集裡的每個運算都是一個等著被誤用的 API。金額是標準案例:底層數字型別開放任意四則運算,但「金額乘金額」在領域裡沒有意義、「金額加折扣率」是單位錯誤;同一個 POS 專案把金額換成高精度數字型別之後、這些誤用仍然全部放行,第二次遷移把金額包成 Money 型別、開放的運算限於領域有意義的集合(金額加減、乘數量、乘倍率、退款的負號)——運算列表本身就是領域規則的宣告(金額型別的三段遷移)。
這個案例同時標出兩個常被混淆的獨立問題:精度(浮點誤差)換底層型別就解決、語意(任意運算全放行)要包 domain type 才解決。解掉第一個問題的當下、第二個問題還完整存在,而它要等夠多誤用路徑累積後才顯形。判準操作化:盤點概念的合法運算清單、跟底層型別的運算集合做差集;差集非空、且裸型別跨模組邊界流動(或差集裡的誤用已經實際發生過一次),包一層的價值就成立。這層封閉防的是無心誤用;刻意拆封仍然可行,攔截點是拆封處的 code review,型別層防護的完整邊界見 不變式的強制層次。
枚舉也是 value object 建模
分類值是 value object 的一種、同樣適用建模判準,而枚舉最常見的設計錯誤是粒度:分類系統的粒度是消費者的屬性、不是分類系統自己的屬性。同一個 POS 專案的支付方式有兩類消費者、粒度需求相反:序列化要無損對齊後端的完整列舉(對帳時兩筆記錄一筆支付寶一筆微信、壓成同一類就回不去了)、UI 行為分流只有少數真正的分歧(要不要找零、限不限會員)。單一枚舉選哪個粒度都犧牲一方,解法是分層——保真層無損對齊後端、行為層歸併成行為真正分歧的大類、層間用 exhaustive switch 衍生:「忘記決定新渠道歸哪類」這條違反路徑在編譯期就走不通(16 種支付渠道、4 種行為分類)。
分層的判斷方式是列消費者:消費者一種、單一枚舉足夠;消費者多種且粒度需求不同、每個消費者一層,層的粒度是「這個消費者眼中真正有分歧的數量」。粒度選錯的訊號是例外註解與重複開始增生——粗粒度層長出「有些成員其實……」的例外說明、細粒度層的行為謂詞大半是複製貼上。另一個相鄰但不同的病要區分開:多個正交的分類軸被壓進同一個枚舉(狀態、格式、來源混裝)——那是拆軸問題、分層救不了,訊號同樣是例外增生、但修法是先把軸分開。
判讀訊號
- 改量、取消、退貨這類操作用內容比對定位對象——同內容的其他實體會被誤中,操作清單已經要求 identity-based 回寫、模型該升級。
- 歷史記錄的顯示內容跟著現行資料變動(商品改名、訂單明細跟著變),是參照凍結時機漏掉的訊號:成為事實的資料要 snapshot。
- 一個領域概念以裸的通用型別跨模組流通(金額是 double、識別碼是 string)、而它的合法運算遠少於底層型別——語意封閉的價值已成立,包 domain type。
- 枚舉的行為謂詞大量重複、或某一類長出「有些成員例外」的註解:粒度或軸的選擇跟消費者需求不合,先列消費者清單再決定分層或拆軸。
函數式生態(Haskell、Elixir、F#)的對應形態不同但判準相同:entity 的同一性用 opaque type handle + 函數操作替代 mutable state + method,value object 用 newtype / smart constructor 確保合法值只能從受控管道建出。載體從 class 換成 module visibility 和 type wrapper,「操作需不需要 identity-based 定位」這條判準不變。
下一步
- 身份與規則就位之後,規則的落點:不變式的強制層次
- 變更路徑收斂與稽核凍結:狀態轉換與稽核軌跡
- 型別類別的入口判準:資料袋與領域模型
- Dart 的實作層整合(三種載體的選型判準、遷移安全網、取值出口設計):值物件的 Dart 實作路徑;個別 case 細節:金額型別的三段遷移、16 種支付渠道、4 種行為分類