← 回文章列表

2024年1月16日

DDD 概論:戰略設計、戰術設計與 Event Storming,先搞懂它到底在解決什麼問題

DDD領域驅動設計架構設計系統設計.NET

在 六角形架構 那篇,我把業務規則都放進「核心」,外部技術變成可以抽換的 Adapter。但做完之後留下兩個問題:核心裡面的模型要怎麼設計? 以及系統越長越大時,一個核心要不要切成好幾個?要從哪裡切?

這兩個問題正好是 DDD(Domain-Driven Design,領域驅動設計)在回答的事。DDD 來自 Eric Evans 在 2003 年出版的同名書籍,二十年下來衍生出很多做法,這篇先把整體架構整理清楚,之後再針對子域劃分、Event Storming 這些主題分開深入。

DDD 在解決什麼問題

做房仲系統這幾年,最常遇到的困難不是技術,而是業務本身很複雜,而程式碼沒有反映出這份複雜:

  • 翻譯成本:業務說「這個物件已經有人斡旋了」,程式裡卻是 house.status = 3。每次需求討論都要在業務語言和程式碼之間翻譯,翻錯一次就是一個 Bug
  • 一個模型用到底:「物件」一張資料表,刊登要用、搜尋要用、成交要用、業績也要用。欄位越長越多,改一個欄位要擔心十個地方
  • 規則散落各處:「斡旋過期就不能接受」這條規則,前端檢查一次、API 檢查一次、排程又檢查一次,三個地方的判斷還不太一樣

DDD 的基本主張是:軟體的核心複雜度來自業務領域本身,所以設計要從理解業務開始,讓程式碼的結構和用詞直接對應業務。

全貌:戰略、戰術、協作探索

DDD 的全貌:左邊是戰略設計,在問題空間把業務分成核心、支撐、通用子域,在解法空間把系統切成刊登、搜尋、成交等限界上下文,同一個「物件」在不同上下文有不同模型,上下文之間的關係用上下文映射描述;右邊是戰術設計,放大成交上下文,裡面由 Application Service 呼叫 Offer 聚合,聚合根 Offer 是實體,價格、斡旋金、期限是值物件,用 ListingId 參照其他聚合,狀態改變時發出 OfferAccepted 領域事件,透過 Repository 以聚合為單位存取;底部是協作探索,用 Event Storming 和 Domain Storytelling 和領域專家一起找出事件、語言與邊界

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》整理了四個設計原則:

  1. 在邊界內保護真正的不變條件:只有「必須同時成立」的規則才放在同一個聚合
  2. 設計小的聚合:大聚合讀取慢、容易衝突,很多規則其實不需要即時一致
  3. 用 Id 參照其他聚合:Offer 存的是 ListingId,不是整個 Listing 物件
  4. 邊界外用最終一致:一個交易只修改一個聚合,其他聚合透過領域事件在之後更新

用斡旋來看:「斡旋過期不能接受」、「已經接受的斡旋不能再撤回」是 Offer 自己的規則,放在聚合裡;「斡旋被接受後,物件要標記為已斡旋、其他斡旋要通知失效」則牽涉其他聚合,透過事件處理。

C#
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 表),避免「資料存了、事件沒送」

應用服務只負責協調:

C#
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
    }
}

一次「接受斡旋」的處理流程:屋主發出接受斡旋的命令,Offer 聚合檢查狀態為已提出且未過期,通過後狀態改為已接受並發出 OfferAccepted 事件,這三步在交易 1 內完成,只修改 Offer 一個聚合;事件送達後,政策「斡旋被接受時,物件改為已斡旋」觸發,Listing 聚合在交易 2 中把狀態改為已斡旋並發出 ListingUnderOffer 事件,這是最終一致。聚合之間不共用交易,用 Id 參照、用領域事件串接

如果把 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),建立團隊共用的語言,依照業務的重要性和語言的差異切出限界上下文(戰略設計),最後才在每個上下文裡用聚合守住真正需要即時一致的規則、用領域事件串起其他部分(戰術設計)。