domain-driven-hexagon: 領域驅動六邊形架構學習資料

banq發表於2022-07-19

學習領域驅動設計、軟體架構、設計模式、最佳實踐,該專案的主要重點是提供有關如何設計軟體應用程式的建議。本自述檔案中介紹了從不同來源收集的一些技術、工具、最佳實踐、架構模式和指南。
程式碼示例是使用NodeJSTypeScriptNestJS框架和Typeorm 編寫的,用於資料庫訪問。

雖然這裡介紹的模式和原則與框架/語言無關,但上述技術可以很容易地被任何替代方案替換。無論使用什麼語言或框架,任何應用程式都可以從下面描述的原則中受益。

注意:程式碼示例適用於 TypeScript 和上述框架,其他語言的實現看起來會有所不同。

架構特點:
主要依據:


以及許多其他來源(每章下面有更多連結)。
在我們開始之前,這裡是使用這樣一個完整架構的優點和缺點:
優點
  • 獨立於外部框架、技術、資料庫等。框架和外部資源可以更輕鬆地插入/拔出。
  • 易於測試和擴充套件。
  • 更安全。一些安全原則已經融入設計本身。
  • 該解決方案可以由不同的團隊進行工作和維護,而不會互相干擾。
  • 更容易新增新功能。隨著系統隨著時間的推移而增長,新增新功能的難度保持不變且相對較小。
  • 如果解決方案沿有界上下文線正確分解,則可以在需要時輕鬆將其部分轉換為微服務

缺點
  • 這是一個複雜的架構,需要對質量軟體原則有深刻的理解,例如 SOLID、清潔/六邊形架構、領域驅動設計等。任何實施此類解決方案的團隊幾乎肯定需要專家來推動解決方案並保持它從發展錯誤的方式和積累技術債務。
  • 此處介紹的一些實踐不推薦用於業務邏輯不多的中小型應用程式。增加了前期複雜性以支援所有這些構建塊和層、樣板程式碼、抽象、資料對映等。因此,實現這樣的完整架構通常不適合簡單的CRUD應用程式,並且可能會使此類解決方案過於複雜。下面描述的一些原則可用於較小規模的應用程式,但必須在分析和了解所有優缺點後才能實施。


流程:

domain-driven-hexagon: 領域驅動六邊形架構學習資料

資料流是這樣的(從左到右):

  • 使用普通 DTO 將請求/CLI 命令/事件傳送到控制器;
  • 控制器解析這個 DTO,將其對映為命令/查詢物件格式並將其傳遞給應用程式服務;
  • 應用服務處理這個命令/查詢;它使用域服務和/或實體執行業務邏輯,並透過埠使用基礎設施層;
  • 基礎設施層使用對映器將資料轉換為所需的格式,使用儲存庫獲取/持久化資料,使用介面卡傳送事件或進行其他 I/O 通訊,將資料對映回域格式並將其返回給應用程式服務;
  • 應用程式服務完成其工作後,它會將資料/確認返回給控制器;
  • 控制器將資料返回給使用者(如果應用程式有演示者/檢視,則返回它們)

每一層都負責自己的邏輯,並具有構建塊,這些構建塊通常應在可能且有意義的情況下遵循單一職責原則Repositories(例如,僅用於資料庫訪問、Entities用於業務邏輯等)。

請記住,不同的專案可能具有比此處描述的更多或更少的步驟/層/構建塊。如果應用程式需要,可以新增更多,如果應用程式不是那麼複雜並且不需要所有抽象,則跳過一些。
對任何專案的一般建議:分析應用程式的規模/複雜程度,找到折衷方案並根據專案需要使用盡可能多的層/構建塊,並跳過可能過於複雜的層。


模組
本專案的程式碼例項採用了模組(也稱為元件)的分離方式。每個模組的名字都應該反映領域中的一個重要概念,並有自己的資料夾和專門的程式碼庫,該模組中的每個業務用例都有自己的資料夾來儲存它所需要的大部分東西(這也叫垂直切分)。如果這些東西相對聚集在一起,就更容易在一起工作,因為這些東西都是相對靠近的。把模組想象成一個 "盒子",把相關的業務邏輯集中起來。

使用模組是封裝高度凝聚的業務領域規則的一部分的好方法。

儘量使每個模組都是獨立的,並使模組之間的互動最小。把每個模組看作是一個由單一上下文約束的小型應用程式。考慮模組內部的私有性,儘量避免模組間的直接匯入(比如從'.../SomeOtherModule'匯入一個類import SomeClass),因為這樣會產生緊密的耦合,會使你的程式碼變成義大利麵條,應用程式變成一個大泥球。

有幾個建議可以避免耦合:

  • 儘量不要在模組或用例之間建立依賴關係。相反,把共享的邏輯移到一個單獨的檔案中,讓兩者都依賴它,而不是相互依賴。
  • 模組可以透過調解器或公共門面進行合作,隱藏模組的所有私有內部資訊,以避免其被濫用,並只讓公眾訪問某些本應公開的功能。
  • 另外,模組之間可以透過使用訊息進行交流。例如,你可以使用命令匯流排傳送命令,或者訂閱其他模組發出的事件(關於事件和命令匯流排的更多資訊見下文)。

這確保了鬆散的耦合,模組內部的重構可以更容易完成,因為外部世界只依賴於模組的公共介面,如果邊界上下文定義和設計得當,每個模組可以很容易地被分離成一個微服務,如果需要的話,無需觸及任何領域邏輯或重大重構。

保持你的模組很小。你應該能夠在較短的時間內重寫一個模組。這不僅適用於模組模式,也適用於一般的軟體開發:物件、函式、微服務、流程等。保持它們的小型和可組合性。這在不斷變化的軟體開發環境中是非常強大的,因為當你的需求改變時,改變小模組比改變大程式要容易得多。你可以在幾天內刪除一個模組並從頭開始重寫它。這個想法將在本講座中進一步描述:Greg Young - The art of destroying software.

程式碼示例:
  • 檢查src/modules目錄結構。
  • src/modules/user/commands - 使用者模組中的“commands”目錄包括模組可以執行的業務用例(命令),每個用例都有自己的 Vertical Slice。



應用核心
這是使用DDD 構建塊構建的系統的核心:
領域層:

  • 實體
  • 聚合
  • 域服務
  • 值物件
  • 域錯誤

應用層:
  • 應用服務
  • 命令和查詢



應用服務
也稱為“工作流服務”、“用例”、“互動者”等。這些服務編排了執行客戶端強加命令所需的步驟。

  • 通常用於協調外部世界如何與您的應用程式互動並執行終端使用者所需的任務。
  • 不包含特定領域的業務邏輯;
  • 對標量型別進行操作,將它們轉換為域型別。標量型別可以被認為是領域模型未知的任何型別。這包括原始型別和不屬於域的型別。
  • 宣告對執行域邏輯所需的基礎設施服務的依賴關係(透過使用埠)。
  • 用於Entities透過埠從資料庫/外部世界獲取域(或其他任何內容);
  • 透過埠執行其他程式外通訊(如事件傳送、傳送電子郵件等);
  • 在與一個實體/聚合互動的情況下,直接執行其方法;
  • 如果使用多個實體/聚合,請使用 aDomain Service來編排它們;
  • 基本上是Command/Query處理程式;
  • 不應該依賴其他應用程式服務,因為它可能會導致問題(如迴圈依賴);




埠是定義應由介面卡實現的協定的介面。例如,一個埠可以抽象技術細節(比如使用什麼型別的資料庫來檢索一些資料),基礎設施層可以實現一個介面卡,以便執行一些與技術細節而不是業務邏輯更相關的操作。埠就像業務邏輯不關心的技術細節的抽象。[url=https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)]在Hexagonal Architecture[/url]中使用最活躍的名稱“埠” 。
在應用程式核心依賴點向內。外層可以依賴於內層,但內層從不依賴於外層。應用程式核心不應依賴框架或直接訪問外部資源。任何對程式外資源的外部呼叫/從遠端程式檢索資料都應該透過ports(介面)完成,類實現在基礎設施層的某處建立並注入應用程式的核心(依賴注入依賴倒置)。這使得業務邏輯獨立於技術,便於測試,允許輕鬆插入/拔出/交換任何外部資源,使應用程式模組化和鬆散耦合

  • 埠基本上只是定義必須做什麼而不關心它是如何完成的介面。
  • 可以建立埠以從域中抽象出副作用,例如 I/O 操作和資料庫訪問、技術細節、侵入性庫、遺留程式碼等。
  • 透過抽象副作用,您可以透過模擬實現來單獨測試您的應用程式邏輯。這對於單元測試很有用。
  • 應該建立埠以滿足域需求,而不是簡單地模仿工具 API。
  • 模擬實現可以在測試時傳遞給埠。模擬使您的測試更快且獨立於環境。
  • 如果需要,埠提供的抽象可用於向埠注入不同的實現(多型性)。
  • 在設計埠時,請記住介面隔離原則。在有意義的情況下將大型介面拆分為較小的介面,但也要記住在不必要時不要過度使用。
  • 埠還可以幫助延遲決策。領域層甚至可以在決定使用什麼技術(框架、資料庫等)之前實現。

注意:由於大多數埠實現是在應用程式服務中注入和執行的,因此應用層可能是保留這些埠的好地方。但是有時域層的業務邏輯依賴於執行一些外部資源,在這種情況下,這些埠可以放在域層中。
注意:濫用埠/介面可能會導致不必要的抽象並使您的應用程式過於複雜。在很多情況下,依賴具體的實現而不是用介面抽象它是完全可以的。如果您真的需要抽象,請在使用它之前仔細考慮。
示例檔案:


領域層
該層包含應用程式的業務規則。
域應該使用通用語言描述的域物件進行操作。最重要的域構建塊如下所述。



1、實體
實體是域的核心。它們封裝了企業範圍的業務規則和屬性。實體可以是具有屬性和方法的物件,也可以是一組資料結構和函式。
實體代表業務模型並表達特定模型具有哪些屬性,它可以做什麼,何時以及在什麼條件下可以做到這一點。商業模式的一個例子可以是使用者、產品、預訂、票證、錢包等。
實體必須始終保護它們的不變數
域實體應始終是有效實體。對於一個應該始終為真的物件,有一定數量的不變數。例如,訂單專案物件始終必須具有必須為正整數的數量,加上商品名稱和價格。因此,不變數的執行是域實體(尤其是聚合根)的責任,並且實體物件不應該能夠在沒有有效的情況下存在。
實體:

  • 包含域業務邏輯。儘可能避免在您的服務中包含業務邏輯,這會導致貧血的領域模型(領域服務是不能放在單個實體中的業務邏輯的例外)。
  • 有一個身份來定義它並使其與其他人區分開來。它的身份在其生命週期中是一致的。
  • 兩個實體之間的平等是透過比較它們的識別符號(通常是它的id欄位)來確定的。
  • 可以包含其他物件,例如其他實體或值物件。
  • 負責收集對狀態的所有理解以及它在同一個地方如何變化。
  • 負責協調對其擁有的物件的操作。
  • 對上層(服務、控制器等)一無所知。
  • 應該對域實體資料進行建模以適應業務邏輯,而不是某些資料庫模式。
  • 實體必須保護它們的不變數,儘量避免公共設定器 - 使用方法更新狀態並在需要時對每次更新執行不變數驗證(這可以是validate()檢查更新是否違反業務規則的簡單方法)。
  • 必須在創作上保持一致。在建立時驗證實體和其他域物件,並在第一次失敗時丟擲錯誤。快速失敗
  • 避免無引數(空)建構函式,接受並驗證建構函式(或工廠方法,如create())中的所有必需屬性。
  • 對於需要一些複雜設定的可選屬性,可以使用Fluent 介面Builder Pattern 。
  • 使實體部分不可變。確定哪些屬性在建立後不應更改並製作它們readonly(例如id或createdAt)。

注意:很多人傾向於為每個實體建立一個模組,但這種方法不是很好。每個模組可能有多個實體。要記住的一件事是,將實體放在單個模組中要求這些實體具有相關的業務邏輯,不要將不相關的實體組合在一個模組中。
示例檔案:


2、聚合

聚合是可以被視為單個單元的域物件叢集。它封裝了概念上屬於一起的實體和值物件。它還包含一組可以操作這些域物件的操作。

  • 聚合透過在單個抽象下收集多個域物件來幫助簡化域模型。
  • 聚合不應受資料模型的影響。域物件之間的關聯與資料庫關係不同。
  • 聚合根是一個實體,包含其他實體/值物件以及操作它們的所有邏輯。
  • 聚合根具有全域性標識(UUID / GUID / 主鍵)。聚合邊界內的實體具有本地身份,僅在聚合內是唯一的。
  • 聚合根是整個聚合的閘道器。來自聚合外部的任何引用都應該只轉到聚合根。
  • 對聚合的任何操作都必須是事務性操作。要麼一切都被儲存/更新/刪除,要麼什麼都沒有。
  • 只能透過資料庫查詢直接獲得聚合根。其他的一切都必須透過遍歷來完成。
  • 與 類似Entities,聚合必須在整個生命週期內保護它們的不變數。當提交對聚合邊界內的任何物件的更改時,必須滿足整個聚合的所有不變數。簡單地說,聚合中的所有物件必須是一致的,這意味著如果聚合中的一個物件更改狀態,則不應與該聚合中的其他域物件衝突(這稱為一致性邊界)。
  • 聚合中的物件可以透過其全域性唯一識別符號 (id) 引用其他聚合根。避免持有直接的物件引用。
  • 儘量避免聚合太大,這可能會導致效能和維護問題。
  • 聚合可以釋出Domain Events(更多內容見下文)。

所有這些規則都來自於圍繞聚合建立邊界的想法。邊界簡化了業務模型,因為它迫使我們非常仔細地考慮每一種關係,並在一組明確定義的規則內。
總而言之,如果你在一個根中組合多個相關實體和值物件Entity,這個根Entity就變成了一個Aggregate Root,而這組相關實體和值物件就變成了一個Aggregate。
示例檔案:



3、領域事件
域事件表示域中發生了您希望同一域的其他部分(程式中)知道的事情。域事件只是推送到記憶體中域事件排程程式的訊息。
例如,如果使用者購買了東西,您可能想要:

  • 更新他的購物車;
  • 從他的錢包裡取錢;
  • 建立一個新的運輸訂單;
  • 執行與執行“購買”命令的聚合無關的其他域操作。

典型的方法涉及在執行“購買”操作的服務中執行所有這些邏輯。但是,這會在不同子域之間產生耦合。
另一種方法是釋出Domain Event. 如果執行與一個聚合例項相關的命令需要在一個或多個附加聚合上執行附加域規則,您可以設計和實現由域事件觸發的那些副作用。可以透過訂閱具體Domain Event並根據需要建立儘可能多的事件處理程式來執行在同一域模型中跨多個聚合的狀態更改傳播。這可以防止聚合之間的耦合。

域事件可能有助於建立審計日誌,透過將每個事件儲存到資料庫來跟蹤對重要實體的所有更改。閱讀更多關於為什麼審計日誌可能有用的資訊:為什麼軟刪除是邪惡的以及該怎麼做

由單個程式中跨多個聚合的域事件引起的所有更改都可以儲存在單個資料庫事務中。這種方法可確保資料的一致性和完整性。將整個流程包裝在事務中或使用工作單元等模式或類似模式可以對此有所幫助。 請記住,當多個使用者嘗試同時修改單個記錄時,濫用事務可能會造成瓶頸。僅在您負擔得起時才使用它,否則請使用其他方法(例如最終一致性)。

有多種方法可以為領域事件實現事件匯流排,例如使用來自MediatorObserver等模式的想法。
例子:



4、域錯誤
應用程式的核心層和域層不應該丟擲 HTTP 異常或狀態,因為它不應該知道它在什麼上下文中使用,因為它可以被任何東西使用:HTTP 控制器、微服務事件處理程式、命令列介面等。更好的方法是使用適當的錯誤程式碼建立自定義錯誤類。
例外是針對特殊情況。複雜的域通常會有很多錯誤,這些錯誤並不例外,而是業務邏輯的一部分(例如“座位已預訂,請選擇另一個”)。這些錯誤可能需要特殊處理。在這些情況下,返回顯式錯誤型別可能是比丟擲更好的方法。
返回錯誤而不是顯式丟擲會顯示方法可以返回的每個異常的型別,以便您可以相應地處理它。它可以使錯誤處理和跟蹤更容易。
為了幫助解決這個問題,您可以使用某種帶有 Success 或 Failure 的 Result 物件型別(來自 Haskell 等函式式語言的Either monad )。與丟擲異常不同,這種方法允許為每個錯誤定義型別,並強制您顯式處理這些情況,而不是使用try/catch. 例如:
if (await userRepo.exists(command.email)) { return Result.err(new UserAlreadyExistsError()); // <- returning an Error } // else const user = await this.userRepo.create(user); return Result.ok(user);


返回錯誤而不是丟擲它們會增加一些額外的樣板程式碼,但可以使您的應用程式更加健壯和安全。
注意:區分域錯誤和異常。異常通常被丟擲而不返回。如果您返回技術異常(如連線失敗、程式記憶體不足等),可能會導致一些安全問題,並違反Fail-fast原則。返回異常不是終止程式流,而是繼續執行程式並允許它在不正確的狀態下執行,這可能會導致更多意外錯誤,因此在這些情況下通常最好丟擲異常而不是返回異常。
示例檔案:
  • user.errors.ts - 使用者錯誤
  • create-user.service.ts - 注意如何Result.err(new UserAlreadyExistsError())返回而不是丟擲它。
  • create-user.http.controller.ts - 在使用者 http 控制器中,我們開啟一個錯誤並決定如何處理它。如果出現錯誤,UserAlreadyExistsError我們會丟擲Conflict Exception一個使用者將收到的409 - Conflict. 如果錯誤未知,我們只需將其丟擲,NestJS 會將其作為500 - Internal Server Error.
  • create-user.cli.controller.ts - 在 CLI 控制器中,我們不關心返回正確的狀態程式碼,所以我們只是.unwrap()一個結果,它只會在錯誤的情況下丟擲。
  • exceptions資料夾包含一些通用應用程式異常(不是特定於域的)
  • exception.interceptor.ts - 在這個檔案中,我們將應用程式的通用異常轉換為 NestJS HTTP 異常。這樣,我們就不受框架或 HTTP 協議的束縛。


基礎設施層
基礎設施層負責封裝技術。您可以在那裡找到用於儲存/檢索業務實體的資料庫儲存庫、用於發出訊息/事件的訊息代理、用於訪問外部資源的 I/O 服務、與框架相關的程式碼以及代表架構可替換細節的任何其他程式碼的實現。
它是最不穩定的層。由於這一層中的事物很可能發生變化,因此它們儘可能遠離更穩定的域層。因為它們是分開的,所以進行更改或將一個元件換成另一個元件相對容易。
基礎設施層可以包含Adapters資料庫相關檔案Repositories,如ORM entities/ Schemas、框架相關檔案等。

1、介面卡

  • 基礎設施介面卡(也稱為驅動/輔助介面卡)使軟體系統能夠透過在請求時接收、儲存和提供資料(如永續性、訊息代理、傳送電子郵件或訊息、請求第 3 方 API 等)與外部系統進行互動。
  • 介面卡也可用於與單個程式內的不同域進行互動,以避免這些域之間的耦合。
  • 介面卡本質上是埠的實現。它們不應該在程式碼中的任何位置直接呼叫,只能透過埠(介面)呼叫。
  • 介面卡可用作遺留程式碼的防損壞層 (ACL)。

閱讀有關 ACL 的更多資訊:反腐敗層:如何防止遺留支援破壞新系統
介面卡應具有:
  • 它實現的應用程式/域層中的port某個地方;
  • 將資料從域對映到域的對映器(如果需要);
  • 接收資料的 DTO/介面;
  • 一個驗證器,以確保傳入的資料沒有損壞(驗證可以使用裝飾器駐留在 DTO 類中,或者可以透過 驗證Value Objects)。


2、儲存庫
儲存庫是對存在於資料庫中的實體集合的抽象。它們集中了通用資料訪問功能並封裝了訪問該資料所需的邏輯。實體/聚合可以放入儲存庫,然後在以後檢索,即使不知道資料儲存在哪裡:在資料庫中、檔案中或其他來源。
我們使用儲存庫將用於訪問資料庫的基礎設施或技術與域模型層分離。

Martin Fowler 對儲存庫的描述如下:
儲存庫執行域模型層和資料對映之間的中介任務,其作用類似於記憶體中的一組域物件。客戶端物件以宣告方式構建查詢並將它們傳送到儲存庫以獲取答案。從概念上講,儲存庫封裝了儲存在資料庫中的一組物件以及可以對它們執行的操作,提供了一種更接近持久層的方式。儲存庫還支援在一個方向上清晰地分離工作域與資料分配或對映之間的依賴關係的目的。

這裡的資料流看起來像這樣:儲存庫Entity從應用程式服務接收域,將其對映到資料庫模式/ORM 格式,執行所需的操作(儲存/更新/檢索等),然後將其對映回域Entity格式並返回給服務。

應用程式的核心通常不允許直接依賴於儲存庫,而是依賴於抽象(埠/介面)。這使得資料檢索技術與技術無關。

3、永續性模型
將單個實體用於域邏輯和資料庫關注點會導致以資料庫為中心的架構。在 DDD 世界中,域模型和永續性模型應該分開。
由於域Entities對其資料進行了建模,以便最好地適應域邏輯,因此它可能不是儲存在資料庫中的最佳狀態。為此目的Persistence models,可以建立具有在所使用的特定資料庫中更好地表示的形狀。領域層不應該對永續性模型一無所知,也不應該關心。
可以有多個模型針對不同目的進行最佳化,例如:

  • 域具有自己的模型-Entities和.AggregatesValue Objects
  • 具有自己的模型的持久層 - ORM(物件-關係對映)、模式、讀/寫模型(如果資料庫被分成讀寫資料庫(CQRS)等)。

隨著時間的推移,當資料量增加時,可能需要對資料庫進行一些更改,例如透過重新設計某些表甚至完全更改資料庫來提高效能或資料完整性。如果沒有明確區分模型Domain和Persistance模型,對資料庫的任何更改都將導致您的域Entities或Aggregates. 例如,在執行資料庫規範化時,資料可以分佈在多個表中,而不是在一個表中,反之亦然這可能會迫使團隊對域層進行完整的重構,這可能會導致意外的錯誤和挑戰。分離域和永續性模型可以防止這種情況。

注意:對於較小的應用程式來說,分離域模型和永續性模型可能是多餘的。建立和維護樣板程式碼(如對映器和抽象)需要付出很多努力。在做出此決定之前,請考慮所有利弊。
示例檔案:


更詳細點選標題見Github專案
 

相關文章