本章以一個**貨運系統(Cargo Shipping System)**為完整範例,示範如何將前面各章介紹的 Building Block 模式——Entity、Value Object、Aggregate、Repository、Factory、Module、Service——整合運用於同一個領域模型中。作者從初始的類別圖出發,逐步施加設計約束,最終引入外部系統整合,展現 Model-Driven Design 的完整過程。

初始領域模型#

初始模型以一張類別圖表達貨運領域的核心概念,並為團隊提供了一套 Ubiquitous Language。透過這個模型,團隊可以做出如下陳述:

  • 「多個 Customer 與一件 Cargo 相關,各自扮演不同的 Role。」
  • 「Cargo 的交付目標由 Delivery Specification 指定。」
  • 「一系列滿足 Specification 的 Carrier Movement 將達成交付目標。」

Figure 7.1: A class diagram representing a model of the shipping domain

各物件的意義#

  • Handling Event:對 Cargo 採取的離散動作(如裝船、通關)。可進一步衍生為不同類型的事件層級結構——裝貨(loading)、卸貨(unloading)、接收人領取(claiming)等。
  • Delivery Specification:定義交付目標,至少包含目的地與到達日期,遵循 Specification 模式(見第 9 章)。將此職責從 Cargo 分離出來有三個好處:
    1. 避免 Cargo 物件因承擔所有交付細節而變得臃腫、難以理解與修改
    2. 在解釋整體模型時可安全地隱藏細節,提高圖的可讀性
    3. 更具表達力——明確說明 Cargo 的確切交付方式尚未確定,但必須達成 Specification 所設定的目標
  • Role:區分不同 Customer 在一次貨運中扮演的角色(shipper、receiver、payer 等)。因為一個特定 Cargo 的某個 Role 只能由一個 Customer 擔任,原本的 many-to-many 關聯變成了 qualified many-to-one。
  • Carrier Movement:特定運輸工具(卡車或船)從一個 Location 到另一個 Location 的一趟行程。Cargo 透過裝載到 Carrier 上來移動。
  • Delivery History:反映 Cargo 實際發生的事情(與描述目標的 Delivery Specification 相對)。可透過分析最後一次裝卸事件及對應 Carrier Movement 的目的地來推算 Cargo 的當前位置。成功交付意味著 Delivery History 滿足了 Delivery Specification 的目標。

隔離領域層:引入 Application#

運用 Layered Architecture 將領域職責與系統其他部分分離。在不做深入分析的前提下,可辨識出三個使用者層級的應用功能,分別對應三個 Application Layer 類別:

  1. Tracking Query——查詢某件 Cargo 過去與現在的處理情況
  2. Booking Application——註冊新 Cargo 並準備系統
  3. Incident Logging Application——記錄每次 Cargo 的處理事件(提供 Tracking Query 查詢的資訊)

這些 Application 類別是協調者(coordinators),不應自行計算答案——那是 Domain Layer 的工作。

區分 Entity 與 Value Object#

逐一檢視模型中的每個物件,判斷其需要追蹤身分(Entity)還是僅表示基本值(Value Object)。

明確的 Entity#

物件判斷理由身分識別方式
Customer代表人或公司,使用者關心其身分公司既有客戶資料庫的 ID 編號
Cargo兩個相同的箱子必須可區分自動產生的 Tracking ID,在訂艙時提供給客戶
Handling Event反映真實世界事件,通常不可互換Cargo ID + 完成時間 + 事件類型的組合
Carrier Movement每趟行程都是獨立的從航運排程取得的代碼
Location同名的兩個地方不是同一個地方自動產生的內部識別碼

特殊案例:Delivery History#

Delivery History 不可互換,因此是 Entity。但它與 Cargo 之間是一對一關係,它的身分是從擁有它的 Cargo 借來的。這一點在 Aggregate 邊界的討論中會更加清晰。

Value Object#

  • Delivery Specification:表達 Delivery History 的假設狀態。如果兩件 Cargo 要到同一個地方,它們可以共用同一個 Delivery Specification,但不能共用同一個 Delivery History。
  • Role:描述關聯的性質,沒有歷史或連續性,可在不同 Cargo/Customer 關聯之間共用。
  • 其他屬性如時間戳記名稱等也是 Value Object。

設計 Association 的遍歷方向#

原始圖中所有 Association 都沒有指定遍歷方向,但雙向 Association 在設計上有問題。而且,遍歷方向往往能捕捉對領域的洞察,深化模型本身。

關鍵的設計決策:

  • Customer → Cargo:取消直接引用。長期重複客戶會累積大量 Cargo,使 Customer 變得笨重。且 Customer 概念不專屬於 Cargo。若需以 Customer 查找 Cargo,透過資料庫查詢即可。
  • Carrier Movement → Handling Event:取消此方向。我們的業務只需追蹤 Cargo,不需追蹤船舶裝載明細。只保留 Handling Event → Carrier Movement 的方向,簡化為單純的物件引用。
  • Cargo → Delivery History → Handling Event → Cargo:模型中存在循環引用。初始原型中 Delivery History 可用 List 持有 Handling Event 集合,但日後可能改為以 Cargo 為鍵的資料庫查詢,以簡化維護並降低新增 Handling Event 的開銷。

Figure 7.2: Traversal direction has been constrained on some associations.

Aggregate 邊界#

CustomerLocationCarrier Movement 各有自己的身分且被多個 Cargo 共用,因此必須是各自 Aggregate 的根。Cargo 也是明顯的 Aggregate Root,但邊界的劃定需要仔細思考。

Cargo Aggregate 的成員#

  • Delivery History:沒有人會不想看 Cargo 就直接查閱 Delivery History。不需要直接的全域存取,身分也只是從 Cargo 衍生而來——適合放在 Cargo 的 Aggregate 邊界內,不需成為 Root。
  • Delivery Specification:是 Value Object,納入 Cargo Aggregate 沒有問題。
  • Handling Event:雖然因特定 Cargo 而存在,但處理貨物的活動在脫離 Cargo 本身後仍有意義(例如查找某趟 Carrier Movement 上所有裝卸作業)。因此 Handling Event 應該是自己 Aggregate 的 Root

Figure 7.3: AGGREGATE boundaries imposed on the model.

圖中未被框線包圍的 Entity,隱含它是自己 Aggregate 的 Root。

選擇 Repository#

設計中有五個 Entity 是 Aggregate Root,只有這些才有資格擁有 Repository。根據應用需求逐一判斷:

Repository需求來源
Customer RepositoryBooking Application 需要選擇扮演各 Role 的 Customer
Location Repository指定 Cargo 的目的地
Carrier Movement RepositoryIncident Logging Application 需要查找 Cargo 正在裝載的 Carrier Movement
Cargo Repository使用者需要指出哪件 Cargo 被裝載

暫時沒有 Handling Event Repository,因為 Delivery History 的關聯在第一次迭代中以集合實作,且目前沒有應用需求需要查找特定 Carrier Movement 上裝載了什麼。如果這兩個條件任一改變,就會增加 Repository。

Figure 7.4: REPOSITORIES give access to selected AGGREGATE roots.

場景走查#

透過場景走查來交叉驗證所有設計決策,確認能有效解決應用問題。

變更 Cargo 的目的地#

客戶臨時改變目的地(例如從 Hackensack 改到 Hoboken)。因為 Delivery Specification 是 Value Object,最簡單的做法就是丟棄舊的、建立新的,然後用 Cargo 的 setter 方法替換。

重複業務(Repeat Business)#

同一客戶的重複訂艙通常相似,使用者希望以舊 Cargo 作為新 Cargo 的原型。採用 Prototype 模式,需要仔細考慮 Aggregate 邊界內每個物件的複製策略:

  • Delivery History:建立新的空白物件(舊的歷史不適用於新 Cargo)
  • Customer Roles:複製持有 Customer 引用的 Map 及鍵值(角色可能相同),但不複製 Customer 物件本身——必須保持對原 Customer Entity 的引用,因為它們在 Aggregate 邊界外
  • Tracking ID:從與全新 Cargo 相同的來源取得新 ID

複製了 Cargo Aggregate 邊界內的所有東西,對複本做了部分修改,但完全沒有影響到 Aggregate 邊界外的任何事物

物件建立#

Cargo 的 Factory 與建構子#

即使有複雜的 Factory 或以另一件 Cargo 作為 Factory(如 Repeat Business 場景),仍需要基本建構子。建構子應產生滿足不變式的物件,或至少(對 Entity 而言)保有完整的身分。

可能的 Factory Method 設計:

// Cargo 上的 Factory Method
public Cargo copyPrototype(String newTrackingID)

// 獨立的 Factory
public Cargo newCargo(Cargo prototype, String newTrackingID)

// 自動產生 ID 的獨立 Factory
public Cargo newCargo(Cargo prototype)

由於 Cargo 與 Delivery History 之間的雙向關聯,兩者必須一起建立。Cargo 的建構子(或 Factory)負責建立 Delivery History,而 Delivery History 的建構子接受 Cargo 作為參數:

public Cargo(String id) {
    trackingID = id;
    deliveryHistory = new DeliveryHistory(this);
    customerRoles = new HashMap();
}

Delivery History 的建構子只被其 Aggregate Root(即 Cargo)使用,從而封裝了 Cargo 的組合結構。

新增 Handling Event#

Handling Event 是 Entity,所有定義身分的屬性都必須傳入建構子:

public HandlingEvent(Cargo c, String eventType, Date timeStamp) {
    handled = c;
    type = eventType;
    completionTime = timeStamp;
}

為了讓客戶端程式碼更具表達力,可在 Handling Event 上為每種事件類型加入 Factory Method:

public static HandlingEvent newLoading(
    Cargo c, CarrierMovement loadedOnto, Date timeStamp) {
      HandlingEvent result =
          new HandlingEvent(c, LOADING_EVENT, timeStamp);
      result.setCarrierMovement(loadedOnto);
      return result;
}

然而,從 Cargo → Delivery History → Handling Event → Cargo 的循環引用使得物件建立變得複雜。新增 Handling Event 時,必須同時將它加入 Delivery History 的集合中,否則物件將處於不一致狀態。

Figure 7.5: Adding a Handling Event requires inserting it into a Delivery History.

重構的暫停:Cargo Aggregate 的替代設計#

模型化與設計不是一路向前的過程,需要頻繁重構以利用新洞察來改進模型與設計。

問題#

更新 Delivery History 以新增 Handling Event 會使 Cargo Aggregate 捲入交易。若其他使用者同時在修改 Cargo,Handling Event 交易可能失敗或延遲。但記錄 Handling Event 是需要快速簡單完成的作業活動,不應有爭用問題。

解決方案#

查詢(query)取代 Delivery History 中的 Handling Event 集合

  • Handling Event 的新增不會引發其自身 Aggregate 之外的完整性問題
  • 交易能夠不受干擾地完成
  • 如果 Handling Event 的新增頻率高而查詢頻率低,此設計更有效率
  • 若底層技術是關聯式資料庫,集合可能本來就是用查詢模擬的
  • 循環引用的一致性維護也因此簡化

新增 Handling Event Repository,支援以下查詢:

  • 查找與某件 Cargo 相關的所有 Event
  • 查找最後一次裝/卸事件以推斷 Cargo 目前狀態
  • 查找裝載在某趟 Carrier Movement 上的所有 Cargo

Figure 7.6: Implementing Delivery History's collection of Handling Events as a query makes insertion of Handling Events simple.

重構的影響#

  • Delivery History 不再有持久狀態,可在需要時衍生產生
  • Cargo Factory 簡化——不再需要為新實例附加空的 Delivery History
  • 循環引用不再難以建立與維護
  • 所有變更都封裝在 Cargo 的 Aggregate 邊界內,只額外增加了 Handling Event Repository

這類替代方案與設計取捨隨處可見。重要的是,透過正確建模 Value、Entity 及其 Aggregate,我們降低了此類設計變更的影響範圍

flowchart LR
    subgraph Before [重構前]
        Cargo1[Cargo] --> DH1[Delivery History]
        DH1 --> HE1["Handling Event\n集合(直接持有)"]
        HE1 -.->|循環引用| Cargo1
        N1["交易鎖爭用、循環引用"]
    end
    subgraph After [重構後]
        Cargo2[Cargo] --> DH2["Delivery History\n(無集合)"]
        HER[Handling Event Repository] -.->|查詢| HE2[Handling Events]
        N2["交易隔離、查詢靈活"]
    end
    Before --> After

Module 劃分#

當模型規模擴大,Module 的組織方式會影響模型的可讀性。

反面範例:按模式分組#

一個假想的讀者可能會按照每個物件遵循的模式來分組(所有 Entity 放一起、所有 Value Object 放一起等)。結果是概念上關係不大的物件被塞在一起(低內聚),而 Association 在所有 Module 之間雜亂穿梭(高耦合)。這些 Package 講述的是開發者當時在讀什麼書的故事,而不是貨運領域的故事。

Figure 7.7: These MODULES do not convey domain knowledge.

按模式分組看似明顯的錯誤,但它其實不比「將持久物件與暫態物件分開」或其他任何不以物件意義為基礎的系統性方案更不合理。

正面範例:按領域概念分組#

應該尋找內聚的概念,聚焦於想要傳達給專案其他成員的訊息:

  • Customer Module——銷售與行銷人員處理客戶、與客戶簽訂協議
  • Shipping Module——營運人員負責運送,將貨物送達指定目的地
  • Billing Module——後勤人員處理帳務,根據客戶協議中的定價提交發票

Figure 7.8: MODULES based on broad domain concepts

Module 名稱成為團隊語言的一部分:「我們公司為客戶做運送,這樣我們才能向他們收費。」這個直覺的劃分可以在後續迭代中精煉甚至完全替換,但它現在已經在支持 Model-Driven Design 並貢獻於 Ubiquitous Language。

引入新功能:Allocation Checking#

業務背景#

銷售部門使用另一套 Sales Management System 管理客戶關係與銷售預測。其中一項功能支援收益管理(yield management):根據貨物類型、起運地與目的地等因素,分配各類型貨物的預計接單量,以確保:

  • 高利潤貨物不會被低利潤貨物擠出
  • 避免接單不足(未充分利用運力)
  • 避免過度超額訂艙(頻繁退貨損害客戶關係)

現在要將此功能整合到訂艙系統中——新訂艙進來時,需檢查是否符合分配額度。

Figure 7.9: Our Booking Application must use information from the Sales Management System and from our own domain.

連接兩個系統#

Sales Management System 使用的不是我們的模型。如果 Booking Application 直接與它互動,我們的應用就必須遷就對方系統的設計,使得 Model-Driven Design 難以保持清晰,Ubiquitous Language 也會被混淆。

解決方案:建立一個類別負責在我們的模型與 Sales Management System 的語言之間翻譯。這不是通用的翻譯機制,而是只暴露我們的應用所需的功能,並以我們領域模型的術語重新抽象它們。這個類別扮演 Anticorruption Layer 的角色(詳見第 14 章)。

與其叫它「Sales Management Interface」,不如根據它在我們系統中的職責來命名:Allocation Checker。這是一種 Service,為我們從外部系統取得的每個 allocation 功能定義介面。

豐富模型:Enterprise Segment#

要回答「這種類型的 Cargo 可以接多少?」的問題,需要定義 Cargo 的「類型」。在 Sales Management System 中,Cargo 類型只是一組分類關鍵字。我們可以照搬,但那樣會錯失以我們領域模型重新抽象的機會。

借鑒 Analysis Patterns(Fowler 1996)中的 Enterprise Segment 模式——一組維度定義了一種分割業務的方式。這些維度可包含前述的貨物類型、起運地/目的地,以及時間維度(如本月至今)。

  • Enterprise Segment 作為額外的 Value Object 出現在領域模型中,需為每件 Cargo 衍生
  • Allocation Checker 負責在 Enterprise Segment 與外部系統的分類名稱之間翻譯
  • Cargo Repository 也必須支援以 Enterprise Segment 為基礎的查詢(注意回傳的是計數而非實例集合)

Figure 7.10: The Allocation Checker acts as an ANTICORRUPTION LAYER presenting a selective interface to the Sales Management System.

職責歸屬的精煉#

初始設計仍有兩個問題:

  1. Booking Application 承擔了業務規則的執行:「若 Enterprise Segment 的分配額度大於已訂數量加上新 Cargo 的大小,則接受 Cargo。」——執行業務規則是領域職責,不應在 Application Layer 中執行。
  2. 不清楚 Booking Application 如何衍生 Enterprise Segment

這兩個職責都應歸屬於 Allocation Checker。調整其介面,將這兩個 Service 分離,使互動清晰明確。

Figure 7.11: Domain responsibilities shifted from Booking Application to Allocation Checker

效能調校#

Allocation Checker 的內部實作可以解決效能問題。例如:

  • 如果 Sales Management System 在另一台伺服器上,每次檢查需要兩次訊息交換
  • 第一次訊息(衍生 Enterprise Segment)基於相對靜態的資料和行為
  • 快取此資訊到 Allocation Checker 所在的伺服器上,將訊息交換減半
  • 代價是設計更複雜,且重複資料必須保持同步

當效能在分散式系統中至關重要時,彈性部署可以是重要的設計目標。

sequenceDiagram
    participant BA as Booking Application
    participant AC as Allocation Checker
    participant SMS as Sales Management System
    BA->>AC: 提交 Cargo 預訂請求
    AC->>AC: 衍生 Enterprise Segment
    AC->>SMS: 查詢該 Segment 的配額
    SMS-->>AC: 回傳當前配額與已用量
    AC->>AC: 比對:已用量 + 新 Cargo ≤ 配額?
    AC-->>BA: accept / reject 決策

最終回顧#

這次整合原本可能把簡單、概念一致的設計變成一團糟,但透過 Anticorruption LayerServiceEnterprise Segment,我們將 Sales Management System 的功能整潔地整合到訂艙系統中,同時豐富了領域模型。

最後一個設計問題:為什麼不讓 Cargo 負責衍生 Enterprise Segment? 乍看之下似乎優雅——如果衍生所需的資料都在 Cargo 中,將它設為 Cargo 的衍生屬性。但事實並非如此簡單:

  • Enterprise Segment 是根據業務策略任意定義
  • 同一個 Entity 可能因不同目的而有不同的分段方式(訂艙分配 vs. 稅務會計)
  • 即使是分配用的 Enterprise Segment 也可能因銷售策略調整而改變
  • Cargo 將不得不知道 Allocation Checker——這遠超出它的概念職責

因此,衍生 Enterprise Segment 的職責留在 Allocation Checker 是正確的歸屬。