2024年1月16日
DDD 概論:戰略設計、戰術設計與 Event Storming,先搞懂它到底在解決什麼問題
在 六角形架構 那篇,我把業務規則都放進「核心」,外部技術變成可以抽換的 Adapter。但做完之後留下兩個問題:核心裡面的模型要怎麼設計? 以及系統越長越大時,一個核心要不要切成好幾個?要從哪裡切?
這兩個問題正好是 DDD(Domain-Driven Design,領域驅動設計)在回答的事。DDD 來自 Eric Evans 在 2003 年出版的同名書籍,二十年下來衍生出很多做法,這篇先把整體架構整理清楚,之後再針對子域劃分、Event Storming 這些主題分開深入。
DDD 在解決什麼問題
做房仲系統這幾年,最常遇到的困難不是技術,而是業務本身很複雜,而程式碼沒有反映出這份複雜:
- 翻譯成本:業務說「這個物件已經有人斡旋了」,程式裡卻是
house.status = 3。每次需求討論都要在業務語言和程式碼之間翻譯,翻錯一次就是一個 Bug - 一個模型用到底:「物件」一張資料表,刊登要用、搜尋要用、成交要用、業績也要用。欄位越長越多,改一個欄位要擔心十個地方
- 規則散落各處:「斡旋過期就不能接受」這條規則,前端檢查一次、API 檢查一次、排程又檢查一次,三個地方的判斷還不太一樣
DDD 的基本主張是:軟體的核心複雜度來自業務領域本身,所以設計要從理解業務開始,讓程式碼的結構和用詞直接對應業務。
全貌:戰略、戰術、協作探索
DDD 可以分成三個部分:
| 部分 | 回答的問題 | 主要概念 |
|---|---|---|
| 戰略設計 | 系統要切成哪幾塊?每塊多重要?塊和塊之間怎麼合作? | 通用語言、子域、限界上下文、上下文映射 |
| 戰術設計 | 一個上下文裡面,模型要怎麼寫? | 聚合、實體、值物件、領域事件、Repository、領域服務 |
| 協作探索 | 怎麼和領域專家一起把業務弄清楚? | Event Storming、Domain Storytelling |
很多團隊導入 DDD 是從戰術設計開始,專案裡多了 Entities、ValueObjects、Repositories 資料夾,但系統還是一團。這種做法常被稱為「DDD-Lite」,只拿到最表面的好處。戰略設計才是 DDD 影響最大的部分:切錯了邊界,裡面的類別寫得再漂亮也沒用。
戰略設計
通用語言(Ubiquitous Language)
團隊(包含業務、PO、開發人員)在討論、文件、程式碼中使用同一套用詞。
房仲業務裡就有很多容易混淆的詞:
| 業務用詞 | 意思 | 常見的混用 |
|---|---|---|
| 委託 | 屋主授權經紀人銷售,有一般委託、專任委託 | 和「刊登」混用,但委託是合約,刊登是上架到網站 |
| 帶看 | 經紀人帶買方實際看屋 | 「看屋」、「約看」、「預約」各講各的 |
| 斡旋 | 買方付斡旋金、提出價格,請經紀人向屋主議價 | 和「出價」混用,但出價不一定有斡旋金、沒有法律效力 |
| 成交 | 屋主接受價格,雙方簽約 | 斡旋被接受就算成交?還是簽約才算?各部門定義不同 |
通用語言不是一份名詞表而已,它要直接出現在程式碼裡:類別叫 Offer(斡旋)而不是 HouseRequest,方法叫 offer.Accept() 而不是 UpdateStatus(3)。當業務說「斡旋過期不能接受」,開發人員應該能在 Offer.Accept() 裡找到這條規則。
最後一列「成交」的定義不一致,就是通用語言要解決的典型問題:只要發現同一個詞有兩種意思,就要停下來釐清,而不是各自假設。
子域(Subdomain)
領域是整個業務,例如「房仲業務」。領域很大,可以再分成幾個子域,並依重要性分類:
| 類型 | 說明 | 房仲的例子 | 策略 |
|---|---|---|---|
| 核心子域 | 讓公司和競爭對手不同的部分 | 物件媒合、斡旋成交流程 | 自己做、放最好的人、持續投資 |
| 支撐子域 | 業務需要,但不是競爭優勢 | 帶看排程、經紀人業績報表 | 自己做但簡單做,或外包 |
| 通用子域 | 大家都一樣的問題 | 會員登入、簡訊通知、金流 | 買現成的、用開源或雲端服務 |
分類的目的是決定資源要放在哪裡:核心子域值得用完整的 DDD 戰術設計慢慢打磨,通用子域則應該盡量不要自己寫。子域要怎麼找、怎麼判斷屬於哪一類,之後會另外整理一篇。
限界上下文(Bounded Context)
子域是「業務長什麼樣子」(問題空間),限界上下文則是「系統怎麼切」(解法空間):一個模型和它的通用語言有效的範圍。
最好的例子就是「物件」這個詞:
- 刊登上下文:物件是一筆刊登內容,有標題、照片、描述、刊登期限
- 搜尋上下文:物件是一筆搜尋結果,有座標、價格區間、標籤,重點是查得快
- 成交上下文:物件是一筆交易標的,有權狀、地號、所有權人、斡旋紀錄
硬把三者塞成一個 House 類別,就是前面說的「一個模型用到底」。切成三個上下文後,每個上下文裡的「物件」只需要自己關心的欄位和規則,彼此透過明確的介面或事件溝通。
限界上下文通常也是團隊與部署的邊界:一個上下文由一個團隊負責,可以是一個獨立的服務,也可以是單體應用程式裡的一個模組。DDD 不要求一定要做成微服務。
上下文映射(Context Map)
上下文之間一定會有往來,Context Map 描述它們之間的關係。常見的幾種:
| 模式 | 說明 | 例子 |
|---|---|---|
| 客戶/供應商(Customer–Supplier) | 下游提需求,上游排進開發計畫 | 搜尋上下文需要刊登上下文提供物件異動事件 |
| 遵奉者(Conformist) | 下游直接採用上游的模型,沒有議價空間 | 直接使用政府實價登錄資料的欄位格式 |
| 防腐層(Anticorruption Layer) | 下游加一層轉換,避免上游的模型汙染自己 | 串接舊的 ERP 系統時,把它的欄位轉成成交上下文自己的模型 |
| 開放主機服務/公開語言(Open Host Service / Published Language) | 上游提供一套穩定、有文件的公開介面給多個下游 | 刊登上下文發布統一格式的「物件已上架」事件 |
| 共享核心(Shared Kernel) | 兩個上下文共用一小部分模型,修改要雙方同意 | 共用地址、行政區的值物件 |
| 各行其道(Separate Ways) | 整合成本太高,乾脆不整合 | 內部公告系統和成交系統完全獨立 |
畫 Context Map 最大的價值不是畫出漂亮的圖,而是把團隊之間的依賴和權力關係說清楚:如果下游是「遵奉者」,上游一改格式下游就壞,那就應該考慮加一層防腐層。
戰術設計
戰術設計是在一個限界上下文裡面,用來表達業務模型的一組建構元素:
| 元素 | 說明 | 例子 |
|---|---|---|
| 實體(Entity) | 有唯一識別,狀態會隨時間改變 | Offer:同一筆斡旋,價格或狀態變了還是同一筆 |
| 值物件(Value Object) | 沒有識別,只看值;不可變 | Money、Address:兩個 1,200 萬是一樣的 |
| 聚合(Aggregate) | 一組必須保持一致的物件,對外只透過聚合根操作 | Offer 聚合包含價格、斡旋金、期限 |
| 領域事件(Domain Event) | 業務上發生過、其他人在意的事,用過去式命名 | OfferAccepted(斡旋已接受) |
| Repository | 以聚合為單位存取,隱藏資料庫細節 | IOfferRepository |
| 領域服務(Domain Service) | 不屬於任何一個實體的業務規則 | 佣金計算,需要成交價、委託類型、經紀人分配比例 |
| 應用服務(Application Service) | 協調流程:讀取聚合、呼叫方法、儲存,不放業務規則 | AcceptOfferHandler |
聚合:最重要也最容易做錯
聚合是一個一致性邊界:邊界內的規則(不變條件,Invariant)在每次交易結束時都必須成立。Vaughn Vernon 在《Implementing Domain-Driven Design》整理了四個設計原則:
- 在邊界內保護真正的不變條件:只有「必須同時成立」的規則才放在同一個聚合
- 設計小的聚合:大聚合讀取慢、容易衝突,很多規則其實不需要即時一致
- 用 Id 參照其他聚合:
Offer存的是ListingId,不是整個Listing物件 - 邊界外用最終一致:一個交易只修改一個聚合,其他聚合透過領域事件在之後更新
用斡旋來看:「斡旋過期不能接受」、「已經接受的斡旋不能再撤回」是 Offer 自己的規則,放在聚合裡;「斡旋被接受後,物件要標記為已斡旋、其他斡旋要通知失效」則牽涉其他聚合,透過事件處理。
public sealed record Money
{
public decimal Amount { get; }
public Money(decimal amount)
{
if (amount <= 0) throw new DomainException("金額必須大於 0");
Amount = amount;
}
}
public sealed class Offer : AggregateRoot
{
public OfferId Id { get; }
public ListingId ListingId { get; } // 只用 Id 參照其他聚合
public BuyerId BuyerId { get; }
public Money Price { get; }
public Money EarnestMoney { get; } // 斡旋金
public DateTimeOffset ExpiresAt { get; }
public OfferStatus Status { get; private set; }
private Offer(OfferId id, ListingId listingId, BuyerId buyerId,
Money price, Money earnestMoney, DateTimeOffset expiresAt)
{
Id = id;
ListingId = listingId;
BuyerId = buyerId;
Price = price;
EarnestMoney = earnestMoney;
ExpiresAt = expiresAt;
Status = OfferStatus.Submitted;
}
public static Offer Submit(ListingId listingId, BuyerId buyerId,
Money price, Money earnestMoney, DateTimeOffset expiresAt, DateTimeOffset now)
{
if (expiresAt <= now) throw new DomainException("斡旋期限必須晚於現在");
var offer = new Offer(OfferId.New(), listingId, buyerId, price, earnestMoney, expiresAt);
offer.AddEvent(new OfferSubmitted(offer.Id, listingId, price.Amount));
return offer;
}
public void Accept(DateTimeOffset now)
{
if (Status != OfferStatus.Submitted)
throw new DomainException($"斡旋狀態為 {Status},不能接受");
if (now > ExpiresAt)
throw new DomainException("斡旋已過期,不能接受");
Status = OfferStatus.Accepted;
AddEvent(new OfferAccepted(Id, ListingId, BuyerId, Price.Amount));
}
public void Withdraw(DateTimeOffset now)
{
if (Status != OfferStatus.Submitted)
throw new DomainException($"斡旋狀態為 {Status},不能撤回");
Status = OfferStatus.Withdrawn;
AddEvent(new OfferWithdrawn(Id, ListingId));
}
}
幾個重點:
- 沒有公開的 setter:狀態只能透過
Accept()、Withdraw()這些有業務意義的方法改變,規則不會被繞過 - 時間從外面傳入:
now由呼叫端提供,單元測試時可以指定任意時間 - 事件先記在聚合上:
AddEvent只是暫存,儲存時才和聚合一起寫入(例如寫進同一個資料庫的 Outbox 表),避免「資料存了、事件沒送」
應用服務只負責協調:
public sealed class AcceptOfferHandler
{
private readonly IOfferRepository _offers;
private readonly TimeProvider _clock;
public AcceptOfferHandler(IOfferRepository offers, TimeProvider clock)
{
_offers = offers;
_clock = clock;
}
public async Task Handle(AcceptOfferCommand command, CancellationToken ct)
{
var offer = await _offers.GetAsync(command.OfferId, ct)
?? throw new NotFoundException($"找不到斡旋 {command.OfferId}");
offer.Accept(_clock.GetUtcNow());
await _offers.SaveAsync(offer, ct); // 同一個交易:更新聚合 + 寫入 Outbox
}
}
如果把 Listing 和所有 Offer 放進同一個大聚合,「接受斡旋」和「物件改狀態」就能在一個交易裡完成,看起來比較簡單。但代價是每次有人提出斡旋、經紀人改刊登文案,都要鎖住同一個大聚合,熱門物件很容易出現併發衝突。先問業務:物件狀態晚幾秒更新,會不會有問題? 大多數時候答案是不會,那就應該拆開。
協作探索:Event Storming
前面的子域、上下文、聚合都需要一個前提:真的理解業務。這份理解在領域專家(業務、PO、資深經紀人)的腦袋裡,不在需求文件裡。
Event Storming 是 Alberto Brandolini 提出的工作坊形式:把領域專家和開發人員找到同一面牆前,用不同顏色的便利貼,沿著時間軸貼出業務上發生的所有事件(橘色),再補上觸發事件的命令(藍色)、角色、政策、外部系統,以及有疑問的熱點。
它有三種層次:
| 層次 | 目的 | 產出 |
|---|---|---|
| Big Picture | 看清楚整個業務的全貌 | 完整的事件時間軸、熱點、子域與上下文的候選邊界 |
| Process Modeling | 拆解單一流程 | 命令、政策、讀取模型、角色之間的完整流程 |
| Software Design | 設計程式 | 聚合的候選、每個聚合負責的命令與不變條件 |
一場 Big Picture 下來,最有價值的常常不是便利貼本身,而是大家發現彼此的理解不一樣:業務部門以為「斡旋被接受」就是成交,財務部門以為「簽約完成」才是成交,這就是一個熱點,也是一個可能的上下文邊界。實際怎麼準備、怎麼引導,之後會另外整理一篇。
和六角形架構、CQRS 的關係
DDD 不是一種架構,而是一種設計方法,它和其他架構模式是互補的:
- 六角形架構決定「依賴方向」:戰術設計的聚合、值物件、領域事件放在核心,Repository 是核心定義的 Port,EF Core 實作是 Adapter
- CQRS 決定「讀寫分開」:聚合只負責寫入時的規則,查詢需求(例如搜尋列表)不需要經過聚合,直接讀取讀取模型
- 領域事件是兩者之間的橋梁:聚合發出事件,讀取模型根據事件更新
什麼時候不需要 DDD
- 以 CRUD 為主的系統:後台設定頁、簡單的資料維護,沒有什麼業務規則,用聚合和領域事件只會增加複雜度
- 找不到領域專家:DDD 的前提是和懂業務的人持續對話。如果需求只能從文件和截圖猜,通用語言就建立不起來
- 通用子域:會員登入、檔案上傳這類問題,用現成方案就好
同一個系統裡,核心子域用完整的 DDD,其他部分用簡單的做法,這才是 DDD 建議的投資方式。
常見誤解
- DDD 等於微服務:限界上下文可以是單體裡的一個模組。先在單體裡把邊界切清楚,之後要拆服務才拆得動;邊界沒切清楚就拆成微服務,只會得到分散式的大泥球
- 有 Entity、Repository 資料夾就是 DDD:如果業務規則還是寫在 Service 裡、Entity 只有 getter/setter(貧血模型),那只是換了資料夾名稱
- 一個聚合對應一張資料表:聚合是一致性邊界,不是資料表。一個聚合可以存成多張表,也可以存成一份 JSON
- 一開始就要設計出正確的模型:模型會隨著對業務的理解而演進。Evans 稱之為「突破」:某次討論後發現一個更好的概念,模型就要跟著重構
一句話總結
DDD 的重點不是 Entity 和 Repository,而是先和領域專家一起把業務攤開(Event Storming),建立團隊共用的語言,依照業務的重要性和語言的差異切出限界上下文(戰略設計),最後才在每個上下文裡用聚合守住真正需要即時一致的規則、用領域事件串起其他部分(戰術設計)。