本章是 Part III 的核心章節,探討如何將隱藏在程式碼與對話中的隱式概念 (implicit concepts) 挖掘出來,轉化為模型中的顯式元素 (explicit elements)。Evans 指出,深層模型的突破往往來自於辨識出那些一直存在、卻從未被正式納入設計的概念。
發掘隱式概念的方法#
Evans 提出四種挖掘隱式概念的途徑:
傾聽語言 (Listen to Language)#
- 留意 Domain Expert 使用的術語——如果某個詞能簡潔地表達複雜事物,它很可能是一個值得建模的概念
- 注意 Expert 是否在糾正你的用詞、或在聽到某個特定說法時眼神一亮
- 如果開發者和 Domain Expert 都在使用某個術語,但它不存在於設計中,這是一個強烈的警訊,也是改善模型的契機
- 這不是老套的「名詞就是物件」做法——聽到新詞只是一個線索,後續還需要透過對話和 Knowledge Crunching 來淬煉出清晰、實用的概念
UBIQUITOUS LANGUAGE 涵蓋了言談、文件、模型圖和程式碼中的詞彙。若某個術語在設計中缺席,就是改善模型的機會。
範例:航運模型中發現 Itinerary#
開發團隊已經有一個可以預訂貨物的應用程式,接著要建立一個「營運支援」系統來處理裝卸工單。在一場開發者與航運專家的對話中:
- Expert 反覆使用 “itinerary” 一詞來描述貨物的完整旅程
- 系統中已經有所有相關資料(航次、裝卸港口、時間),但這些資料散落在資料庫表格的各列中,沒有一個統一的概念來串聯
- 開發者捕捉到這個關鍵詞,隨即在白板上畫出 Itinerary 與 Leg 的關係圖
開發者與其他同事一起討論後,產出了重構後的模型:

Figure 9.3: The refactored model of Itinerary and Leg
將 Itinerary 顯式化帶來了以下好處:
- Routing Service 的介面更具表達力——直接回傳 Itinerary 物件
- 解耦 Routing Service 與預訂資料庫表格
- 釐清預訂應用程式與營運支援應用程式之間的關係(共享 Itinerary 物件)
- 減少重複——Itinerary 統一推導裝卸時間,同時服務預訂報表和營運支援
- 將 Domain Logic 從報表中移回獨立的 Domain Layer
- 擴展 UBIQUITOUS LANGUAGE,讓開發者與 Domain Expert 之間的討論更精確
審視笨拙之處 (Scrutinize Awkwardness)#
你需要的概念不一定浮在表面。當設計中出現以下狀況時,就是挖掘的好地方:
- 程序做著複雜又難以解釋的事
- 每個新需求都讓複雜度飆升
- 物件承擔了所有工作,但某些職責顯得格格不入
此時需要主動與 Domain Expert 一起搜尋。如果 Expert 願意一起玩味想法、實驗模型,那是最好的情況;否則開發者就得自己提出假說,觀察 Expert 臉上的不安或認可來驗證。
範例:利息計算的艱難之路#
一家投資公司的應用程式追蹤投資與收益。每晚批次腳本逐一對每個 Asset 計算利息和費用,再記入會計系統。初始模型如下:

Figure 9.4: An awkward model
開發者察覺到 Interest Calculator 日益複雜,便邀請 Domain Expert 一起深入挖掘:
- Expert 指出利息的「逾期未付」並不是特殊案例,而是付款方式本來就有很大彈性
- Expert 提到了 Accrual Basis Accounting(應計基礎會計) 的概念——每天(或按排程)產生一筆 Interest Accrual,記入帳簿;付款則是另外獨立的記帳
- 開發者意識到 Accrual(應計) 才是缺失的核心概念
經過幾輪白板對話與重構,產出了更深層的模型:

Figure 9.8: A deeper model after refactoring
重構後的應用程式中,每晚批次腳本呼叫每個 Asset 的 calculateAccrualsThroughDate(),回傳一組 Accrual,各自記入對應的帳簿。新模型的優勢:
- 以 “accrual” 豐富了 UBIQUITOUS LANGUAGE
- 解耦應計與付款
- 將 Domain Knowledge(如應記入哪個帳簿)從腳本移入 Domain Layer
- 將費用與利息統一處理,消除程式碼重複
- 透過 Accrual Schedule,提供了直觀的擴展路徑來新增各種費用與利息變體
思考矛盾 (Contemplate Contradictions)#
不同的 Domain Expert 基於各自的經驗和需求,看事情的方式不同。甚至同一個人提供的資訊,在仔細分析後也可能邏輯不一致。這些矛盾可以是通往更深層模型的重要線索。
Evans 以 Galileo 的思想實驗為例:常識告訴我們地球是靜止的(人不會被吹飛),但 Copernicus 論證地球繞太陽高速運行。Galileo 透過思考「騎馬者丟球」的情境,推導出慣性參考框架的早期概念,解決了矛盾並產生更有用的物理模型。
並非所有矛盾都需要調和(Chapter 14 會討論如何抉擇和管理),但即使矛盾保留在原處,思考兩個陳述如何同時適用於同一外部現實這個過程本身就能帶來洞察。
閱讀書籍 (Read the Book)#
- 很多領域都有書籍解釋基本概念和慣例智慧
- 你仍需與 Domain Expert 合作,蒸餾出與問題相關的部分,並揉捏成適合物件導向軟體的形式
- 但書籍能提供一個連貫的、經過深思的起點
範例:從書本學到的利息模型#
假設另一個場景:Domain Expert 無暇配合,開發者只好自己去書店找了一本會計入門書。她從中發現了 Accrual Basis Accounting 的定義:
This method recognizes income when it is earned, even if it is not paid.
基於這個知識,她與另一位開發者一起腦力激盪,得到了一個模型:

Figure 9.9: A somewhat deeper model based on book learning
這個模型沒有前一個範例那麼深入(Calculator 仍在、帳簿知識仍在應用層),但她成功地分離了付款與收入應計——這是最棘手的部分,也將 “accrual” 引入了模型和 UBIQUITOUS LANGUAGE。後續迭代可以進一步精煉。
除了領域書籍外,閱讀其他軟體專業人士的著作(如 Fowler 的 Analysis Patterns)也能提供不同的起點和前人的萃取經驗,避免重新發明輪子。Chapter 11 會進一步探討這個選項。
反覆嘗試 (Try, Try Again)#
- 在對話中可能追蹤六條線索才找到一條清晰且實用的
- 之後可能至少再替換一次
- 建模者不能執著於自己的想法
- 每次方向轉換都不是白費——每次改變都在模型中嵌入更深的洞察,每次重構都讓設計更有彈性、更容易在需要彎曲的地方彎曲
- 實驗是學習什麼可行、什麼不可行的唯一途徑
mindmap
root((發現隱式概念))
傾聽語言
領域專家的術語
團隊交流中的線索
審視笨拙之處
設計中的不協調
複雜的程序暗示遺漏概念
思考矛盾
模型內的衝突
領域專家的不同看法
閱讀書籍
領域文獻
Analysis Patterns如何建模較不明顯的概念#
物件導向範式引導我們尋找「事物」和「動作」——即入門書談的「名詞與動詞」。但還有其他重要類別的概念也可以在模型中顯式化。Evans 討論了三個類別:Constraint(約束)、Process(流程) 和 Specification(規格)。
Explicit Constraints(顯式約束)#
Constraint 經常隱式地出現在程式碼中。將它們顯式化可以大幅改善設計。
以一個 Bucket 物件為例,它必須保證容量不被超過的不變式:
class Bucket {
private float capacity;
private float contents;
public void pourIn(float addedVolume) {
if (contents + addedVolume > capacity) {
contents = capacity;
} else {
contents = contents + addedVolume;
}
}
}這段邏輯很簡單,規則一目了然。但在更複雜的類別中,這種約束很容易被淹沒。將它抽取為獨立方法並給予 Intention-Revealing Name:
class Bucket {
private float capacity;
private float contents;
public void pourIn(float addedVolume) {
float volumePresent = contents + addedVolume;
contents = constrainedToCapacity(volumePresent);
}
private float constrainedToCapacity(float volumePlacedIn) {
if (volumePlacedIn > capacity) return capacity;
return volumePlacedIn;
}
}兩種版本都能執行約束,但第二種與模型的關係更明顯——這是 MODEL-DRIVEN DESIGN 的基本要求。
以下警訊表示 Constraint 正在扭曲宿主物件的設計:
- 評估約束需要的資料不屬於該物件的定義
- 相關規則出現在多個物件中,導致重複或不當繼承
- 設計與需求討論圍繞著約束,但在實作中它們被藏在程序式程式碼裡
當約束模糊了物件的基本職責,或約束在領域中很突出卻在模型中不突出時,可以將它抽取為獨立物件,甚至建模為一組物件與關係。
範例回顧:Overbooking Policy#
Chapter 1 中的航運超額預訂案例——預訂比運輸能力多 10% 的貨物。這個 Voyage 與 Cargo 之間關聯的約束,透過新增一個代表約束的類別而被顯式化:

Figure 9.11: The model refactored to make policy explicit
Processes as Domain Objects(流程作為領域物件)#
- 我們不希望讓程序成為模型的主角——物件應該封裝程序,讓我們思考目標或意圖
- 但有些存在於領域中的流程必須在模型中表達,它們往往造成笨拙的物件設計
- SERVICE 是顯式表達流程的一種方式(如航運系統的 Routing 流程)
- 當流程有多種執行方式時,可以將演算法本身(或關鍵部分)做成物件——不同流程的選擇變成不同物件的選擇,即 STRATEGY 模式(Chapter 12 會深入討論)
區分哪些流程該顯式化、哪些該隱藏的關鍵很簡單:這是 Domain Expert 會談論的事,還是只是電腦程式的機制?
Specification 模式#
Evans 與 Martin Fowler 共同開發了 SPECIFICATION 模式。它提供了一種簡潔的方式來表達特定種類的規則,將它們從條件邏輯中抽離,在模型中顯式呈現。
問題背景#
在各種應用程式中,Boolean 測試方法其實是小規則的碎片。簡單的如 anInvoice.isOverdue():
public boolean isOverdue() {
Date currentDate = new Date();
return currentDate.after(dueDate);
}但並非所有規則都這麼簡單。例如 anInvoice.isDelinquent() 可能需要考慮:
- 寬限期(取決於客戶帳戶狀態)
- 是否該發第二次通知或交給催收機構
- 客戶的付款歷史
- 公司對不同產品線的政策
如此一來,Invoice 作為「付款請求」的清晰語意會被大量規則評估程式碼淹沒,並且會對各種 Domain Class 和子系統產生不當依賴。
如果為了拯救 Invoice 而將規則評估移到 Application Layer,規則就完全脫離了 Domain Layer,留下一個不表達商業規則的「死資料物件」。
SPECIFICATION 的核心概念#
SPECIFICATION 是一個 predicate-like VALUE OBJECT:
- 它陳述對另一個物件狀態的約束
- 它可以測試任何物件,判斷是否滿足指定的標準
- 將從 Boolean 測試中膨脹出來的方法整齊地擴展為獨立物件

Figure 9.13: A more elaborate delinquency rule factored out as a SPECIFICATION
SPECIFICATION 的好處:
- 將規則保留在 Domain Layer 中
- 設計能更明確地反映模型
- FACTORY 可以使用來自客戶帳戶或企業政策資料庫的資訊來配置 SPECIFICATION,避免 Invoice 直接耦合這些來源
- SPECIFICATION 可以透過建構函式簡單直觀地取得所需資訊(如特定的評估日期)
三種應用場景#
SPECIFICATION 的價值在於它統一了看似不同的應用功能。同一個概念可用於三個目的:
- Validation(驗證):測試個別物件是否滿足某些標準
- Selection / Querying(篩選/查詢):從集合中選出滿足標準的子集
- Building to Order(按規格建造):指定新物件的建立標準
這三種用途在概念層面是相同的。沒有 SPECIFICATION 模式,同一條規則可能以不同面貌出現,甚至產生矛盾。
Validation(驗證)#
最簡單也最直觀的用途。

Figure 9.14: A model applying a SPECIFICATION for validation
class DelinquentInvoiceSpecification extends InvoiceSpecification {
private Date currentDate;
public DelinquentInvoiceSpecification(Date currentDate) {
this.currentDate = currentDate;
}
public boolean isSatisfiedBy(Invoice candidate) {
int gracePeriod =
candidate.customer().getPaymentGracePeriod();
Date firmDeadline =
DateUtility.addDaysToDate(candidate.dueDate(), gracePeriod);
return currentDate.after(firmDeadline);
}
}客戶端使用範例——當業務員查看客戶資料時顯示逾期紅旗:
public boolean accountIsDelinquent(Customer customer) {
Date today = new Date();
Specification delinquentSpec =
new DelinquentInvoiceSpecification(today);
Iterator it = customer.getInvoices().iterator();
while (it.hasNext()) {
Invoice candidate = (Invoice) it.next();
if (delinquentSpec.isSatisfiedBy(candidate)) return true;
}
return false;
}Selection / Querying(篩選/查詢)#
當需要從集合中選出滿足條件的子集時(例如列出所有有逾期發票的客戶),概念相同但實作考量不同。
如果資料量小且已在記憶體中,驗證的實作可以直接沿用:
public Set selectSatisfying(InvoiceSpecification spec) {
Set results = new HashSet();
Iterator it = invoices.iterator();
while (it.hasNext()) {
Invoice candidate = (Invoice) it.next();
if (spec.isSatisfiedBy(candidate)) results.add(candidate);
}
return results;
}但實際上資料通常在關聯式資料庫中。SPECIFICATION 與 REPOSITORY 的結合有多種策略:
- 直接在 SPECIFICATION 中嵌入 SQL(簡單但會讓表格結構洩漏到 Domain Layer)
- 使用 double dispatch——SPECIFICATION 呼叫 REPOSITORY 的特化查詢方法,將 SQL 保留在 REPOSITORY 中,而 SPECIFICATION 控制使用哪個查詢
- 折衷方案——REPOSITORY 提供較通用的查詢(如
selectWhereDueDateIsBefore),SPECIFICATION 再於記憶體中進一步篩選

Figure 9.15: The interaction between REPOSITORY and SPECIFICATION
不同實作在模型層面沒有差別——實作的選擇是自由的,除非模型明確約束。代價是查詢的撰寫和維護方式可能更繁瑣。Evans 強調這只是觸及了 SPECIFICATION 與資料庫結合挑戰的表面。
Building to Order(按規格建造)#
如同五角大廈要求新戰鬥機達到 Mach 2、航程 1800 英里、造價不超過 5000 萬美元——Specification 不是設計圖,更不是成品,而是對尚未存在之物件的標準描述。
用 SPECIFICATION 定義生成器介面的優勢:
- 解耦生成器的實作與介面——SPECIFICATION 宣告需求但不定義如何達成
- 介面顯式傳達規則,開發者無需理解所有實作細節就能預期結果
- 介面更有彈性——需求的陳述掌握在客戶端手中
- 更容易測試——傳入生成器的 SPECIFICATION 同時可用於驗證輸出(這是 Chapter 10 討論的 ASSERTION 的一個範例)
範例:化學品倉儲打包器#
倉庫中儲存各種化學品,放在類似貨車車廂的大型容器中。規則:
- 某些化學品惰性,可存放在任何地方
- 某些揮發性,需要通風容器
- 某些爆炸性,需要裝甲容器
- 還有關於容器中組合的規則

Figure 9.16: A model for warehouse storage
每種化學品有一個 Container Specification:
| Chemical | Container Specification |
|---|---|
| TNT | Armored container |
| Sand | (無特殊需求) |
| Biological Samples | 不可與爆炸物共存 |
| Ammonia | Ventilated container |
SPECIFICATION 的驗證邏輯:
public class ContainerSpecification {
private ContainerFeature requiredFeature;
public ContainerSpecification(ContainerFeature required) {
requiredFeature = required;
}
boolean isSatisfiedBy(Container aContainer) {
return aContainer.getFeatures().contains(requiredFeature);
}
}Container 的安全檢查:
boolean isSafelyPacked() {
Iterator it = contents.iterator();
while (it.hasNext()) {
Drum drum = (Drum) it.next();
if (!drum.containerSpecification().isSatisfiedBy(this))
return false;
}
return true;
}mindmap
root((SPECIFICATION Pattern))
Validation(驗證)
測試物件是否滿足條件
isSatisfiedBy 方法
Selection / Querying(查詢)
從集合中篩選符合條件的物件
Repository 整合
Building to Order(依規格建造)
根據規格產生新物件
Generator 模式基於這些 SPECIFICATION,定義了一個清晰的 SERVICE 介面:
public interface WarehousePacker {
public void pack(Collection containersToFill,
Collection drumsToPack) throws NoAnswerFoundException;
/* ASSERTION: At end of pack(), the ContainerSpecification
of each Drum shall be satisfied by its Container.
If no complete solution can be found, an exception shall
be thrown. */
}用工作原型疏通開發瓶頸#
打包器的最佳化邏輯需要數月開發。在此期間,應用程式團隊可以用一個極簡的 Prototype Packer 來推進:
- 這個 Prototype 只是依序將 Drum 放入第一個可容納的 Container,不做最佳化
- 但它確實遵守了已陳述的規則
- 它讓應用程式團隊能全速推進(包括與外部系統的整合)
- Packer 開發團隊也能從使用者與原型互動中獲得回饋
- 最終整合輕而易舉,因為正式版實作了相同的介面和 ASSERTION
這是「最簡單能運作的東西」因為更精緻的模型而成為可能的範例——幾十行易懂的程式碼就能產出非常複雜元件的工作原型。較不以 MODEL-DRIVEN 為導向的做法會更難理解、更難升級(因為 Packer 會與設計的其他部分更緊密耦合),而且原型開發可能反而更慢。
本章重點回顧#
- 隱式概念顯式化是通往深層模型的關鍵路徑——透過傾聽語言、審視笨拙之處、思考矛盾、閱讀書籍來發掘
- Constraint 應該被命名、抽取為獨立方法或物件,避免模糊宿主物件的職責
- Process 如果是 Domain Expert 會談論的事物,就應顯式表達為 SERVICE 或 STRATEGY
- SPECIFICATION 統一了 Validation、Selection 和 Building to Order 三種看似不同的需求,將商業規則保留在 Domain Layer,同時保持物件的清晰語意
- 反覆嘗試、不執著於已有想法,是深化模型不可或缺的心態