本章以一個**貨運系統(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 分離出來有三個好處:
- 避免 Cargo 物件因承擔所有交付細節而變得臃腫、難以理解與修改
- 在解釋整體模型時可安全地隱藏細節,提高圖的可讀性
- 更具表達力——明確說明 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 類別:
- Tracking Query——查詢某件 Cargo 過去與現在的處理情況
- Booking Application——註冊新 Cargo 並準備系統
- 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 邊界#
Customer、Location 和 Carrier 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 Repository | Booking Application 需要選擇扮演各 Role 的 Customer |
| Location Repository | 指定 Cargo 的目的地 |
| Carrier Movement Repository | Incident 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 --> AfterModule 劃分#
當模型規模擴大,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.
職責歸屬的精煉#
初始設計仍有兩個問題:
- Booking Application 承擔了業務規則的執行:「若 Enterprise Segment 的分配額度大於已訂數量加上新 Cargo 的大小,則接受 Cargo。」——執行業務規則是領域職責,不應在 Application Layer 中執行。
- 不清楚 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 Layer、Service 和 Enterprise Segment,我們將 Sales Management System 的功能整潔地整合到訂艙系統中,同時豐富了領域模型。
最後一個設計問題:為什麼不讓 Cargo 負責衍生 Enterprise Segment? 乍看之下似乎優雅——如果衍生所需的資料都在 Cargo 中,將它設為 Cargo 的衍生屬性。但事實並非如此簡單:
- Enterprise Segment 是根據業務策略任意定義的
- 同一個 Entity 可能因不同目的而有不同的分段方式(訂艙分配 vs. 稅務會計)
- 即使是分配用的 Enterprise Segment 也可能因銷售策略調整而改變
- Cargo 將不得不知道 Allocation Checker——這遠超出它的概念職責
因此,衍生 Enterprise Segment 的職責留在 Allocation Checker 是正確的歸屬。