
本文示例項目代碼倉庫:https://github.com/lowkeyfish/IDDD
1、前言
1.1、理解并用好 DDD 仍舊是一個挑戰(zhàn)
領(lǐng)域驅(qū)動設計(Domain-Driven Design,簡稱 DDD)是一種軟件開發(fā)方法論,由 Eric Evans 在 2003 年提出。他在 2003 年出版了一本名為《領(lǐng)域驅(qū)動設計:軟件核心復雜性應對之道》(Domain-Driven Design: Tackling Complexity in the Heart of Software)的著作,詳細介紹了這一方法論。我們頻繁地聽到 DDD 的名字,也經(jīng)常討論它使用它,但它卻慢慢變得和面向?qū)ο缶幊蹋∣bject Oriented Programming,簡稱 OOP)一樣,看似已經(jīng)融入了我們的編程生活,但真正理解它并用好它卻并不容易。
許多開發(fā)者了解或者嘗試 DDD 都是因為在編程中遇到了問題,希望找到一種方法來幫助自己解決問題,我們期待 DDD 就是那個解決方案。關(guān)于 DDD 的書籍和文章數(shù)不勝數(shù),但是許多開發(fā)者未能從中找到落地實踐 DDD 的方法,特別是淺顯易懂的方法。經(jīng)典書籍無論我們何時翻閱,總能給我們帶來新的啟示和認識。然而,對于新手來說,這些理論可能過于抽象,不夠直觀,示例項目也往往和我們的實際需求和經(jīng)歷相去甚遠。因此,盡管我們閱讀了大量的書籍,但在實際操作時仍然感到無所適從。有時,我們甚至會開始懷疑自己的選擇,認為 DDD 只是為解決復雜項目問題而生,并不適合我們的項目。
實際上,很大一部分原因是我們對 DDD 的理解還不夠深入,對于 DDD 能解決什么問題,如何解決這些問題并不十分清楚。我們對 DDD 的認知可能還僅限于一些基本概念,可能許多人并未有過完整的 DDD 項目實踐,對如何在項目中使用 DDD 沒有一個完整的概念。這也是這篇文章的意義,通過詳細的概念講解和一個簡單的項目實踐讓更多向往 DDD 但不得其解的開發(fā)者全面了解 DDD,用上 DDD。
1.2、為什么選擇 DDD 作為編程指導思想
代碼即文檔,這是很多開發(fā)者經(jīng)常掛在嘴邊的一句話,雖然經(jīng)常用來作為不寫注釋的理由。但這是有前提的,能夠良好體現(xiàn)業(yè)務需求的代碼才是文檔。只能由機器執(zhí)行得到結(jié)果的代碼還遠遠稱不上文檔,只能從這些機器才能理解的代碼中了解需求的困境應該很多人都體驗過,但是針對這個問題大家既是受害者也是始作俑者。好的代碼不僅是需求的一種實現(xiàn)方式,同時也能體現(xiàn)出需求的意圖,反之不好的代碼總是讓人產(chǎn)生一種它為什么這么寫的疑惑。
雖然將程序描述為數(shù)據(jù)結(jié)構(gòu)加算法有些過于籠統(tǒng),但是大部分業(yè)務代碼確實可以理解為輸入處理、業(yè)務邏輯、數(shù)據(jù)持久化和輸出處理。在這種基本劃分下開發(fā)者基于自上而下的分層架構(gòu)和 controller、service、dao 等基本概念,再加上天生就會的面向過程的事務腳本的編碼方式就能完成需求的開發(fā),簡單粗暴有時候甚至好用。但隨著業(yè)務的發(fā)展這樣的設計難以為繼,對于代碼的設計,開發(fā)者沒有一個不需要太多強制就能默認達到的共識。隨著時間推移、人員變更,代碼會越來越亂,代碼逐漸以追求執(zhí)行結(jié)果為目標,為了實現(xiàn)目標不在乎打了多少補丁,寫了多少匪夷所思的邏輯。這也是導致代碼不能清晰體現(xiàn)出需求的根本原因。
DDD 通過建立豐富的領(lǐng)域模型,使業(yè)務邏輯在代碼中的表述更加清晰直觀。通過聚合和領(lǐng)域服務等概念為處理復雜業(yè)務規(guī)則提供了工具。DDD 倡導的設計原則和模式(如實體、值對象、領(lǐng)域事件等)可以幫助我們創(chuàng)建高內(nèi)聚、低耦合的系統(tǒng)。通過反映業(yè)務領(lǐng)域的真實情況,DDD 使得軟件更容易適應業(yè)務的變化。
1.3、關(guān)于示例項目
理論結(jié)合實踐才能獲得更深的理解和更全面的學習,整篇文章的討論我們都基于一個汽車經(jīng)銷商網(wǎng)上店鋪的項目進行討論。汽車經(jīng)銷商網(wǎng)上店鋪的示例項目主要分為兩個子項目:車輛產(chǎn)品庫項目和網(wǎng)上店鋪項目。車輛產(chǎn)品庫項目主要用于管理汽車品牌、車系和車型數(shù)據(jù),之所以獨立出一個項目主要是為了演示 DDD 中如何依賴其他系統(tǒng)提供的功能。網(wǎng)上店鋪項目主要包含經(jīng)銷商數(shù)據(jù)管理、經(jīng)銷商服務購買以及訂單的支付、退款等。示例項目的完整源代碼可以通過 https://github.com/lowkeyfish/IDDD 獲取。
2、概念詳解
DDD 是一種方法論,由許多小的概念組成,例如大家耳熟能詳?shù)膶嶓w、值對象、聚合根、領(lǐng)域服務等。有些概念是抽象的,例如上下文。有些概念是具體的,是代碼中會明確體現(xiàn)的,例如實體、值對象、聚合根等。之所以能分出這些概念,就是因為它們相互不同,有本質(zhì)差異,且在 DDD 中分別承擔了不可或缺的職責。因此準確理解并用好這些概念才能發(fā)揮出 DDD 的價值。
對于 DDD 基本概念的定義大家都不陌生,因此我們不再過多復述對它們的定義,著重討論這些概念的本質(zhì),以及如何正確使用它們,從而讓大家可以對 DDD 有一個整體上的清晰認識,能夠準確辨識出 DDD 和其他編程方式的不同。
2.1、聚合根(Aggregate Root)、實體(Entity)、值對象(Value Object)
編程中可以寬泛的將操作分作兩大類:命令和查詢。所有命令類的操作都可以最終轉(zhuǎn)換為業(yè)務數(shù)據(jù)的變更。業(yè)務數(shù)據(jù)變更主要的挑戰(zhàn)是對業(yè)務數(shù)據(jù)一致性和完整性的保證。DDD 提供了聚合根、實體和值對象用于業(yè)務數(shù)據(jù)的建模,它們可以說是 DDD 最核心的概念,同時也是相對于事務腳本的編程方式最大的不同。在接下來的部分,我們將深入探討聚合根、實體以及值對象,并提供關(guān)于如何有效使用它們的實踐建議。
示例項目中的經(jīng)銷商數(shù)據(jù)管理,按以往的編程方式,我們先在數(shù)據(jù)庫建一個經(jīng)銷商表(dealer),表中的主鍵作為經(jīng)銷商的 ID。代碼中會有一個和數(shù)據(jù)表字段對應的類,這個數(shù)據(jù)類對外方法只有 getter 和 setter,調(diào)用 setter 方法就可以直接修改數(shù)據(jù),數(shù)據(jù)是否可以修改,修改成什么值都是有事務腳本也就是我們經(jīng)常說的 service 控制。在 DDD 中,我們會將經(jīng)銷商相關(guān)的業(yè)務概念建模為實體(Dealer),實體的屬性用于存儲經(jīng)銷商相關(guān)的業(yè)務數(shù)據(jù),但并不一定要按照數(shù)據(jù)庫表的結(jié)構(gòu)去設計實體的屬性。且在實體中你看不到之前熟悉的公共的 getter 和 setter 方法,因為實體不僅是數(shù)據(jù)同時也是業(yè)務行為的封裝。借助實體,調(diào)用者的關(guān)注點從數(shù)據(jù)操作轉(zhuǎn)變?yōu)榱藰I(yè)務行為的執(zhí)行。
對于不需要唯一標識且具有不變性的對象在 DDD 中被稱為值對象,值對象的等價性是基于它們的屬性值,而不是某個唯一標識。值對象一般被用作一組相關(guān)屬性的集合,實體 Dealer 中唯一標識 DealerId 和地址 Address 都屬于值對象。
對于業(yè)務場景中涉及的實體和值對象,在整個生命周期中作為一個整體維護它們的一致性和完整性是很有挑戰(zhàn)的。為此 DDD 提出了聚合的領(lǐng)域概念。聚合有一個聚合根,聚合根是聚合內(nèi)的一個特定實體,對于經(jīng)銷商聚合來說,我們選用 Dealer 實體作為聚合根。由 Dealer 作為聚合根管理經(jīng)銷商相關(guān)的業(yè)務數(shù)據(jù),并對外提供業(yè)務方法管理內(nèi)部實體和值對象的一致性和完整性。
以上只是對聚合根、實體和值對象的最基本認識,接下來我們通過以下幾點詳細討論如何更好的使用它們。
2.1.1、使用有參構(gòu)造函數(shù)和靜態(tài)方法初始化
如果一個類只作為數(shù)據(jù)容器,除了默認的無參構(gòu)造函數(shù)我們一般不需要再提供有參構(gòu)造函數(shù)。但是聚合根、實體和值對象不止是數(shù)據(jù)容器,更有對業(yè)務規(guī)則的封裝。聚合根、實體和值對象必須保證其內(nèi)部數(shù)據(jù)的一致性和完整性,除了業(yè)務方法外它們不會對外提供針對內(nèi)部字段修改的 setter 方法,因此必須保證它們在初始化后其數(shù)據(jù)狀態(tài)就必須是滿足業(yè)務規(guī)則的有效的狀態(tài)。只有聚合根、實體和值對象使用有參構(gòu)造函數(shù)進行初始化,且在初始化時對參數(shù)進行校驗才能滿足這個要求。聚合根、實體和值對象的實例必須通過有參構(gòu)造函數(shù)進行創(chuàng)建,這一做法與傳統(tǒng)的編程方式形成了鮮明的對比。
聚合根和實體需要為不同場景提供不同的構(gòu)造方式,分別用于調(diào)用方首次創(chuàng)建聚合根和通過倉庫查詢后重建聚合根。首次創(chuàng)建聚合根時使用的靜態(tài)方法僅需提供核心參數(shù)即可,倉庫重建聚合根需要提供全部的屬性值,這些值使用上次聚合根已保存數(shù)據(jù)。
例如活動聚合根針對不同場景提供不同的方式:
// 用于倉庫重建聚合根
public Activity(
DealerId dealerId,
ActivityId id,
String name,
String summary,
String image,
TimeRange registrationTimeRange,
TimeRange participationTimeRange,
int participantLimit,
List<ActivityGift> gifts,
ActivityStatusType registrationStatus,
ActivityStatusType participationStatus,
boolean deleted
);
// 用于首次創(chuàng)建聚合根
public static Activity newActivity(
DealerId dealerId,
ActivityId id,
String name,
String summary,
String image,
TimeRange registrationTimeRange,
TimeRange participationTimeRange,
int participantLimit,
Map<GiftId, Integer> gifts
);
兩個方式的參數(shù)類型也有差異,倉庫重建聚合根的參數(shù)一般與聚合根內(nèi)部字段保持一致,而首次創(chuàng)建聚合根的靜態(tài)方法參數(shù)一般有以下幾點需要注意:
- 參數(shù)盡量避免不必要的依賴。例如聚合根需要
DealerId,就不能接收Dealer,避免和Dealer聚合根產(chǎn)生不必要的依賴。即使同時需要經(jīng)銷商聚合根唯一標識和名稱,也盡量使用兩個參數(shù)分別接收而非接收Dealer。只有內(nèi)部確實需要Dealer(例如需要Dealer的方法)時才直接接收Dealer類型參數(shù),因為此時必須和Dealer聚合根產(chǎn)生依賴了。 - 參數(shù)不能直接提供聚合根內(nèi)部實體的引用。例如創(chuàng)建聚合根時禮品參數(shù)類型為
Map<GiftId, Integer>而非List<ActivityGift>,主要是為了防止外部和聚合根內(nèi)部使用同一份實體引用,從而外部可以繞過聚合根直接修改實體的數(shù)據(jù)狀態(tài)。 - 在靜態(tài)方法中需要對接收的參數(shù)進行數(shù)據(jù)一致性和完整性校驗。如果不滿足需要直接拋出異常。而用于倉庫重建聚合根的構(gòu)造函數(shù)不需要再對參數(shù)進行校驗。
2.1.2、聚合根是聚合的唯一入口
通過聚合和聚合根,我們可以將相關(guān)的實體和值對象組織在一起,定義嚴格的邊界,封裝內(nèi)部的業(yè)務規(guī)則,確保業(yè)務的一致性和完整性,聚合根是對外界可見的唯一入口。聚合根不能對外提供聚合根內(nèi)部實體的引用。因為一旦外部可以獲取到內(nèi)部的實體引用就可以繞過聚合根修改實體的狀態(tài),這些操作會導致聚合根整體數(shù)據(jù)的一致性和完整性問題。
2.1.3、聚合根、實體和值對象一定是充血模型
聚合根、實體和值對象都是數(shù)據(jù)和業(yè)務規(guī)則的封裝。外部對象只能通過調(diào)用聚合根和實體的方法完成其狀態(tài)的變更,值對象也通過提供的方法返回狀態(tài)變更后的新的值對象實例,因此它們一定是充血模型。如果按照僅有 getter 和 setter 的 Java Bean 來實現(xiàn),那一定不是 DDD。
2.1.4、把聚合根作為命令模型實現(xiàn)
命令查詢職責分離(Command Query Responsibility Segregation,簡稱 CQRS)模式將應用的讀操作和寫操作分離開來,使得我們可以獨立地擴展和優(yōu)化它們。
將聚合根作為命令模型實現(xiàn)的確可以大大降低聚合根的復雜性。這是因為在這種模式下,聚合根主要關(guān)注的是業(yè)務行為,而不是數(shù)據(jù)查詢。這樣,聚合根可以專注于處理復雜的業(yè)務規(guī)則和確保業(yè)務數(shù)據(jù)的一致性,而無需關(guān)注各種查詢需求。
此外,這種模式還能夠避免數(shù)據(jù)模型的膨脹和分裂。在傳統(tǒng)的模型中,為了滿足各種查詢需求,我們可能需要不斷地添加新的字段或者新的關(guān)聯(lián)關(guān)系,導致數(shù)據(jù)模型變得越來越復雜。將聚合根作為命令模型實現(xiàn),可以避免對聚合根的過度復雜化。
2.1.5、聚合根數(shù)據(jù)驗證的范疇
聚合根 Dealer 使用 BrandId 標識其售賣的品牌。業(yè)務中我們必須保證使用的 BrandId 確實有對應的品牌,不能是一個無效的 ID。但 Dealer 創(chuàng)建時其構(gòu)造函數(shù)只需要驗證傳入的 BrandId 是否為 null,不需要驗證 BrandId 是否存在 Brand。驗證 BrandId 是否存在 Brand 屬于創(chuàng)建 Dealer 業(yè)務流程中必須保證的業(yè)務規(guī)則,但不是 Dealer 聚合根的職責,應該在應用服務即 DealerApplicationService 中執(zhí)行這個規(guī)則驗證。
總結(jié)來說聚合根(包括用于創(chuàng)建聚合根的工廠)僅需對聚合自身數(shù)據(jù)進行一致性和完整性驗證,自身數(shù)據(jù)關(guān)聯(lián)對象的驗證雖然也屬于業(yè)務規(guī)則但并不屬于聚合根的職責。為什么這樣做是更好的呢?假設我們想讓 Dealer 聚合根創(chuàng)建時驗證其關(guān)聯(lián)的 BrandId 必須有對應的 Brand。我們可以通過以下幾點分析下帶來哪些問題:
- 如果接收
BrandId,那獲取到Brand并驗證就需要Dealer具有查詢Brand的能力,就需要讓Dealer增加對BrandService的依賴。如果你需要同時創(chuàng)建多個Dealer的實例,就會存在多次重復獲取Brand,這是不必要的重復操作。 - 你可能想到可以在應用服務層直接獲取到
Brand然后應用服務不校驗,傳給Dealer校驗。雖然這樣可以避免重復獲取Brand的問題,但是Dealer還是增加了對Brand的依賴,這實際上是沒必要的。 - 即使你不考慮額外依賴
Brand的問題。創(chuàng)建Dealer時傳的是Brand,構(gòu)造函數(shù)中就必須驗證Brand是否為 null,否則就可能在獲取BrandId時遇到空引用問題。但是如果應用服務層獲取到Brand后也需要做其他的規(guī)則判斷,例如,創(chuàng)建經(jīng)銷商時需要檢查使用的城市和品牌是否支持創(chuàng)建,這樣就出現(xiàn)了多處對同一業(yè)務規(guī)則做校驗的重復性操作。
2.1.6、聚合根的依賴
聚合根一般應該視為是一個用時創(chuàng)建用完回收的短生命周期對象。聚合根不能像 Service 一樣初始化時就依賴其他領(lǐng)域服務或資源庫。
如果聚合根自身無法完成某個操作,但又需要保證其內(nèi)部數(shù)據(jù)的一致性和完整性,那么聚合根就可以依賴其他的領(lǐng)域服務來完成這個操作。例如發(fā)起支付場景,支付單聚合根 PaymentOrder 使用方法 initiatePayment(PaymentService) 發(fā)起支付,用于向支付平臺發(fā)起支付的領(lǐng)域服務 PaymentService 就作為參數(shù)被 PaymentOrder.initiatePayment 方法使用。
聚合根不對外提供數(shù)據(jù)查詢,也不關(guān)注自身的獲取和持久化,因此聚合根不需要也不能依賴資源庫。
2.1.7、使用值對象作為唯一標識
使用值對象作為唯一標識相對于原始數(shù)據(jù)類型有以下優(yōu)勢:
- 更好的封裝性:值對象可以封裝一些與標識相關(guān)的行為和邏輯,而原始數(shù)據(jù)類型不能。
- 更高的類型安全性:使用值對象可以提供更高的類型安全性。假如一個方法同時接收活動 ID 和經(jīng)銷商 ID 作為參數(shù),使用
(ActivityId activityId, DealerId dealerId)比(String activityId, String dealerId)有更高的類型安全性。 - 更豐富的業(yè)務語義:值對象可以具有更豐富的業(yè)務語義。例如,
ActivityId比一段字符串更能表明其作為活動唯一標識的業(yè)務含義。
2.1.8、聚合根的粒度應該盡量小
在可能的情況下盡量將聚合根設計的足夠小。例如,經(jīng)銷商服務購買場景我們會設計三個聚合根:訂單聚合根 DealerServicePurchaseOrder、支付單聚合根 PaymentOrder、退款單聚合根 RefundOrder。雖然這三個聚合根在業(yè)務上存在關(guān)聯(lián),但是我們不會將支付單和退款單作為訂單聚合根的內(nèi)部數(shù)據(jù),因為這樣做存在以下缺點:
- 如果支付單和退款單屬于訂單內(nèi)部數(shù)據(jù),因此操作支付單和退款單都需要對訂單加鎖,這對并發(fā)控制和性能優(yōu)化非常不利。
- 每次重建訂單聚合根都需要獲取和處理大量的數(shù)據(jù),可能會對性能產(chǎn)生影響。
- 聚合的所有操作必須通過訂單聚合根作為入口,可能沒有支付單、退款單獨自作為聚合根時直觀和便利。
總的來說,設計小的聚合可以使得聚合更加內(nèi)聚,關(guān)注的業(yè)務范圍更小,更符合單一職責原則。當不同的聚合有業(yè)務上的關(guān)聯(lián)時,我們可以通過領(lǐng)域事件來協(xié)調(diào)它們的行為,實現(xiàn)聚合間的低耦合協(xié)作。
2.1.9、值對象應該是不可變的
值對象也是業(yè)務數(shù)據(jù)和行為的封裝,但值對象方法并不直接修改當前值對象實例的數(shù)據(jù)狀態(tài),如果值對象方法是用來修改當前值對象的狀態(tài),那么由于值對象是不可變的,這個方法通常會返回一個新的值對象(類似于 LocalDate.now().plusDays(1) 直接返回了一個新的實例)。讓值對象保持不變性使其創(chuàng)建后狀態(tài)就固定了,不會出現(xiàn)難以預料的狀態(tài)變化,使得值對象可以放心的被使用而不擔心被其他對象修改,使得代碼更易于理解和使用。
2.1.10、迪米特法則和 “Tell, Don't Ask” 原則
在 DDD 中,我們通過聚合根、實體和值對象封裝業(yè)務邏輯。這些對象不僅包含數(shù)據(jù),還包含操作數(shù)據(jù)的行為。我們應避免通過領(lǐng)域?qū)ο蟛樵兤鋽?shù)據(jù)狀態(tài),然后在外部進行業(yè)務邏輯判斷后再修改它的數(shù)據(jù)。應該直接告訴領(lǐng)域?qū)ο笠鍪裁矗屗约和瓿刹僮鳌?/p>
例如,支付單發(fā)起支付的場景,以往的編程方式調(diào)用方會主動詢問 PaymentOrder 的當前狀態(tài),如果當前狀態(tài)可以發(fā)起支付,調(diào)用方會調(diào)用 PaymentService 發(fā)起支付,然后再更新 PaymentOrder 狀態(tài)為已發(fā)起支付。在 DDD 中,調(diào)用方只需要調(diào)用 PaymentOrder.initiatePayment(PaymentService) 即可,PaymentOrder.initiatePayment 方法內(nèi)部自己保證只有在支付單狀態(tài)滿足時才發(fā)起支付,并在支付發(fā)起后更新其內(nèi)部數(shù)據(jù)狀態(tài),從而可以更好保證聚合根的數(shù)據(jù)狀態(tài)的正確性。
遵守這兩個原則可以使我們的代碼減少耦合、提高封裝性和可讀性。按照 DDD 思想正確實現(xiàn)的領(lǐng)域?qū)ο蠖寄軌蚝芎玫淖袷剡@兩個原則。
2.1.11、聚合根對外暴露的數(shù)據(jù)
聚合根可以對外暴露一些數(shù)據(jù),只是暴露數(shù)據(jù)前需要保證暴露的數(shù)據(jù)不會被外部修改而破壞聚合根狀態(tài)的正確性??梢詫ν獗┞兜臄?shù)據(jù)有:
- 唯一標識:聚合根的唯一標識通常需要對外暴露,它是外部用來引用聚合的唯一方式。
- 值對象:值對象是不可變的,可以安全的對外暴露。如果需要對外暴露內(nèi)部實體的數(shù)據(jù),也需要將其轉(zhuǎn)為值對象后再對外暴露。
- 快照:聚合根不對外提供 getter 方法,為了滿足聚合根的持久化,可以讓聚合根返回其數(shù)據(jù)狀態(tài)的快照,快照對象屬于值對象可以被安全的使用。對于一些 ORM 框架,可以通過反射獲取到聚合根的私有屬性,這種情況下就沒有必要再提供快照數(shù)據(jù)了。
2.1.12、單個事務中只允許對一個聚合根進行修改
單個事務中只對一個聚合根進行修改是 DDD 中一個非常重要的原則。遵守這個原則有以下好處:
并發(fā)操作加鎖更容易處理:因為一個事務中只修改一個聚合根,操作的聚合根是顯而易見的,可以方便的對正確的資源進行加鎖。如果一個事務中涉及多個聚合根,有些聚合根可能操作所處的流程比較深,甚至是中間步驟的數(shù)據(jù),不容易在流程開始位置加鎖。而且多個資源加鎖,更容易造成死鎖。
有助于避免處理修改多個聚合根時為保證數(shù)據(jù)一致性所帶來的復雜性:當我們需要在單個事務中修改多個聚合根時,通常需要使用更復雜的技術(shù),如分布式事務,以確保數(shù)據(jù)一致性。這不僅增加了系統(tǒng)的復雜性,還可能帶來性能問題。
有助于實現(xiàn)系統(tǒng)解耦:通過領(lǐng)域事件的發(fā)布和訂閱,不同聚合根之間可以實現(xiàn)解耦,能夠更容易進行微服務或模塊的拆分和獨立開發(fā)。
易于理解和維護:當一個事務中只涉及一個聚合根時,相關(guān)的業(yè)務邏輯會更清晰,代碼會更易于理解和維護。
更容易實現(xiàn)冪等操作:例如,訂單狀態(tài)設置為已支付,如果當前狀態(tài)已經(jīng)是已支付,訂單聚合根方法就可以不做任何操作,也不發(fā)布領(lǐng)域事件。因為數(shù)據(jù)沒有變動,所以訂單持久化也不會產(chǎn)生副作用。如果一個事務中不止涉及訂單的操作,訂單方法就必須在無法設置為已支付時拋出異常,因為這樣才能將整個流程中斷,避免其他操作因為訂單設置為已支付而執(zhí)行。
2.2、工廠(Factory)
當創(chuàng)建一個領(lǐng)域?qū)ο蠡蚓酆细鶗r,如果創(chuàng)建工作很復雜,或者暴露了過多的內(nèi)部結(jié)構(gòu),可以使用工廠進行封裝。
工廠存在的形式一般有三種:單獨的工廠類、聚合根用于自身元素添加而提供的工廠方法和領(lǐng)域?qū)ο鬄槠渌麑ο髣?chuàng)建而提供的工廠方法。
2.2.1、單獨的工廠類
例如,經(jīng)銷商服務購買場景中使用 DealerServicePurchaseOrderFactory 創(chuàng)建聚合根 DealerServicePurchaseOrder。雖然看起來創(chuàng)建邏輯不復雜,但是使用工廠類能夠使應用服務不用關(guān)注創(chuàng)建的細節(jié),使得代碼流程看起來更清晰流暢。
不過,一般還是建議優(yōu)先使用聚合根的構(gòu)造函數(shù),只有構(gòu)造函數(shù)無法滿足時再嘗試使用獨立的工廠類。
2.2.2、聚合根用于自身元素添加而提供的工廠方法
在聚合根上添加工廠方法用于為自身添加元素可以把聚合根的內(nèi)部實現(xiàn)細節(jié)隱藏起來,同時聚合根也可以確保添加元素時其自身數(shù)據(jù)的完整性。例如,活動聚合根提供用于添加禮品的工廠方法用于為自己添加更多的禮品。
2.2.3、領(lǐng)域?qū)ο鬄槠渌麑ο髣?chuàng)建而提供的工廠方法
一個對象與另一個對象密切相關(guān),但其并不擁有所生成的對象。當一個對象的創(chuàng)建主要使用另一個對象的數(shù)據(jù)或者規(guī)則時,可以在后者的對象上創(chuàng)建一個工廠方法,這樣就不必將后者的信息暴露出來。例如,支付單聚合根 PaymentOrder 上提供了工廠方法 generateRefundOrder 用于創(chuàng)建退款單聚合根 RefundOrder。
使用這種類型的工廠方法時我們需要避免一些誤區(qū)。例如,大部分聚合根和 Dealer 有關(guān)系,即大部分聚合根的創(chuàng)建都需要 DealerId,那么應該在 Dealer 中為這些和它有關(guān)的聚合根創(chuàng)建工廠方法嗎?
聚合根創(chuàng)建需要 DealerId,你需要對 DealerId 校驗其是否存在 Dealer,甚至還要判斷 Dealer 的一些狀態(tài)或權(quán)限。基于這些考慮,你可能會覺得在 Dealer 中為其它聚合根創(chuàng)建工廠方法好像挺好的,甚至使用起來還挺面向?qū)ο蟮?。那我們應該這么做嗎,結(jié)論當然是沒有必要這么做,甚至要避免這樣做。
原因當然是這樣做是弊大于利的,我們可以通過以下幾點來分析它的弊端:
-
Dealer后續(xù)會頻繁的為其它聚合根添加工廠方法,這也將導致Dealer類越來越臃腫。 - 一個聚合根可能會和多個聚合根有關(guān)系,那用于創(chuàng)建當前聚合根的工廠方法應該放到哪個聚合根上又會讓我們難以抉擇。例如,
DealerModel表示經(jīng)銷商在售車系,它需要DealerId和ModelId,為什么在Dealer上為其提供工廠方法而不是由Model提供。畢竟業(yè)務流程中也需要驗證ModelId是否存在車系甚至車系狀態(tài)。 - 很多時候不屬于一個業(yè)務場景的聚合根可能不在一個服務中部署,例如,
Dealer和Activity可能會部署到兩個服務中,Activity所處的項目中就不存在Dealer聚合根。這種情況下你又面臨了無Dealer聚合根可用的問題。
總結(jié)來說,如果兩個聚合根處于同一個業(yè)務場景中,并且它們之間有強業(yè)務規(guī)則關(guān)聯(lián),那么在一個聚合根中為另一個聚合根提供工廠方法是很有意義的,例如,支付單和退款單。這樣可以保證業(yè)務規(guī)則的實施,并且使得領(lǐng)域模型更加符合業(yè)務邏輯。反之,就沒有必要在一個聚合根中為另一個聚合根提供工廠方法,這樣可以避免不必要的耦合,也使得領(lǐng)域模型更加清晰,也更容易維護和擴展。
2.3、資源庫(Repository)
資源庫用于存儲、檢索和刪除聚合根。每個聚合根都有其對應的資源庫,且只有聚合根才能有資源庫,每個資源庫也僅用于一個聚合根。資源庫的命名一般以聚合根名稱加后綴 Repository 組成。例如聚合根 Dealer 其資源庫為 DealerRepository。
聚合根是聚合對外的唯一入口,因此資源庫操作的對象必須是聚合根,即資源庫保存的對象是聚合根,查詢出的對象還是聚合根,這樣可以確保聚合的一致性。
資源庫一般會針對其聚合根提供必須的幾個方法,例如 DealerReponsitory 提供了 findById(DealerId)、save(Dealer) 和 remove(Dealer)。
因為資源庫操作的對象是聚合根,所以如果用資源庫執(zhí)行復雜的查詢,無論是復雜的查詢條件,還是查詢非聚合根粒度的數(shù)據(jù),使用資源庫都不是一種很舒服的方式。使用 CQRS 我們應該盡量把查詢相關(guān)的需求提到查詢服務中。這樣做的結(jié)果就是資源庫提供的方法就變得更單一了,資源庫提供的聚合根相關(guān)的方法也主要滿足寫操作下的需求即可。
使用資源庫通過標識查找聚合根,通過調(diào)用聚合根方法完成其狀態(tài)變更,然后使用資源庫保存聚合根。資源庫和聚合根的交互使用起來就是這么簡單。我們不應該要求資源庫承擔太多的職責。資源庫僅用于聚合根的管理,復雜的查詢、涉及多張表的連表查詢或者以性能為首要目標而只想返回部分數(shù)據(jù)的場景都不宜放到資源庫中。
當然資源庫也不是不能提供其他方法,但是如果將資源庫作為你唯一的持久化數(shù)據(jù)管理手段只會讓你感到使用時的局限性。例如查詢的對象必須是聚合根,如果不是用于寫操作,構(gòu)建聚合根是不必要的性能浪費。如果你為了性能不想返回聚合根,你只能返回值對象而非聚合根內(nèi)部的實體等可能破壞聚合根數(shù)據(jù)正確性的數(shù)據(jù)類型。明顯是用于讀操作場景的方法還是放到查詢服務中更合適。
2.4、領(lǐng)域服務(Domain Service)
聚合根、實體和值對象都是數(shù)據(jù)和業(yè)務規(guī)則的封裝,它們是領(lǐng)域?qū)拥暮诵模峭獠颗c領(lǐng)域?qū)咏换サ闹饕獙ο?。即使它們封裝了大部分的業(yè)務規(guī)則和業(yè)務邏輯,但還有一些不適合它們獨自處理的業(yè)務邏輯,領(lǐng)域服務就是作為補充這些缺失部分的角色存在的。
2.4.1、使用領(lǐng)域服務的場景
-
涉及多個聚合根的操作。
當業(yè)務邏輯涉及多個聚合根時,這些業(yè)務邏輯無論放到哪個聚合根上都不合適,就可以提出單獨的領(lǐng)域服務協(xié)調(diào)這些聚合根的狀態(tài)變更,這是領(lǐng)域服務的典型應用場景。
-
無法將邏輯自然的分配給聚合根、實體或值對象。
例如經(jīng)銷商名稱需要滿足唯一性檢查,這個操作無法放到經(jīng)銷商聚合根中,只能作為領(lǐng)域服務實現(xiàn)。
-
邏輯需要重用。
如果一個邏輯需要在多個聚合根之間共享和重用,可以將其定義為領(lǐng)域服務。
-
邏輯依賴外部資源或服務。
如果業(yè)務邏輯需要與外部系統(tǒng)或資源進行交互,可以將其封裝為領(lǐng)域服務。領(lǐng)域服務可以處理外部資源的訪問和適配,將領(lǐng)域?qū)ο笈c外部依賴解耦。
2.4.2、使用領(lǐng)域服務中的一些注意事項
- 聚合根、實體和值對象是領(lǐng)域的核心,領(lǐng)域服務應該作為它們的輔助,避免使用領(lǐng)域服務替代它們的所有行為,以免將聚合根、實體和值對象變成貧血模型。
- 領(lǐng)域服務屬于領(lǐng)域?qū)?,其方法定義入?yún)⒑头祷刂祽摱际穷I(lǐng)域?qū)ο?,不能是展示層或基礎(chǔ)設施層的對象。
- 領(lǐng)域服務是無狀態(tài)的。
- 領(lǐng)域服務應該滿足單一職責,且領(lǐng)域服務命名盡量滿足其業(yè)務操作場景,避免將不相關(guān)的領(lǐng)域操作放到一個領(lǐng)域服務中。
2.5、領(lǐng)域事件(Domain Event)
領(lǐng)域事件是 DDD 中除了聚合根、實體和值對象外最重要的概念,同時也是 DDD 中最不可或缺的組成部分。缺少了領(lǐng)域事件,其他概念的使用指導將會從最佳實踐變成枷鎖。
領(lǐng)域事件代表了在業(yè)務領(lǐng)域中發(fā)生的一些重要的、有意義的事件,比如 DealerCreated、ActivityCreated 等。這些事件通常由領(lǐng)域?qū)ο螅ㄈ缇酆细驅(qū)嶓w)在狀態(tài)變化時觸發(fā),并被其他的領(lǐng)域?qū)ο蠡蚍沼嗛喓吞幚怼?/p>
2.5.1、使用領(lǐng)域事件改變編碼方式
領(lǐng)域事件的最大的意義莫過于改變了編碼方式。領(lǐng)域事件對應業(yè)務中一些關(guān)鍵數(shù)據(jù)變更節(jié)點,這些關(guān)鍵節(jié)點也是項目迭代過程中最容易延伸出新的業(yè)務規(guī)則的地方。在傳統(tǒng)的過程式編程中,我們的代碼往往是命令式的,需要明確指定每一步的操作。而使用領(lǐng)域事件,我們的代碼則變成了響應式的:我們只需要讓聚合發(fā)布事件,然后其他部分的代碼可以訂閱這些事件并作出響應。
例如,活動報名后需要發(fā)送報名短信給用戶,傳統(tǒng)編程方式,需要在活動報名的代碼中直接調(diào)用發(fā)短信代碼。這種方式有一些缺點:發(fā)短信和報名邏輯耦合在了一起,使得代碼難以閱讀和維護。發(fā)送短信可能會失敗,這會影響到活動報名的操作。而且以后一旦需要針對活動報名增加新的需求,活動報名相關(guān)代碼將被頻繁修改。
使用領(lǐng)域事件,可以改變這種方式:當活動報名時,只需要發(fā)布一個 ActivityRegistrationCreated 事件。然后我們可以有一個單獨的服務訂閱這個事件,并在收到事件時發(fā)送報名短信。這樣,活動報名和發(fā)送短信就解耦了,使得代碼更加清晰,也更容易維護。即使發(fā)短信失敗,也不會影響到活動報名的操作。而且活動報名的邏輯也不會因為后續(xù)報名場景增加新的需求而頻繁變動。
2.5.2、由誰發(fā)布領(lǐng)域事件
領(lǐng)域事件應由聚合根、實體或領(lǐng)域服務發(fā)布。聚合根、實體和領(lǐng)域服務是業(yè)務規(guī)則的執(zhí)行者,它們知道何時何地應該發(fā)布領(lǐng)域事件。當聚合根或?qū)嶓w的狀態(tài)發(fā)生變化時,或者領(lǐng)域服務完成特定的業(yè)務操作時,它們會發(fā)布對應的領(lǐng)域事件。不能在其他位置發(fā)布領(lǐng)域事件,例如,不能在應用服務中發(fā)布領(lǐng)域事件。
聚合根創(chuàng)建成功的場景,一般可以在聚合根的構(gòu)造函數(shù)中發(fā)布領(lǐng)域事件。聚合根狀態(tài)變更的場景一般由對應的聚合根方法發(fā)布領(lǐng)域事件。例如,聚合根 Dealer 用于創(chuàng)建的構(gòu)造函數(shù)會發(fā)布領(lǐng)域事件 DealerCreated,數(shù)據(jù)更新方法會發(fā)布領(lǐng)域事件 DealerInfoUpdated。
一些場景領(lǐng)域事件如果不能通過聚合根或?qū)嶓w發(fā)布,可以在執(zhí)行領(lǐng)域操作的領(lǐng)域服務中發(fā)布領(lǐng)域事件。
2.5.3、領(lǐng)域事件應該包含的信息
領(lǐng)域事件應該包含以下信息:
-
事件名稱
事件名稱應該清晰地表達出業(yè)務發(fā)生了什么事情。例如
DealerCreated、PaymentInitiated。 -
事件源的標識
事件源就是觸發(fā)事件的聚合根或?qū)嶓w,它的標識符應該包含在領(lǐng)域事件中,以便知道是哪個對象發(fā)布了事件。例如,支付單聚合根
PaymentOrder發(fā)布了領(lǐng)域事件PaymentInitiated,事件應該包含支付單標識PaymentOrderId。 -
事件發(fā)生的時間
事件發(fā)生的時間是領(lǐng)域事件的重要組成部分,它可以幫助我們知道何時發(fā)生了這個事件。
-
事件相關(guān)的業(yè)務數(shù)據(jù)
除了事件源的標識和事件發(fā)生的時間,領(lǐng)域事件還應該包含與事件相關(guān)的業(yè)務數(shù)據(jù)。這些數(shù)據(jù)應該足夠讓事件的訂閱者理解和處理這個事件。
如果希望在消費事件時實時獲取事件源信息,也可以不在事件中包含業(yè)務數(shù)據(jù)。但這會帶來一些使用上的不便,事件消費邏輯需要知道如何獲取事件源信息,如果消費邏輯和事件源不在一個系統(tǒng)的話,獲取事件源信息的復雜性會進一步提升,而事件包含業(yè)務數(shù)據(jù)的話這些都可以避免。
如果你使用事件溯源(Event Sourcing)架構(gòu)模式的話,領(lǐng)域事件就必須包含事件相關(guān)的業(yè)務數(shù)據(jù)了。
2.5.4、領(lǐng)域事件的持久化
領(lǐng)域事件的持久化作為整個項目的基礎(chǔ),作為調(diào)用方的應用服務需要對領(lǐng)域事件的發(fā)布和存儲無感知,領(lǐng)域事件的發(fā)布者只需發(fā)布領(lǐng)域事件也無需關(guān)注領(lǐng)域事件的持久化。為了保證聚合根數(shù)據(jù)狀態(tài)變更和領(lǐng)域事件發(fā)布的一致性,可以將領(lǐng)域事件和聚合根在一個事務中進行持久化。
在示例項目中我們會使用 DomainEventPublisher 用于事件的發(fā)布。且提供 DomainEventProcessor 基于 AOP 在所有應用服務方法執(zhí)行前為 DomainEventPublisher 的當前實例設置領(lǐng)域事件訂閱者,領(lǐng)域事件訂閱者用于將領(lǐng)域事件存儲到數(shù)據(jù)庫。通過 DomainEventPublisher 發(fā)布領(lǐng)域事件時會查找設置的領(lǐng)域事件訂閱者將領(lǐng)域事件實時存儲到數(shù)據(jù)庫,以此將業(yè)務數(shù)據(jù)和領(lǐng)域事件在一個事務中進行持久化,保證它們同時成功或失敗。
2.5.5、領(lǐng)域事件的通知和訂閱
領(lǐng)域事件通知面臨的首要問題是確保業(yè)務數(shù)據(jù)變更和領(lǐng)域事件通知的一致性。具體而言,我們必須避免出現(xiàn)數(shù)據(jù)成功變更但領(lǐng)域事件未成功通知,或者領(lǐng)域事件已經(jīng)通知但數(shù)據(jù)未成功變更的情況。因此我們采取將業(yè)務數(shù)據(jù)變更和領(lǐng)域事件持久化在一個事務中處理,而事件通知在獨立的流程中處理。
將事件通知在獨立的流程中處理,可以保證已發(fā)布的領(lǐng)域事件一定能夠成功對外通知,只需要領(lǐng)域事件訂閱方處理好事件消重即可。
在示例項目中,通過定時任務將指定的領(lǐng)域事件使用消息隊列的方式進行通知。在數(shù)據(jù)庫表中記錄每個領(lǐng)域事件類型已通知過的最大記錄 ID,相較于為每個領(lǐng)域事件數(shù)據(jù)記錄單獨標識是否通知的狀態(tài),為每個事件類型記錄最大 ID 將大大減少數(shù)據(jù)庫操作的頻率。而且可以方便的通過重置已通知事件類型的最大記錄 ID 來實現(xiàn)領(lǐng)域事件的重復通知。
2.6、應用服務(Application Service)
應用服務首先還是服務,也就是我們通常認知中的 Service。我們以往事務腳本編程方式寫的 Service 是和應用服務最接近的,DDD 中應用服務也是針對具體業(yè)務場景的用例流程,你的每一個業(yè)務場景都會對應到一個應用服務的方法。但是應用服務和舊的服務編寫方式的不同是,應用服務僅僅是非常薄的一層,它主要是通過協(xié)調(diào)調(diào)用領(lǐng)域?qū)訉ο髞韺崿F(xiàn)具體的業(yè)務流程,應用服務自身不應該包含任何領(lǐng)域邏輯相關(guān)的代碼。
2.6.1、應用服務的特征
應用服務使用上有區(qū)別于其他服務的明顯特征:
應用服務位于應用層
-
應用服務命名以
ApplicationService結(jié)尾使用
ApplicationService作為應用服務的統(tǒng)一后綴可以在命名上將應用服務和其他服務區(qū)分開。例如,DealerApplicationService、ActivityApplicationService。 -
應用服務的方法都是寫操作
在 CQRS 模式下應用服務只用負責處理所有的寫操作,這些操作通常對應于業(yè)務場景中的具體行為或命令,因此服務方法名通常反映了它們要執(zhí)行的具體業(yè)務操作,例如,
DealerApplicationService針對創(chuàng)建、更新名稱以及啟用和禁用提供了方法create()、changeName()、enable()和disable()。 -
使用特定的
Command類作為應用服務方法的入?yún)?/p>在 CQRS 模式中,我們通常會創(chuàng)建特定的
Command類來封裝特定業(yè)務場景需要的所有參數(shù)。例如,DealerApplicationService.create(DealerCreateCommand command)。使用專用的Command類作為參數(shù)有以下幾點好處:-
Command的創(chuàng)建滿足原子性,可以在其構(gòu)造函數(shù)中對參數(shù)做校驗,應用服務方法使用Command時只需要校驗參數(shù)是否為null即可,使應用服務方法更專注于業(yè)務流程的處理。 -
Command類可以像值對象一樣實現(xiàn)為不可變的,不用擔心后續(xù)流程無意中對其修改。 - 以往編程方式下可能會存在一個數(shù)據(jù)庫表對應的數(shù)據(jù)模型從展示層、服務層到數(shù)據(jù)持久層一通到底的現(xiàn)象,甚至一個數(shù)據(jù)模型不同場景分別使用部分字段的情況,隨著業(yè)務發(fā)展無法再清晰的知道場景所需參數(shù),而且后續(xù)流程也無法清晰的知道一些字段是調(diào)用方傳遞的還是某一個流程設置的。使用
Comamnd可以避免這種問題,每個方法都對應自己的Command,僅包含自身業(yè)務場景所需的參數(shù),不同場景相互沒有影響,Command也僅用于傳參不會對后續(xù)流程產(chǎn)生副作用。
-
-
應用服務方法不需要返回值
如果調(diào)用方需要獲取執(zhí)行結(jié)果,使用端口適配器模式,應用服務方法可以接收
CommandResult接口作為參數(shù),然后通過調(diào)用方提供的實現(xiàn)向外傳遞操作結(jié)果。
2.6.2、應用服務的職責
應用服務和領(lǐng)域服務的職責不同,領(lǐng)域服務主要是領(lǐng)域規(guī)則的處理,應用服務主要是作為完成特定業(yè)務場景操作的入口。應用服務的職責包含:
-
事務控制
作為業(yè)務場景操作的入口,應用服務通常也是事務的邊界,因此事務由應用服務控制。
-
安全性和權(quán)限檢查
安全性和權(quán)限檢查都不是領(lǐng)域相關(guān)的內(nèi)容,它們都由應用服務負責。
-
協(xié)調(diào)和驅(qū)動領(lǐng)域?qū)ο筮M行工作
應用服務協(xié)調(diào)多個領(lǐng)域?qū)ο?,以實現(xiàn)一個完整的業(yè)務流程。應用服務不應該包含業(yè)務規(guī)則或復雜的業(yè)務邏輯,自身應該作為一個協(xié)調(diào)者,例如,
DealerApplicationService通過領(lǐng)域服務DealerNameUniquenessCheckService驗證創(chuàng)建經(jīng)銷商使用的名稱在系統(tǒng)中唯一,使用Dealer聚合根創(chuàng)建新的實例,并通過DealerRepository對Dealer聚合根完成持久化操作。
2.7、CQRS(Command Query Responsibility Segregation,命令查詢職責分離)
CQRS 主張將應用的讀操作和寫操作拆分開,分別處理查詢(Query)和命令(Command)。由于讀操作和寫操作被分離,可以根據(jù)需要獨立優(yōu)化讀操作和寫操作,提高系統(tǒng)性能。
2.7.1、命令模型
我們上面提到的應用服務和聚合根都是用于寫操作的,它們都不應該提供數(shù)據(jù)查詢方法。也就是說涉及數(shù)據(jù)查詢的場景就和應用服務、聚合根無緣了。聚合根是業(yè)務數(shù)據(jù)和行為的封裝,為了保證數(shù)據(jù)一致性和完整性,聚合根基本上只對外暴露方法,這就限制了數(shù)據(jù)查詢場景的使用。而且由于聚合根在設計上強調(diào)的是保證業(yè)務規(guī)則的執(zhí)行和數(shù)據(jù)的一致性,強制將聚合根用于數(shù)據(jù)查詢不僅性能比較差,也會破壞聚合根的封裝性。資源庫也主要用于寫操作場景,盡量不要在資源庫中添加和聚合根操作無關(guān)的查詢方法。
2.7.2、查詢模型
使用查詢服務對外提供數(shù)據(jù)查詢。使用查詢服務時我們也可以對一些類的命名做一些規(guī)范,例如,查詢服務都以 QueryService 作為名稱后綴。
查詢服務獲取數(shù)據(jù)不一定要使用資源庫。對于復雜的數(shù)據(jù)查詢需求,例如需要從多個聚合根獲取數(shù)據(jù),或者需要進行復雜的數(shù)據(jù)處理等,通過資源庫進行查詢可能會面臨性能問題,也可能會導致資源庫的代碼復雜度增加。查詢服務獲取數(shù)據(jù)可以使用任意便捷的技術(shù)實現(xiàn),例如,使用 MyBatis 時你可以直接在查詢服務中使用 Mapper,直接查詢出當前場景需要的數(shù)據(jù)。
不要使用領(lǐng)域?qū)ο螅ㄈ缇酆细蛯嶓w)而應該使用專門的 ViewModel 作為對外返回數(shù)據(jù)的類型。數(shù)據(jù)展示的需求是多變的,直接使用領(lǐng)域?qū)ο笞鳛榉祷仡愋?,可能會限制?shù)據(jù)展示的靈活性。
即使通過更底層的數(shù)據(jù)查詢技術(shù)查詢數(shù)據(jù),實時獲取和拼裝數(shù)據(jù)在許多場景下也無法滿足性能要求??梢酝ㄟ^一些策略來提高查詢性能:
-
數(shù)據(jù)緩存
例如,使用 Redis 作為數(shù)據(jù)緩存,可以大大降低訪問數(shù)據(jù)庫的頻率,從而提升查詢性能。通過使用領(lǐng)域事件,我們有機會通過訂閱領(lǐng)域事件達到數(shù)據(jù)緩存的近實時更新。
要想高效的使用和管理數(shù)據(jù)緩存,不能簡單的基于方法來做緩存(如使用 Spring 的 @Cacheable)。應該基于業(yè)務數(shù)據(jù)模型來設計緩存,從而更好的控制緩存數(shù)據(jù)的粒度,也更容易基于領(lǐng)域事件管理緩存的更新。當然這樣做需要手動編寫更多的緩存操作代碼,但從長遠來看,這樣做能夠提供更高的緩存利用率,更好的緩存管理以及更強的可擴展性。
-
預先計算和存儲
當緩存不存在時,實時查詢數(shù)據(jù)可能會對性能造成較大波動,特別是當數(shù)據(jù)需要通過外部接口獲取時,接口的性能可能無法滿足需求。為了解決這個問題,可以采取預先計算、預先獲取,提前針對數(shù)據(jù)查詢場景準備好對應的數(shù)據(jù),提前處理好的數(shù)據(jù)可以在數(shù)據(jù)庫中存儲,數(shù)據(jù)庫表可以和數(shù)據(jù)緩存結(jié)構(gòu)保持一致。為了保證數(shù)據(jù)的實時性,我們可以通過領(lǐng)域事件訂閱的方式,及時更新數(shù)據(jù)庫和緩存中的數(shù)據(jù)。當緩存不存在時,可以直接從數(shù)據(jù)庫中獲取數(shù)據(jù)并填充到緩存中。這種預先處理的策略,可以將復雜的數(shù)據(jù)查詢簡化為簡單的讀緩存和讀數(shù)據(jù)庫操作,大大提高了系統(tǒng)的性能。同時,使用數(shù)據(jù)庫作為備份,也提高了系統(tǒng)的可靠性。
2.8、架構(gòu)
2.8.1、洋蔥架構(gòu)

洋蔥架構(gòu)將系統(tǒng)分解成一系列同心圓,這些同心圓代表了系統(tǒng)的不同部分和層級。洋蔥架構(gòu)由外向內(nèi)分別是:基礎(chǔ)設施、用戶接口、測試,應用服務,領(lǐng)域服務,領(lǐng)域?qū)ο蟆?/p>
在洋蔥架構(gòu)中,每一層只能依賴它內(nèi)部的層,內(nèi)部的層可以獨立外部的層發(fā)展。例如,領(lǐng)域?qū)涌梢员粦脤雍突A(chǔ)設施層引用,而領(lǐng)域?qū)硬荒芤脩脤雍突A(chǔ)設施層。通過依賴倒置將接口定義在領(lǐng)域?qū)?,在基礎(chǔ)設施層實現(xiàn)領(lǐng)域?qū)佣x的接口。
2.8.2、六邊形架構(gòu)

六邊形架構(gòu)也成為端口與適配器。六邊形架構(gòu)將應用分為內(nèi)部和外部兩部分。內(nèi)部通常包含應用層和領(lǐng)域?qū)?。?nèi)部和外部是完全隔離的,不直接和任何外部元素進行交互。內(nèi)部通過定義一系列端口(即接口)與外部進行交互。外部包含了所有與內(nèi)部交互的元素,例如,用戶界面、數(shù)據(jù)庫、外部服務、測試代碼等。外部通過適配器實現(xiàn)內(nèi)部定義的接口,從而和內(nèi)部進行交互。
六邊形架構(gòu)端口分為兩類:
-
輸入端口
輸入端口是有應用的內(nèi)部提供給外部的接口。定義了應用可以提供的服務或操作。外部通過調(diào)用這些輸入端口(接口)來驅(qū)動應用的業(yè)務邏輯。簡單來說,輸入端口是應用內(nèi)部定義的用來接收外部請求的接口。
一般情況下我們可以不使用輸入端口,例如,應用服務可以直接被外部調(diào)用,不用單獨定義接口讓應用服務實現(xiàn),然后外部調(diào)用應用服務實現(xiàn)的接口。當然使用接口可以帶來更好的解耦,更容易的測試以及更大的靈活性和擴展性。
-
輸出端口
輸出端口是應用內(nèi)部需要的接口,但是由外部來實現(xiàn),在 DDD 中這些實現(xiàn)都位于基礎(chǔ)設施層。它們定義了應用內(nèi)部需要的資源或服務,如訪問數(shù)據(jù)庫、外部接口等。應用的內(nèi)部(如應用服務或領(lǐng)域服務)通過調(diào)用這些輸出端口來使用這些資源或服務。簡單來說,輸出端口是應用的內(nèi)部定義的用來使用外部提供的服務或資源的接口。例如,資源庫的接口定義在領(lǐng)域?qū)?,實現(xiàn)位于基礎(chǔ)設施層。對外部接口的使用會在領(lǐng)域?qū)佣x領(lǐng)域服務接口,然后在基礎(chǔ)設施層實現(xiàn)這個領(lǐng)域服務接口。
2.8.3、選用哪種架構(gòu)
在 DDD 開發(fā)中往往會結(jié)合使用六邊形架構(gòu)和洋蔥架構(gòu)。這兩種架構(gòu)都支持 DDD 的核心原則和概念。
六邊形架構(gòu)強調(diào)的是從依賴管理的角度對系統(tǒng)進行抽象和解耦,通過定義清晰的接口(端口)和實現(xiàn)(適配器),使得應用的業(yè)務邏輯(應用和領(lǐng)域?qū)樱┡c技術(shù)細節(jié)(基礎(chǔ)設施層)解耦,這極大地提高了代碼的可測試性和可維護性。
而洋蔥架構(gòu)則從層次結(jié)構(gòu)的角度進行系統(tǒng)設計,核心領(lǐng)域位于中心,周圍是領(lǐng)域服務,再外層是應用服務,最外層為基礎(chǔ)設施。這種設計的一個優(yōu)點是,內(nèi)層的領(lǐng)域邏輯不會被外層的具體實現(xiàn)(如 UI, 數(shù)據(jù)庫等)污染。
結(jié)合這兩種架構(gòu),我們可以在保證代碼清晰、高效的同時,也充分利用了 DDD 的優(yōu)點,例如:聚焦領(lǐng)域邏輯,業(yè)務與技術(shù)的解耦,以及提高代碼的可維護性和可擴展性等。
3、實踐指南
3.1、項目結(jié)構(gòu)

3.2、領(lǐng)域事件的持久化、通知與訂閱
確保業(yè)務數(shù)據(jù)和相應的領(lǐng)域事件發(fā)布的一致性是我們在使用領(lǐng)域事件時需要首先解決的問題。我們必須確保它們或者全部成功,或者全部失敗。通過將業(yè)務數(shù)據(jù)和領(lǐng)域事件的發(fā)布封裝在同一事務中進行持久化,我們能夠有效地實現(xiàn)這一目標。
盡管領(lǐng)域事件和業(yè)務數(shù)據(jù)在同一事務中進行持久化,我們?nèi)詰_保領(lǐng)域事件的持久化操作在實現(xiàn)層面與業(yè)務流程保持隔離。利用 AOP,我們可以輕松實現(xiàn)領(lǐng)域事件的同步持久化,同時不對業(yè)務流程產(chǎn)生任何侵入,從而在保證系統(tǒng)功能的同時,也維持了代碼的整潔和業(yè)務邏輯的清晰。
在 DDD 中,所有的寫操作都是通過位于應用層的應用服務進行的。因此,我們可以將 ApplicationService 的方法作為 AOP 的切入點,并通過簡單的發(fā)布-訂閱機制,實現(xiàn)領(lǐng)域事件的發(fā)布和持久化。
下圖詳細地描繪了這一過程:

將領(lǐng)域事件和業(yè)務數(shù)據(jù)同步持久化僅僅解決了首要問題。我們只有在領(lǐng)域事件完成對外通知后,領(lǐng)域事件的發(fā)布流程才能算是最終完成。為了實現(xiàn)這一目標,我們可以利用定時任務來進行領(lǐng)域事件的對外通知。
我們可以為每一種領(lǐng)域事件設定獨立的定時任務,并將執(zhí)行間隔設定為每秒一次,以確保持久化的領(lǐng)域事件能夠及時完成對外通知。在完成對外通知后,我們并不直接管理每條領(lǐng)域事件記錄的通知狀態(tài),而是僅記錄每種領(lǐng)域事件已通知的最大事件記錄 ID。這樣的設計既減少了數(shù)據(jù)庫更新操作,又可以通過重置領(lǐng)域事件通知的跟蹤 ID 來實現(xiàn)領(lǐng)域事件的重復通知。
下圖詳細地展示了整個流程:

通過訂閱領(lǐng)域事件通知,我們可以對特定的業(yè)務操作進行響應,進而實現(xiàn)系統(tǒng)間的解耦,這有助于提升系統(tǒng)的靈活性和可維護性。在這種機制下,各系統(tǒng)模塊可以獨立地響應和處理自己關(guān)心的領(lǐng)域事件,而無需了解事件的全局處理過程,從而實現(xiàn)了業(yè)務邏輯的高內(nèi)聚和低耦合。
具體的訂閱流程如下圖所示:

3.3、通過示例演示如何使用 DDD
我們接下來用于演示的示例主要涉及以下幾部分:
- 聚合根 ID 的設計。
- 經(jīng)銷商數(shù)據(jù)管理。
- 經(jīng)銷商服務購買:訂單創(chuàng)建、支付和退款。
- 經(jīng)銷商服務臨近到期提醒。
3.3.1、聚合根 ID 的設計
在概念詳解部分我們已經(jīng)討論過使用值對象作為唯一標識的好處。涉及到具體的設計,提供抽象泛型類 AbstractId<T>,并針對不同的數(shù)據(jù)類型,分別提供子類 AbstractIntegerId AbstractLongId AbstractStringId。具體的聚合根標識只需直接繼承對應的子類即可,例如,Dealer 聚合根標識 DealerId 就繼承自 AbstractLongId。

3.3.2、經(jīng)銷商數(shù)據(jù)管理
經(jīng)銷商數(shù)據(jù)管理寫操作場景主要包含:經(jīng)銷商創(chuàng)建、修改名稱、修改聯(lián)系電話、修改地址以及漏出狀態(tài)管理和服務時間更新。通過經(jīng)銷商數(shù)據(jù)管理相關(guān)的代碼設計,我們可以從整體上完整的了解如何使用 DDD 來實現(xiàn)我們的需求,以及如何基于 CQRS 實現(xiàn)寫操作和讀操作的分離。
完整的類設計如下圖:

從上面的類圖可以看出,應用服務 DealerApplicationService 是業(yè)務場景的操作入口,其中所有的方法都沒有返回值,且使用 Command 作為參數(shù),如果操作需要對外提供操作結(jié)果,可以使用 CommandResult 接口來向外輸出操作結(jié)果。
所有寫操作的核心都是對聚合根的操作,這里也就是對聚合根 Dealer 的操作。由資源庫 DealerRepository 提供對聚合根 Dealer 的查詢和持久化。對于經(jīng)銷商名稱不能重復的業(yè)務規(guī)則通過領(lǐng)域服務 DealerNameUniquenessCheckService 提供支持。聚合根需要自己保證其內(nèi)部數(shù)據(jù)的正確性,因此 Dealer 聚合根 changeName 方法通過參數(shù)依賴領(lǐng)域服務 DealerNameUniquenessCheckService,用于保證其被更新的名稱是符合業(yè)務規(guī)則的。領(lǐng)域事件的可以通過聚合根或領(lǐng)域服務發(fā)布,這里由聚合根 Dealer 在不同場景發(fā)布不同的領(lǐng)域事件。為了探討更本質(zhì)的實現(xiàn)方式,雖然也使用了 MyBatis,但我們?nèi)圆捎昧烁謩拥姆绞酵瓿删酆细牟樵兒捅4?,因此示例代碼中我們提供了值對象 DealerSnapshot 用于在持久化時可以獲取到全部的 Dealer 聚合根的數(shù)據(jù)。同時 Dealer 也提供了兩個構(gòu)造函數(shù),一個用于創(chuàng)建新的聚合根,一個用于聚合根查詢后的對象重建。
其中,DealerApplicationService 以及 DealerCreateCommand 和 DealerCreateCommandResult 位于應用服務層。Dealer、DealerRespository、DealerNameUniquenessCheckService、DealerSnapshot、IdGenerator 以及領(lǐng)域事件位于領(lǐng)域?qū)印?code>DealerRepository 的實現(xiàn) MyBatisDealerRepository、DealerMapper、IdGenerator 的實現(xiàn) SnowflakeIdGenerator以及DealerDatabaseModel 位于基礎(chǔ)設施層。
對于讀操作,在應用服務層提供查詢服務 DealerQueryService,查詢服務可以直接使用 DataMapper 來查詢數(shù)據(jù),而不用受限于聚合根和資源庫。查詢服務可以使用緩存提升查詢性能。查詢服務對外提供數(shù)據(jù)會使用 DealerViewModel 而不是聚合根等領(lǐng)域?qū)訉ο?。通過訂閱寫操作所發(fā)布的領(lǐng)域事件,我們能夠?qū)崟r更新涉及到讀操作的數(shù)據(jù)緩存。這種機制使得我們能夠更快速地響應寫操作,進而確保讀操作提供的數(shù)據(jù)能及時地反映最新的變動。類圖設計如下所示:

3.3.3、經(jīng)銷商服務購買:訂單創(chuàng)建、支付和退款
通過經(jīng)銷商數(shù)據(jù)管理的設計,我們可以直觀地理解一個完整的領(lǐng)域驅(qū)動設計應該是什么樣子。接下來通過經(jīng)銷商服務購買部分的代碼設計我們將更深入地了解如何通過領(lǐng)域事件來實現(xiàn)低耦合、易理解和易維護的代碼。同時,也更為清晰地展現(xiàn)了聚合根作為操作核心的重要性。
針對經(jīng)銷商服務購買場景,我們提出了聚合根 DealerServicePurchaseOrder,服務購買實際上就是創(chuàng)建新的 DealerServicePurchaseOrder。服務購買過程如下圖所示:

服務購買即 DealerServicePurchaseOrder 聚合根創(chuàng)建的過程有一些細節(jié)需要我們注意:
-
DealerServicePurchaseOrder的創(chuàng)建使用了Dealer的相關(guān)數(shù)據(jù),但是我們并未在Dealer中通過工廠方法的方式來創(chuàng)建DealerServicePurchaseOrder,而是提出了獨立的工廠類DealerServicePurchaseOrderFactory。因為實際場景中Dealer確實不需要了解DealerServicePurchaseOrder,因此沒有必要增加它們之間的依賴,通過Dealer暴露方法nextServicePeriod()提供訂單創(chuàng)建會使用到的主要數(shù)據(jù)更合理。 -
DealerServicePurchaseOrderFactory的職責是且僅是創(chuàng)建聚合根,不應該包含其他邏輯,例如,服務購買流程需要保證同時只能有一個進行中的服務訂單,這個業(yè)務規(guī)則的驗證目前在應用服務中,就不應該放到工廠類中,因為這個規(guī)則和創(chuàng)建服務訂單無關(guān)。
訂單創(chuàng)建后的下一步操作是發(fā)起支付。針對發(fā)起支付這個操作,實際上是創(chuàng)建支付單聚合根 PaymentOrder,并向支付平臺發(fā)起支付,并將發(fā)起支付結(jié)果響應給前端用于下一步的實際支付操作。發(fā)起支付過程如下圖所示:

發(fā)起支付過程中值得關(guān)注的點有:
- 整個發(fā)起支付操作通過領(lǐng)域服務
InitiatePaymentService完成。因為整個流程包含的業(yè)務規(guī)則同時涉及到DealerServicePurchaseOrder和PaymentOrder,而且整個流程比較復雜,通過領(lǐng)域服務可以讓應用服務保持簡單,不用泄露領(lǐng)域邏輯到應用服務。 - 領(lǐng)域服務
InitiatePaymentService在這個場景中不僅發(fā)起了支付操作,同時也完成了對聚合根PaymentOrder的持久化工作。因為在當前場景中,發(fā)起支付正是領(lǐng)域服務的主要職責,而支付單是其內(nèi)部生成的,所以聚合根的持久化操作更適合在領(lǐng)域服務中進行。這樣的設計可以確保應用服務保持簡潔且易于理解。當然,如果應用服務將聚合根作為參數(shù)提供給領(lǐng)域服務,委托領(lǐng)域服務對聚合根完成一些操作,聚合根的持久化還是在應用服務中處理更合適。 - 在創(chuàng)建
PaymentOrder的時候,其初始狀態(tài)是未發(fā)起支付,只有調(diào)用支付平臺成功后,其狀態(tài)才會更新為已發(fā)起支付。向支付平臺發(fā)起支付并更新支付單狀態(tài)都由PaymentOrder提供的initiatePayment(PaymentService)完成,領(lǐng)域服務PaymentService作為參數(shù)被PaymentOrder依賴。這種設計方式是 DDD 與傳統(tǒng)編程方式的一個重要區(qū)別。在這種設計中,我們不需要從外部查詢PaymentOrder是否可以發(fā)起支付,也不需要在發(fā)起支付后從外部更新PaymentOrder的狀態(tài)。相反,這些都是由PaymentOrder聚合根自身來處理的。正因如此,PaymentOrder才能夠更好的保證其自身數(shù)據(jù)狀態(tài)的正確性。
注意,發(fā)起支付操作只是創(chuàng)建了支付單并發(fā)起了支付,訂單狀態(tài)并未同步設置為已發(fā)起支付。訂單狀態(tài)變更為已發(fā)起支付我們通過訂閱領(lǐng)域事件 PaymentIntitated 來完成:

你可能會對此感到疑惑,發(fā)起支付流程并不特別復雜,為什么就不能同時更新DealerServicePurchaseOrder的狀態(tài)呢?因為 DDD 建議我們在一個事務中盡量只操作一個聚合根,這個建議似乎比較嚴格,甚至可能顯得操作復雜,但能夠帶來許多益處,具體的優(yōu)點我們在概念詳解部分已經(jīng)有過介紹。
用戶支付成功后,通過支付平臺的異步通知支付單的狀態(tài)會更新為已支付,支付單狀態(tài)同步以及更新自身數(shù)據(jù)狀態(tài)由 PaymentOrder.syncPaymentResult(PaymentService) 完成,支付成功后 PaymentOrder 發(fā)布領(lǐng)域事件 Paid,如果支付失敗,發(fā)布領(lǐng)域事件 PaymentFailed。過程詳情如圖所示:

支付單支付成功后,通過訂閱領(lǐng)域事件 Paid,DealerServicePurchaseOrder 狀態(tài)將變更為已支付并發(fā)布領(lǐng)域事件 OrderPaid,過程詳情如圖所示:

支付單支付失敗后,通過訂閱領(lǐng)域事件 PaymentFailed,DealerServicePurchaseOrder 狀態(tài)將變更為支付失敗并發(fā)布領(lǐng)域事件 OrderPaymentFailed,過程詳情如圖所示:

不要忘記我們當前流程的起點是為了購買服務,因此在訂單支付成功后,通過訂閱領(lǐng)域事件 OrderPaid,Dealer 聚合根將更新其服務狀態(tài)和服務到期時間,同時 Dealer 發(fā)布領(lǐng)域事件 DealerServiceChanged,我們在經(jīng)銷商數(shù)據(jù)管理部分已經(jīng)提及過這個領(lǐng)域事件,它會被訂閱然后用于更新查詢模型的緩存。過程詳情如圖所示:

接下來我們繼續(xù)看訂單退款的流程如何使用 DDD 設計。
用戶申請退款本身就是一個異步流程。用戶申請退款的對象是訂單即 DealerServicePurchaseOrder,因此申請退款操作實際上是更新訂單的狀態(tài)為已申請退款,并發(fā)布領(lǐng)域事件 RefundReqeusted。過程詳情如圖所示:

訂單申請退款后,通過訂閱領(lǐng)域事件 RefundRequested 針對支付單 PaymentOrder 實際發(fā)起退款,發(fā)起退款操作流程會創(chuàng)建退款單聚合根 RefundOrder,然后由 RefundOrder.initiateRefund(PaymentService) 發(fā)起退款,并更新 RefundOrder 的狀態(tài)為已發(fā)起退款,并發(fā)布領(lǐng)域事件 RefundInitiated。過程詳情如圖所示:

如上圖所示,發(fā)起退款功能由領(lǐng)域服務 InitiateRefundService 提供,和發(fā)起支付場景類似,同樣 InitiateRefundService 也承擔了 RefundOrder 的持久化工作。同時由 PaymentOrder 中提供的工廠方法 generateRefundOrder() 創(chuàng)建新的 RefundOrder ,因為退款單和支付單密切相關(guān),通過 PaymentOrder 上提供工廠方法,可以保證只有在正確的狀態(tài)下才能創(chuàng)建退款單。
通過訂閱領(lǐng)域事件 RefundInitiated,DealerServicePurchaseOrder 狀態(tài)變更為已發(fā)起退款,并發(fā)布領(lǐng)域事件 OrderRefundInitiated,過程詳情如圖所示:

退款成功后,通過支付平臺的異步通知退款單的狀態(tài)會更新為已退款,退款單狀態(tài)同步以及更新自身數(shù)據(jù)狀態(tài)由 RefundOrder.syncRefundResult(PaymentService) 完成,退款成功后 RefundOrder 發(fā)布領(lǐng)域事件 Refunded,如果退款失敗,發(fā)布領(lǐng)域事件 RefundFailed。過程詳情如圖所示:

退款單退款成功后,通過訂閱領(lǐng)域事件 Refunded,DealerServicePurchaseOrder 狀態(tài)將變更為已退款并發(fā)布領(lǐng)域事件 OrderRefunded,過程詳情如圖所示:

3.3.4、經(jīng)銷商服務臨近到期提醒
在實際的項目中,除了用戶直接發(fā)起的操作外,還有許多由服務或定時任務發(fā)起的操作。這些操作通常涉及到批量數(shù)據(jù)的處理。
接下來我們討論的需求場景是:對所有服務即將到期的經(jīng)銷商進行短信提醒。許多開發(fā)者會在一個服務或定時任務中先獲取所有的經(jīng)銷商,然后通過循環(huán)的方式單獨處理每一個經(jīng)銷商的提醒操作。然而,這種單線程處理大量數(shù)據(jù)的方法會導致整體耗時較長,效率低下。而手動開啟多線程并發(fā)處理雖然能夠提高效率,但也會帶來更高的實現(xiàn)成本,增加系統(tǒng)的復雜性。更糟糕的是,一些開發(fā)者可能會直接通過 SQL 篩選出需要提醒的經(jīng)銷商。這種做法實際上已經(jīng)將業(yè)務邏輯轉(zhuǎn)移到了 SQL 中,大大降低了代碼的可讀性和可維護性,不利于后續(xù)的代碼理解和維護。
接下來,我們將運用 DDD 的理念和方法,來實現(xiàn)對所有服務即將到期的經(jīng)銷商進行短信提醒的需求。
首先,通過定時任務執(zhí)行對所有服務即將到期的經(jīng)銷商進行短信提醒的操作,通過應用服務 DealerServiceApproachingExpiryRemindApplicationService.startAllDealerServiceApproachingExpiryRemind() 發(fā)起對所有經(jīng)銷商的服務到期提醒,不同的是,startAllDealerServiceApproachingExpiryRemind() 并不會實際執(zhí)行服務到期檢查和提醒。它只是通過 AllDealerIdQueryService 查詢服務獲取到所有的 DealerId,然后針對每個 DealerId 發(fā)布對應的領(lǐng)域事件 DealerServiceApproachingExpiryRemindRequested,為所有經(jīng)銷商發(fā)布領(lǐng)域事件后,它的工作就完成了。過程詳情如下圖所示:

通過訂閱領(lǐng)域事件 DealerServiceApproachingExpiryRemindRequested 完成對單個經(jīng)銷商的服務到期檢查和提醒。通過消息隊列能夠并發(fā)處理所有的經(jīng)銷商,這將極大地提升處理效率。一次處理一個經(jīng)銷商,將使我們的代碼邏輯大大簡化。單個經(jīng)銷商處理過程詳情如下圖所示:

如上圖所示,領(lǐng)域服務 DealerServiceApproachingExpiryRemindService.remind(Dealer dealer) 完成對服務是否臨近到期的檢查,以及臨近到期時發(fā)送短信提醒。領(lǐng)域服務 SmsService 用于發(fā)送短信,你應該已經(jīng)注意到,SmsService.sendSms() 并未同步調(diào)用短信接口發(fā)出短信,它只是創(chuàng)建了短信聚合根 Sms,Sms 通過構(gòu)造函數(shù)發(fā)布了領(lǐng)域事件 SmsCreated。那么短信又是在什么時候?qū)嶋H發(fā)送的呢?

如上圖所示,實際短信發(fā)送是一個獨立的流程。通過訂閱領(lǐng)域事件 SmsCreated 使用 SmsApplicationService 完成短信的發(fā)送。這種實現(xiàn)方式有以下優(yōu)點:
- 獨立的短信發(fā)送,可以降低業(yè)務操作因短信發(fā)送異常而中斷的概率。
- 獨立的短信發(fā)送,避免因為短信發(fā)送增加業(yè)務操作的整體耗時。
- 能夠?qū)⒍绦抛鳛榛A(chǔ)模塊維護,例如統(tǒng)一的號碼攔截,定時發(fā)送,錯誤重試等。
4、參考
《領(lǐng)域驅(qū)動設計:軟件核心復雜性應對之道》(Domain-Driven Design: Tackling Complexity in the Heart of Software)Eric Evans
領(lǐng)域驅(qū)動設計.png
《實現(xiàn)領(lǐng)域驅(qū)動設計》(Implementing Domain-Driven Design)Vaughn Vernon
實現(xiàn)領(lǐng)域驅(qū)動設計.png
《Patterns, Principles, and Practices of Domain-Driven Design》Scott Millett / Nick Tune
patterns principles and practices of ddd.png


