本章聚焦於構成領域模型的個別元素,探討如何在不犧牲 Model-Driven Design 精髓的前提下,將模型忠實地映射到軟體實作中。主要涵蓋四個核心建構單元:Association(關聯)、Entity(實體)、Value Object(值物件)、Service(服務),以及組織這些元素的 Module(模組)。


Association#

模型中物件之間的關聯看似容易繪製,但實作起來卻是潛在的泥沼。每一條可遍歷的關聯,在軟體中都必須有對應的機制來實現。

簡化關聯的三種策略#

  1. 施加遍歷方向(Imposing a traversal direction)——將雙向關聯縮減為單向
  2. 加入限定詞(Adding a qualifier)——有效降低多重性
  3. 移除非必要的關聯(Eliminating nonessential associations)

遍歷方向反映領域本質#

以「國家」與「總統」為例:我們通常從國家查詢其歷任總統,很少從「George Washington」反查他是哪國總統。將雙向關聯縮減為從國家到總統的單向關聯,不僅是實作上的便利,更反映了領域中方向性的自然偏好——保持 Person 類別獨立於 President 這個相對不基本的概念。

Figure 5.1: Some traversal directions reflect a natural bias in the domain.

用限定詞降低多重性#

進一步探究會發現:一個國家在同一時間通常只有一位總統。加入「時間」作為限定詞,便將一對多關聯縮減為一對一,同時將重要的業務規則嵌入模型之中。

Figure 5.2: Constrained associations communicate more knowledge and are more practical designs.

範例:Brokerage Account#

書中以券商帳戶為例,說明關聯可以用不同方式實作(物件集合 vs. 資料庫查詢),只要行為與模型一致即可。當在 Brokerage Account 與 Investment 之間加入 stock symbol 作為限定詞,多對多變成了透過鍵值查詢的一對一,使模型更精確、實作更容易維護。

持續約束關聯以反映領域偏好,不僅讓關聯更具表達力、更容易實作,也賦予剩下的雙向關聯更強的語義意義——保留雙向代表雙向性本身就是領域的特徵。


Entity(又稱 Reference Object)#

許多物件的根本定義不在於其屬性,而在於一條貫穿生命週期的連續性與身份(continuity and identity)。

什麼是 Entity#

  • 一個物件如果主要由其**身份(identity)**而非屬性來區分,就稱為 Entity
  • Entity 擁有可能劇烈變化的生命週期,但必須維持一條身份的連續線索
  • 它可以是人、城市、汽車、彩券,或銀行交易——任何在應用程式中需要跨越狀態變化被追蹤的事物

物件導向語言內建的 identity 操作(如 Java 的 ==)只比較記憶體位址,對領域層面的身份識別毫無意義。從資料庫重新載入或跨網路傳輸後,原始的記憶體身份就會消失。Entity 的身份定義必須來自領域模型本身。

同一事物在不同情境下可能是或不是 Entity#

書中以「座位」為例:

  • 對號入座的場館——每個座位有座位號,是 Entity
  • 自由入座的活動——個別座位不需區分,只關心總數,座位就不是 Entity

這說明身份不是事物的固有屬性,而是因為「有用」而被賦予的意義。

建模 Entity 的原則#

  • 精簡為上:將 Entity 的定義剝離到最本質的特徵,特別是用來識別或匹配它的屬性
  • 只加入概念上不可或缺的行為與屬性
  • 其餘行為和屬性應移出到其他關聯的物件(可能是其他 Entity 或 Value Object)
  • Entity 通常透過協調其擁有的物件來履行職責

Figure 5.5: Attributes associated with identity stay with the ENTITY.

設計 Identity 操作#

建立 Entity 的身份識別方式有幾種策略:

策略說明範例
自然屬性組合利用屬性或屬性組合保證唯一報紙名稱 + 城市 + 日期
系統產生的 ID附加一個不可變的唯一符號自動遞增編號、UUID
外部機構發放的 ID使用外部權威機構的識別碼身份證字號、社會安全號碼
使用者指定的 ID由使用者負責確保唯一性常客飛行號碼

無論採用哪種策略,根本的概念問題始終是:兩個物件代表「同一件事」究竟意味著什麼? 如果 ID 或比較操作不對應領域中有意義的區分,只會製造更多混亂。這也是為什麼身份指派操作經常需要人為介入——例如支票簿對帳軟體可能提供可能的匹配,但最終判定仍由使用者做出。


Value Object#

許多物件沒有概念上的身份,它們描述的是某件事物的特徵

什麼是 Value Object#

  • 代表領域中描述性面向、沒有概念身份的物件
  • 我們只關心它是什麼(what),而不是它是哪一個(which)
  • 常見的例子:顏色、字串、數字、地址(在某些情境下)、金額

給所有物件都賦予身份會帶來三重代價:(1)系統必須追蹤所有身份,犧牲效能;(2)需要額外分析工作定義有意義的身份;(3)讓所有物件看起來一模一樣,模糊了模型。

Address 是 Entity 還是 Value Object?#

這取決於誰在問

  • 郵購公司——Address 用於確認信用卡和寄送包裹,不需區分同一地址的不同訂單 → Value Object
  • 郵政服務——Address 是遞送路線階層的一部分,會因郵遞區號重劃而連動 → Entity
  • 電力公司——Address 對應服務線路的目的地,需識別同一地址的不同申請 → Entity(或者用 Dwelling 作為 Entity,Address 作為其 Value Object 屬性)

Value Object 的設計原則#

  • 視為不可變(immutable)——變更管理簡化為完全替換
  • 屬性應構成概念上的整體——例如街道、城市、郵遞區號不應是 Person 的獨立屬性,而應組合成一個完整的 Address Value Object

Figure 5.6: A VALUE OBJECT can give information about an ENTITY. It should be conceptually whole.

複製 vs. 共享#

Value Object 不關心是哪個實例,這給予設計上的自由度:

  • 複製:每個擁有者持有自己的副本,簡單安全
  • 共享:多個物件指向同一實例,節省空間(FLYWEIGHT 模式),但前提是物件必須不可變

適合共享的情境:

  • 資料庫中節省空間或物件數量至關重要
  • 通訊開銷低(如集中式伺服器)
  • 共享物件嚴格不可變

何時允許可變#

雖然不可變是首選,但在以下情況可考慮允許 Value Object 可變:

  • 值頻繁變更
  • 物件建立或刪除成本高
  • 替換(而非修改)會打亂 clustering
  • 幾乎不共享

如果 Value Object 的實作是可變的,就絕不能共享。無論是否共享,盡可能將 Value Object 設計為不可變。

涉及 Value Object 的關聯#

  • Value Object 之間的雙向關聯沒有意義——沒有身份,說「一個物件指回同一個指向它的 Value Object」是無意義的
  • 如果發現兩個 Value Object 之間似乎需要雙向關聯,應重新考慮它是否其實擁有尚未被明確識別的身份
flowchart TD
    Start{物件需要被追蹤身分嗎?}
    Start -->|是| Entity[Entity]
    Start -->|否,只關心屬性| VO{屬性會改變嗎?}
    VO -->|不可變| ValueObject[Value Object]
    VO -->|需要變更| VOmutable[考慮替換而非修改]

    Entity --> IDStrategy{Identity 策略}
    IDStrategy --> Auto[系統自動產生 ID]
    IDStrategy --> Natural[領域自然鍵]
    IDStrategy --> External[外部指派]

    style Start fill:#e0e0e0,stroke:#333,stroke-width:2px,color:#000
    style Entity fill:#f9c74f,stroke:#f77f00,stroke-width:2px,color:#000
    style ValueObject fill:#a8dadc,stroke:#457b9d,stroke-width:2px,color:#000
    style VOmutable fill:#a8dadc,stroke:#457b9d,stroke-width:2px,color:#000
    style IDStrategy fill:#f9c74f,stroke:#f77f00,stroke-width:2px,color:#000

    note["同一概念在不同 Context 可能不同分類\n(如 Address 可以是 Entity 或 Value Object)"]
    style note fill:#fff3cd,stroke:#856404,stroke-width:2px,color:#000

Service#

有時候,領域中的某些操作就是不適合歸屬於任何物件。

為什麼需要 Service#

  • 某些重要的領域操作在 Entity 或 Value Object 中找不到自然的歸屬
  • 強行把操作塞進不合適的物件會使該物件失去概念清晰度,難以理解和重構
  • 複雜操作可能淹沒簡單物件,模糊其角色

Service 的三個特徵#

  1. 操作涉及的領域概念不自然屬於任何 Entity 或 Value Object
  2. 介面以領域模型中的其他元素來定義
  3. 操作是無狀態的(stateless)

Service 的命名傾向於使用動詞而非名詞,強調的是「它能為客戶端做什麼」。操作名稱應來自 Ubiquitous Language,參數和結果應為領域物件。

Service 的分層#

Service 存在於不同層中,區分它們的歸屬非常重要:

層級範例:資金轉帳職責
ApplicationFunds Transfer App Service解析輸入(如 XML 請求)、發送訊息給 Domain Service、監聽確認、決定是否透過 Infrastructure Service 發送通知
DomainFunds Transfer Domain Service與 Account、Ledger 物件互動,執行借貸操作,回傳結果(轉帳是否成功)
InfrastructureSend Notification Service依指示發送電子郵件、信件等通訊
sequenceDiagram
    participant App as Application Service
    participant Domain as Domain Service
    participant Infra as Infrastructure Service

    App->>Domain: 轉帳請求(借方、貸方、金額)
    Domain->>Domain: 借方 Account.debit()
    Domain->>Domain: 貸方 Account.credit()
    Domain->>Infra: 發送通知
    Infra-->>Domain: 通知已發送
    Domain-->>App: 轉帳完成

粒度(Granularity)#

  • 中等粒度的無狀態 Service 更容易在大型系統中重複使用,因為它們在簡單介面後封裝了重要功能
  • 過於細粒度的領域物件在分散式系統中會導致低效的訊息傳遞
  • 適當引入 Domain Service 有助於維持層與層之間的清晰界線,防止領域知識洩漏到應用層

Service 的存取方式#

  • 不一定需要分散式架構(如 J2EE、CORBA)來發布 Service
  • 一個簡單的 SINGLETON 就足以提供存取
  • 應根據實際需求選擇架構,而非為了「以防萬一」的彈性而過度工程化

Module(又稱 Package)#

Module 是老牌的設計元素,其首要動機是降低認知負荷。Module 給予人們兩種觀看模型的視角:深入某個 Module 的細節,或鳥瞰 Module 之間的關係。

核心原則#

  • 低耦合、高內聚不只是技術度量,更是概念層面的原則
    • 低耦合:人一次能思考的事物有限
    • 高內聚:片段不連貫的想法與一鍋大雜燴同樣難以理解
  • Module 是溝通機制——將某些類別放在同一個 Module,等於告訴下一位開發者「請把它們一起思考」
  • Module 的名稱應進入 Ubiquitous Language

設計指引#

選擇能講述系統故事並包含一組內聚概念的 Module。如果模型是在講故事,Module 就是章節。

  • Module 應與模型共同演進,而非停留在早期形態
  • 重構 Module 比重構類別的代價更高,但不應因此避免
  • Module 名稱應反映對領域的深入理解

基礎設施驅動的封裝陷阱#

書中以 J2EE 為例,說明框架將單一領域物件拆分到多個 tier(entity bean、session bean)並分置不同 package 的做法,會帶來兩個嚴重成本:

  1. 程式碼不再顯露模型——實作概念物件的元素被拆散
  2. 心智分割空間耗盡——框架用掉了所有的分區配額,領域開發者失去按領域概念分組的能力

除非真的打算將程式碼部署到不同伺服器,否則應將實作同一概念物件的所有程式碼放在同一個 Module(甚至同一個物件)中。用封裝來分離領域層與其他程式碼,其餘的封裝自由度留給領域開發者。

Agile Module#

  • Module 需要與模型和程式碼一起重構
  • 早期 Module 選擇的錯誤會導致高耦合,使後續重構更困難,形成慣性
  • 克服方法:鼓起勇氣,根據實際遇到的問題點重新組織 Module

Modeling Paradigm#

Model-Driven Design 需要與建模典範(modeling paradigm)配合的實作技術。目前物件導向是主流典範。

物件導向為何主導#

  • 簡單與複雜的平衡:概念直覺易懂,非技術成員也能跟上 UML 圖
  • 成熟的基礎設施:經過多年發展,常見需求都有現成方案
  • 龐大的開發者社群:模式和最佳實踐已廣泛傳播

在物件世界中融入非物件元素#

當領域的某些部分用其他典範(如規則引擎、工作流引擎)更自然表達時,可以混合使用,但必須注意:

混合典範的四條經驗法則:

  1. 不要對抗實作典範——為領域找到適合該典範的模型概念
  2. 依靠 Ubiquitous Language——即使工具之間沒有嚴格連接,一致的語言也能防止設計分歧
  3. 不要執著於 UML——有時其他繪圖風格或簡單的英文描述更適合
  4. 保持懷疑——工具真的值得它帶來的複雜度嗎?規則可以用物件表達,不一定需要規則引擎

在承擔混合典範的負擔之前,應先窮盡主流典範內的選項。即使某些領域概念不是顯而易見的物件,通常仍可在物件典範中建模(第 9 章將討論用物件技術建模非傳統概念)。