DDD 的源頭精神是把業務規則放進領域模型、讓違反規則的路徑走不通。這句話隱含一個前置判斷:眼前這個型別有沒有業務規則要承擔。有規則要強制的型別、才值得領域模型的設計投資;欄位之間互不約束的型別、一袋欄位就是正確形態。本章建立這條分界——它是本模組其餘判準的入口:先判定型別的類別、後續的 entity 判準與不變式層次才有作用對象。

兩種型別各自承擔什麼

資料袋承擔資料的搬運與呈現:DTO、API model、UI state、設定物件都屬於這一類。它的特徵是任何欄位組合都是合法狀態——修改其中一欄、其餘欄位的意義照舊成立。因為組合全部合法,全開放的建構子、逐欄位覆寫工具(copyWith、setter、builder)在這裡語意清晰、沒有代價;各語言生態替 data class 自動生成這些工具,正是建立在「組合全部合法」的前提上。

領域模型承擔業務規則的強制。它的特徵是存在業務上根本不成立的欄位組合、或必須沿特定路徑發生的變更:狀態欄位只能照業務流程轉換、每次變更要留下稽核紀錄、某幾個欄位被同一條規則綁住必須一起換。這些規則就是不變式——在物件整個生命週期都必須為真的條件。領域模型的介面圍繞規則設計:變更收斂成有業務意圖的方法、建構路徑保證出生即合法。

面向資料袋領域模型
合法狀態任何欄位組合部分組合在業務上不存在
變更方式逐欄位覆寫、語意即「換值」有意圖的領域方法、語意是業務事件
相配工具建構子全開放、copyWith、setter收斂的建構路徑、方法內部檢查

表格的三個面向是同一件事的三個投影:合法狀態的形狀決定變更方式、變更方式決定工具的開放程度。判斷時從第一列進入——先確認「有沒有不合法的組合」,工具選擇是推論結果、順序反過來(先選了工具再回頭定規則)就會走進下一節的事故。

判準:有沒有不允許任意組合的欄位

分界的可操作判準是一個問題:這個型別有沒有「不允許任意組合的欄位」。一個書籍管理 App 的 Book 把兩種形狀疊在同一個型別上:它帶著一組有意圖的狀態轉換方法(開始豐富化、完成豐富化、標記可用),每個方法往稽核欄位追加一筆變更紀錄——這是典型的領域模型形狀;但它同時掛著一個 public 的、參數列包含狀態與稽核欄位的 copyWith,事後追查發現工廠層直接用 copyWith 改狀態、繞過了領域方法(copyWith 是逃生口,不是設計)。

判準的答案對應到行動:

  • 型別存在不允許任意組合的欄位(狀態、稽核紀錄、被規則綁住的欄位群),而且這些欄位會被變更——寫入路徑要收斂到領域方法,逐欄位覆寫工具對它們關閉。
  • 欄位有約束但沒有變更需求(設定物件的交叉約束、唯讀投影)——收斂到建構路徑就足夠:不可變加建構期驗證、零變更方法,領域方法是變更存在時才需要的載體。
  • 型別的欄位組合全部合法——資料袋,全開放工具正當,加上領域模型的儀式(工廠、私有建構、變更方法)只會製造沒有規則可守的 boilerplate。

這條判準也有沉默的地方。它回答「現在有沒有規則」,對「未來會長出什麼規則」沉默——規則還沒到場的型別照資料袋處理,升級時機由本章末段的演化訊號決定。它判定的對象是容器:欄位自身該不該包成 domain type 是另一條正交的軸,資料袋裡照樣可以放 Money 這類語意封閉的欄位型別。規則若以相等性定義或運算集合的形式存在、而不是以欄位組合的形式存在,這條判準同樣看不見——兩者都屬身份語意的範圍,判準見 entity 與 value object 的判準

判準用錯的代價:規則退化成建議

領域模型掛上資料袋的全開放工具之後,業務規則就從唯一路徑退化成建議路徑。上述專案的三個實證按層次排開:工廠層用 copyWith 直接改狀態,對應的狀態轉換沒有進入稽核紀錄——稽核軌跡出洞、而且是靜默的,沒有任何錯誤或測試失敗會揭露它;狀態轉換方法的註解宣稱了轉換約束、實作裡沒有任何對應檢查(文件層約束的失效機制在 不變式的強制層次 展開);最後連測試作者都把 copyWith 當成業務入口、期待它留下稽核痕跡——兩條路徑(工具方法沒有紀錄、業務方法有紀錄)並存在同一個 public 介面上,每個使用者都要自己記得哪條是哪條。

執行者會換人、時間會沖淡記憶、便利工具讓繞行毫無阻力——依靠記憶的規則遲早失守,失守的形式是靜默的資料異常、隔著幾層在別處浮現。判準用錯的方向有兩個、代價形狀相反:領域模型配資料袋工具,讓違反規則的路徑走得通;資料袋配領域模型儀式,則是在沒有規則的地方築牆、每次改欄位都要穿過沒有規則可守的方法層。

分界隨生命週期移動

資料袋或領域模型的判定作用在概念的一個生命週期階段,而不是概念本身。一個 POS 專案的品項模型把同一個概念沿生命週期換了類別:點餐輸入階段的購物車品項是純需求描述、連 id 欄位都沒有,兩個品項是否同一項靠內容比對——形態上接近資料袋、只多一個相等性定義;而相等性定義開始承載業務決策(改過價的品項算獨立的訂單行)的那一刻,它已經跨進 value object 的範圍。品項被掛單系統接受之後,操作開始要求精確指到特定實體,模型隨之升級成持有身份參照的形態(同一個品項、四個 model)。

判準要逐生命週期階段重問,答案改變的時刻就是模型該交棒的時刻。POS 品項恰好在兩個軸上同時升級(欄位組合規則從無到有、身份需求從無到有),但這兩個判準是獨立的——一個物件可以有嚴格的欄位組合規則卻不需要身份(純 value object),也可以需要身份但欄位組合全部合法(identity-bearing data holder)。同一個業務概念在輸入階段是內容比對的 value object、進入外部系統後是 entity、成為歷史事實後又凍結成 snapshot(當下內容的複本)——強行用單一模型通吃,每個階段都要為其他階段的需求付代價。

跨服務傳輸時,一個在來源端是領域模型的概念,到了接收端刻意降級成資料袋(DTO)是正當的設計選擇。規則的擁有者是來源端的 bounded context,搬運端沒有強制規則的責任——在接收端看來,這些欄位的任意組合都是合法的(規則在別人那裡)。身份語意的完整判準在 entity 與 value object 的判準 展開。

資料袋起步、訊號出現才升級

規則還沒到場時,資料袋起步是合理的設計,升級時機由演化訊號決定、而由預測決定的升級幾乎都會蓋錯。同一個 POS 專案的商品模型留下一組可對照的時間軸:早期文件記錄的 Product 是扁平結構(一商品、一價、一庫存),並附一張「未來擴展」清單——商品分類、庫存管理、折扣策略、商品圖片。十個月後清單上每一項都發生了,但沒有一項是在扁平模型上加欄位實現的:真實業務帶來「規格」這個變體維度(中杯與大杯各自有條碼、售價、庫存),模型長成雙層結構——商品作為聚合根(對外代表整組資料一致性的邊界物件)、規格作為其下被分化的子層,欄位歸屬的判準是「兩個規格會不會不同」(文件裡的扁平 Product、程式碼裡的雙層聚合)。

這個案例把 YAGNI(You Aren’t Gonna Need It、需求到場前先別蓋)落到模型設計的精確形式:預測「會有什麼需求」可行、預測「結構會怎麼長」幾乎不可能——結構由「變體會沿哪個軸分化」決定,而分化軸是設計當下還沒到場的業務資訊。需求清單可以先列,它是雷達;結構要等需求真正到場才定形:預先蓋的欄位每一個都是將來的遷移債。升級訊號比預測可靠:同一概念出現多個變體需求(規格、方案、版本)、欄位組合開始被業務規則綁住、變更開始需要留痕或走審批——訊號出現的當下再做歸屬判準與拆層,結構是從真需求長出來的。

判讀訊號

  • 型別上出現「請用某方法修改」「此欄位勿直接改」的註解時,規則已經到場、強制還停在文件層,讀 不變式的強制層次
  • 測試或工廠用逐欄位拼裝的方式製造特定狀態的物件——變更路徑沒有收斂,稽核或狀態規則可能已被繞過;拼裝的動機常是工廠表達力不足、缺陷被逃生口吸收,機制見 建構路徑設計、原則層見 #223
  • 同一個概念長出多個變體需求:扁平模型已到結構性極限,先做「哪些欄位會被變體分化」的歸屬判準再拆層。
  • 一個型別同時有領域方法與全開放的覆寫工具——兩條變更路徑並存,規則正在退化成建議,收斂方向見 不變式的強制層次

下一步