領域驅動設計初識

Tommy Cheese | · 閱讀約需 7 分鐘

領域驅動設計概述

領域驅動設計(DDD,Domain-Driven Design)是一種圍繞業務領域構建軟體的設計方法。開發人員與領域專家共同探索業務知識,形成領域模型,並讓模型中的概念、規則和語言體現在程式碼中,從而幫助團隊理解和維護複雜業務。

DDD 包含戰略設計與戰術設計兩個相互影響的層面:戰略設計關注子域、限界上下文及其協作關係;戰術設計關注上下文內部的實體、值物件、聚合、領域服務和領域事件等。它們需要在實踐中持續迭代。

電商示例:業務子域與限界上下文相對應,訂單上下文內部包含訂單聚合,聚合根本身是實體。
圖中是一種可能的設計:子域與限界上下文的對應關係需要建模決定;聚合位於具體上下文內部。

戰略設計相關概念

領域

領域是軟體所關注的業務知識與活動範圍。例如,電商系統可能涉及商品銷售、支付結算與訂單履約。理解領域需要討論業務目標、流程和規則,而不只是列出系統功能。

子域

可以按業務能力將領域劃分為子域,再根據它們對企業競爭優勢的貢獻以及是否存在成熟通用解法,區分以下類型:

  • 核心子域:體現企業差異化和競爭優勢的業務能力,值得投入更多建模與研發資源。
  • 支援子域:業務運行所需、具有企業特定需求,但不直接提供核心競爭優勢的能力。
  • 通用子域:行業已有成熟解決方案、通常可以購買或復用的能力。

這種分類取決於企業的業務。例如,身分認證對普通電商可能是通用子域,對身分服務提供商卻可能是核心子域。“被多個模組使用”本身並不能證明某項能力屬於通用子域。

通用語言 Ubiquitous Language

領域專家與開發人員應在同一限界上下文內建立並持續使用一套共同語言,讓討論、文檔與程式碼中的術語含義一致。例如,“訂單已支付”是否要求全額付款、何時允許取消,都需要形成共同理解。通用語言也常譯為統一語言;它在上下文內保持一致,不要求整個企業使用一個模型。

限界上下文

限界上下文(Bounded Context)是一個特定領域模型及其語言保持一致、有效的適用邊界。子域描述業務問題的劃分,限界上下文描述模型的邊界,兩者不應被定義成固定的包含關係。設計時可以爭取清晰的對應關係,但也需要考慮既有系統和組織約束。

例如,同一個“商品”在銷售上下文中可能關注售價與優惠規則,在倉儲上下文中關注庫存位置與出入庫規則。它們可以通過商品識別碼關聯,卻不必共享同一個包含所有屬性和行為的商品類。

完整業務流程可以跨越多個上下文:下單、支付和發貨可以由訂單、支付和履約上下文協作完成。上下文映射(Context Map)用於明確這些關係與集成契約;必要時可以通過防腐層轉換外部模型,保護自己的模型。

限界上下文也是劃分服務的重要依據,但不等於部署單元。DDD 可以用於模組化單體,也可以用於微服務;一個上下文可以由多個模組或物理服務實作,不能機械地要求“一個上下文對應一個微服務”。

戰術設計相關概念

值物件和實體

區分實體與值物件,要看業務是否需要持續追蹤它的身分:

  • 實體(Entity):通過身分識別的領域物件。例如,訂單金額或狀態發生變化後,具有同一訂單識別碼的物件仍代表同一筆訂單;不同識別碼的兩筆訂單即使金額相同,也不是同一實體。
  • 值物件(Value Object):沒有需要追蹤的獨立身分,按有業務意義的屬性值比較。例如,在相同金額與幣種下,兩個金額物件可以互相替代;金額相同但幣種不同則不相等。

值物件通常設計為不可變物件,變化時用新值替換舊值,避免共享引用帶來的意外修改。實體也可以採用不可變的實作;相等性由身分或值語義決定,不由是否可變決定。同一個概念在不同上下文中也可能有不同建模方式。

在複雜業務中,實體、值物件和聚合根應封裝適合自身的業務規則。例如,訂單可以通過“取消訂單”行為檢查是否允許取消,而不是讓調用方任意修改狀態。應用層負責用例編排,資料庫訪問等技術細節由基礎設施承擔;不需要讓實體直接調用資料庫才能擁有豐富的領域行為。

只有資料和 getter/setter、業務規則全部外置的設計通常被稱為貧血領域模型。它不應作為複雜領域建模的普遍推薦,但簡單 CRUD 場景可以採用較簡單的資料模型。POJO 指普通 Java 物件,本身並不限制物件是否包含業務邏輯。

儲存庫 Repository

儲存庫(也常譯為倉儲)提供類似領域物件集合的訪問介面,隱藏查詢、物件映射與存儲細節。在 DDD 中,通常圍繞需要持久化和直接訪問的聚合根設計儲存庫,而不是為每張表或每個內部實體分別設計一個儲存庫。

介面應表達清楚的領域意圖,例如按訂單識別碼載入訂單、儲存訂單聚合。方法名稱是否簡短不是設計標準;也應避免把資料庫字段和任意單表更新直接暴露給領域層。

可以使用 MyBatis 實作儲存庫。Mapper 是否能直接承擔儲存庫介面,取決於它是否滿足聚合載入、儲存與封裝要求;普通單表 CRUD Mapper 不能僅因為提供存儲操作就與 DDD 儲存庫等同。

聚合與聚合根

聚合(Aggregate)是維護業務不變量的交易一致性邊界。不變量是每次業務更新提交時都必須成立的規則。聚合可以包含多個實體和值物件,也可以只有一個根實體。聚合根(Aggregate Root)本身是實體,負責作為訪問入口並保護聚合內部的規則。

例如,假設某個訂單模型要求訂單總金額始終等於明細金額之和,可以把訂單與訂單明細放在同一聚合中,由訂單根實體統一處理明細變更。這裡決定邊界的是一致性要求,不是“所有有關聯的物件都應該放在一起”。

  • 需要修改聚合內部狀態時,外部程式碼應通過聚合根的行為完成,避免繞過它修改內部實體、破壞業務規則。不可變值物件可以安全地作為回傳值。
  • 一次業務更新提交時,聚合內必須滿足需要立即保持的規則,例如明細數量必須為正、總金額計算正確。持久化時還需要適當的交易與併發控制。
  • 跨聚合關聯通常優先使用識別碼;跨聚合協作可採用領域事件和最終一致性。是否需要更強的一致性,應由業務要求決定,不能僅憑物件有關聯就把它們全部合併。

“取出、修改、儲存”可以描述一次用例,但不意味著所有讀取都必須包在覆蓋全過程的資料庫交易中。聚合邊界表達一致性要求,具體交易和併發策略還需要在實作中確定。

領域事件 Domain Events

領域事件表達領域中已經發生、且有業務意義的事實,例如“訂單已取消”。它應由業務語義與規則決定,而不是實體任意屬性一變化就自動產生。名稱常採用過去式,事件攜帶的事實通常保持不可變。

上下文內部的領域事件可以在進程內同步或異步分發。需要跨上下文傳播時,通常將提交成功後的業務事實轉換為集成事件,通過消息系統交給其他服務處理;消息隊列是實作選擇,不是領域事件定義的一部分。

例如,訂單取消後,可以通知庫存上下文釋放預留庫存。事件記錄的“取消已發生”不能被改寫成未發生,但業務可以有後續處理或補償動作;不能將事件不可變理解為所有業務結果都無法調整。

領域服務 Domain Service

當某個重要的領域操作不能自然歸屬於一個實體或值物件時,可以將它建模為領域服務。例如,在一種需要綜合目的地、運輸方式和承運商報價的模型中,運費計算可以由領域服務承擔。是否需要服務,取決於行為歸屬,而不是涉及的物件數量。

領域服務通常無狀態,按通用語言命名服務及其操作。“將明細加入訂單”如果自然屬於訂單聚合,就可以直接由訂單行為完成,不必另建一個同步實體狀態的服務。

應用服務負責接收用例請求、載入聚合、調用領域行為、協調儲存與交易等流程;領域服務負責其中不能歸屬其他領域物件的業務規則。兩者都不等同於一個獨立部署的微服務。

DDD 領域建模

可以用下面的順序開始建模,並隨著新知識和實作反饋不斷回到前面的步驟:

  1. 與領域專家討論典型業務場景、規則和目標,形成通用語言,並識別初步子域。
  2. 確定模型在哪些邊界內保持一致,劃分限界上下文,描述上下文之間的協作與集成契約。
  3. 在每個上下文內識別實體、值物件和業務不變量,據此設計聚合邊界、聚合根及其行為。
  4. 按需要設計領域服務、領域事件、儲存庫與物件創建方式,並明確應用層和基礎設施的職責。
  5. 用具體場景檢驗模型,驗證關鍵規則;當業務理解發生變化時,同時調整程式碼、語言和模型邊界。

先建立初步戰略劃分、再深化戰術模型是一種實用的起點,但不是不可回溯的兩個階段。DDD 的重點是通過持續協作和反饋,讓軟體模型準確表達業務;應根據業務複雜度選擇模式,避免為了湊齊概念而增加結構。

參考資料

留言

使用 GitHub 帳號參與討論,所有語言版本共用此留言區。

輸入首頁、新聞、前沿研究等跳轉頁面,或搜尋文章。

↑↓ 選擇 ↵ 開啟 Esc 關閉