iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 16
4
Software Development

Think in Domain-Driven Design系列 第 16

DDD 戰術設計:組織你的幫派 Aggregate 聚合設計

DDD 戰術設計:Aggregate 聚合設計

當我們的領域擁有越來越多的 Entity 與 Value Object,根據業務規則的需求,模型之間關聯性的複雜度可能會超出我們的想像。尤其是當你越想要在生命週期中維護完整性,你就越難管理他們的關聯。

一個物件的生命週期的變化大概上會如下圖 (Value Object 則只有建立與刪除),可以看到一個物件的變化很多時候還會觸發連鎖反應。

https://ithelp.ithome.com.tw/upload/images/20191002/20111997p4gLEmyDtq.png
(Ref: DDD Book)

今天的文章將會介紹 Aggregate 來為這些互相牽連的物件們畫立一個清晰的界線,減少互動的複雜性以及保護界線內規則的完整性,並試圖回答以下問題:

  1. 如何處理模型之間複雜的關聯性?
  2. 我們該如何在複雜的關係中找出一個邊界?
  3. 如何把這個關聯性連同模型存入資料庫?
  4. 使用 Repository 模式時是否要為每個 Entity 都建立一個 Repository?

首先,我們先由罪惡的源頭:「狀態更改」開始說起。

Aggregate 模式:確保更改的一致性

我們都了解,在具有複雜關聯性的模型中,要想確保「物件更改的一致性」是很困難的,尤其是當事物之間的關聯性糾纏在一起。比如一張訂單由訂單品項、折扣、金流、物流、發票等要素組成,但一旦你修改了一個訂單品項,你必須連帶地重新計算折扣(滿額折扣)、金流(刷退重新付款)、物流(金額限制)、發票(廢除後重新開立),甚至可能還會影響會員等級的升降。

因此,我們需要 Aggregate 的幫助來降低這些複雜性。Agregate 是一群相關聯的物件的組合,讓我們可以把它作為一個狀態變更的單位。而每個 Aggregate 都需要有一個 Entity 作為他的 Aggregate Root,任何的改變與事件傳遞,都要先通過 Aggregate Root,再傳到裡面的元件。

在引入 Aggregate 前,我們的模型設計會像是下圖,必須隨時面對不知道從哪裡來的狀態變更,還需要維持整體模糊的完整性。

https://ithelp.ithome.com.tw/upload/images/20191002/20111997oBvOQAM28D.png

讓我們看看若是我們將這些物件都放入 Aggregate 的邊界內,然後指定 Order 這個 Entity 作為 Aggregate Root,我們的模型會變成如何:

https://ithelp.ithome.com.tw/upload/images/20191002/20111997CpU3Q8hRGE.png

可以看到,不論是何種狀態變更,都必須先經過 Aggregate Root Order 才能進入跟裡面的物件做互動,而 Aggregate Root 不但可以很好地處理這項需求,也可以同時遵守「資料變化時必須保持一致的規則」,也就是俗稱的固定規定 (Invariant)

讓我們來看看一個訂單概念可能會有多少的固定規則:

  1. 訂單品項 (OrderItem) 的內容與數量可以被新增、減少或異動,但最終總數量不能為 0、最終總額不能小於 0
  2. 訂單金流有最低最高訂單金額使用限制。
  3. 訂單物流有最低最高訂單金額使用限制,而且新增品項時也要考量某些商品搭配特定物流(如冷藏宅配)。
  4. 台灣政府規定,若是信用卡付款,電子發票須加註卡號後四碼
  5. 訂單若是取消,則將金流退款、訂單品項相關的商品加回庫存
  6. 其他更多

讀者可以試著思考看看,如果沒有 Aggregate 的幫助,要如何靠一團鬆散的物件保證業務的正確性?更別提與其他的 Aggregate 做交流這件事情。假如今天訂單品項修改數量僅會單純地影響庫存變化,那你原先的物件關聯路徑會像這樣:OrderItem.update()Order.check()Order.update()Inventory.update(),如果使用 Aggregate,那就會變成 Order.updateItem()Inventory.update(),一下子就簡單許多。

Aggregate 的設計原則

由以上的案例,我們就可以帶出幾個 Aggregate 的設計原則:

  • Aggregate Root 的 Entity 必須要在 Bounded Context 中有唯一標示性,它的 ID 不能與其他 Aggregate Root 重複。
  • Aggregate Root 負責檢查邊界內所有固定規則。
  • Aggregate 外的物件不能引用除了 Aggregate Root 之外的任合內部物件。
  • 根據上一條規則,只有 Aggregate Root 才能直接透過資料庫查詢來獲取。內部的其他物件都要透過 Aggregate Root 才能取得。
  • Aggregate 內部物件可以引用其他 Aggregate Root,但僅能引用該 Aggregate Root 的 ID
  • 刪除操作必須一次刪除 Aggregate 邊界內的所有物件
  • 當提交對 Aggregate 內部的任何物件的修改時,整個 Aggregate 的固定規則都要被滿足,意指一次就要存整個 Aggregate 進去。

這邊可能會有人會有效能上的疑慮。寫入方面,將變更寫入資料庫要一次將整個 Aggregate 塞進去不但消耗效能且會一次鎖住 Aggregate 內的所有物件的資料表 (table),聽起來很不實際。因此,我們後面會有相關的設計來縮小 Aggregate 的大小,而且事實上比起效能,大多時候「正確性」應該要先被優先考慮,再來才是對有需要的效能做優化。

再來是讀取方面,有可能我今天僅需要一個訂單列表僅包含「訂單編號」與「訂單狀態」兩個欄位,不需要後面跟著一坨拉庫的詳細品項、金物流、發票等等。同樣地,如果這真的造成很大的效能隱憂,到時候可以再考慮引入 CQRS 模型來對讀取做優化。在 CQRS 模型中,你的每次 Query 可以自行客製化內容,而不需要直接引用 Aggregate。

如何找出 Aggregate

找出 Aggregate 的方式有很多,其中之一就是透過 Event Storming 找出固定規則以及有相關連的物件,可以參考前面的篇漲。另外,我們還有一些方法可以幫助你。

第一步:數大便是美

設計 Aggregate 有一個觀念:

Aggregate 拆越大,複雜度越低、效能越差;Aggregate 拆越小,複雜度越高、效能越好。

當你的 Aggregate 拆越小越多,你就要負責維持彼此間的最終一致性,所以任何的設計方法都有他的優缺點。因此設計的第一步就是:越大越好。

當然,並不是說只建立一個就好,舉商店來說,最大的聚合就是 Shop 這個物件,但如果你今天把所有其他的 OrderProdcutDiscount 等等都放在 Shop 這個 Aggregate 裡,那就會發生一些不方便的事情。比如你只是想更改 Shop 的商店說明,你卻要鎖住所有的 Table,連訂單都不能建立,直到 Shop 更新完成。

當你先用這個方式,雖然不盡完美,但你可以先快速產出一個大概。

第二部:短小才能精幹

有了大 Aggregate 之後,開始可以透過更多的使用案例來對聚合做更多設計。特別注意那些可能由兩個以上使用者同時修改一個 Aggregate 的情況

比如原先由於訂單是屬於會員的,而且訂單也關乎會員的購物金與等級,於是你把 Order 規劃到 Member 的 Aggregate 底下。但問題來了,有可能當會員在下訂單的同時,商家正在進行對會員寄送購物金的動作而讓會員下單失敗,這件事情是不能被接受的!因此我們可以把 OrderMember 分開成兩個 Aggregate,但是 Order 身上帶有 MemberID 來保持它對於 Member 的引用。

Aggregate 實作

其實在實作方面,Aggregate 比較像是一個邊界而非一個實際的物件,所以我們僅會實作 AggregateRoot class 讓 Aggregate Root 去繼承。而實際上那也還是一個 Entity,因此表達性意義大於實際意義。不過之後會介紹到 Domain Event,若是想要實作這個模式的,我們會在之後對 Aggregate 做擴展。

export abstract class AggregateRoot<
  Id extends EntityId<unknown>,
  Props
> extends Entity<Id, Props> {}

Summary

如果說 Bounded Context 像是國界,阻擋外部勢力的不當入侵。那麼 Aggregate 就像是國界內的一個個小幫派,而幫派之間只能透過老大(Aggregate Root) 來溝通,幫派之內也只有老大 (Aggregate Root)可以指派小弟 (Aggregate 內其他物件) 做事情。

不過 Aggregate 可能是 DDD 最複雜的設計模式之一,下一篇我們將繼續探討 Aggregate 更多的設計守則吧!

Reference


上一篇
DDD 戰術設計:Value Object 概念與實作
下一篇
DDD 戰術設計:Aggregate 聚合設計 (續)
系列文
Think in Domain-Driven Design30

1 則留言

0
aix
iT邦新手 5 級 ‧ 2020-11-20 21:51:30

剛剛打完整篇問題,因為是新手練習生的關係,結果被系統吃掉,所以重新再打一次

真的是一系列的好文章,我之前只關注戰術設計的部份,沒想到有戰略設計的部份,收穫挺多的,但我找不到按鈕可以按like

我這邊有個問題想要提問,關於Aggregate的設計?(因為我參考書籍的範例,都比較簡單,我自己有些場景想要套進去,但不知道如何下手,不知道怎樣做才是對的,我覺得這邊是我接觸戰術設計上最難的地方)

以下是我的Use Case:

  • 顧客確定付完款之後訂單成立,訂單成立之後要進行拆單的動作(Purchase Order),讓不同的廠商進行出貨(一張訂單可以來自不同廠商的商品)
  • 在鑑賞期間,顧客可以進行退貨的動作(可以針對特定的OrderItem進行退貨)
  • 假如已經出貨的話,必須歸還商品至廠商,才能進行退款
  • 假如還沒有出貨,可以直接進行退款

我的問題如下:

  • 假設將Order 和 Purchase Order(Shipment) 拆分成兩個不同的Aggregate,Order Aggregate 在Order成立之後發出一個事件 OrderCreated的事件,Purchase Order Aggregate接到事件之後產生Purchase Order (基於Use Case第一個論點)
  • 之後有顧客要進行退貨,但有沒有出貨的資訊其實在 Purchse Order Aggregate裡面(因為在不同的Aggregate,所以我認為一定要從Purchase Order Aggreagte知道目前出貨的狀態),所以假如有一個退貨的Command產生,應該是由Purchase Order Aggregate負責接收,因為只有它知道能判斷最一開始的決策(儘管可能沒有出貨可以直接退款)?
  • 但整個概念上,也可以是由Order Aggregate出發,基於顧客只知道Order,不知道Purchase Order,那代表說,退貨的Command應該由Order Aggregate接收在OrderItem壓個狀態(處理中之類的),之後發一個事件出去(先不管Event 還是 Command),給 Purchase Order Aggregate 處理歸還貨物,處理完之後可能有兩種事件,沒有貨物需要歸還或者歸還之後發一個已經歸還貨物的事件,最後都由Order Aggregate接收之後進行退款,所以這樣的流程比較合理嘛? Order Aggregate ⇒ Purchase Order Aggregate ⇒ Order Aggregate
  • 那是不是基於上面那個流程(反正最終還是要回到Order Aggregate),是不是乾脆合併成一個Aggregate就好(把出貨、退款、換貨所有的商業邏輯(還不考慮金流、發票)全部都放在一起呢?可是這麼龐大的Aggregate,有一種說不上的違和感,以這個例子到底是要合併還是分開好呢?)
  • 最後有三個小問題,第一個小問題,假如可以單獨針對特定OrderItem進行退購或換貨,是不是說其實OrderItem應該是Entity而非VO呢?
  • 第二個小問題,付完錢訂單才能成立,但大大你在Order Aggregate下把Payment視為Order的VO,我就在想付錢這個動作,應該是Order Aggregate底下的商業邏輯(ex: order.pay("XXXpay")),但沒有付錢成功就沒有訂單的存在,怎麼執行這個動作呢?我在理解上犯了什麼樣的錯?
  • 第三個小問題,在Order aggregate裡面,Payment是Order的VO,也就是代表說Order Aggregate裡面Payment是不能被更改的,可能是由另外一個Payment Aggregate決定Payment的狀態,假如是這樣我第二個小問題可能就有解答,就是付錢的動作由Payemnt Aggregate負責,之後付完錢之後,發出已付錢事件,由Order Aggregate接收之後成立Order,但假如是這樣,Order Aggregate下的Order是否就沒有必要有 Payment這個VO了呢?(其實我一直搞不清楚一件事,aggregate root 底下的 entity 和 VO可以是另外一個aggregate root但只能有reference ID,這有什麼作用呢?反正我就不能改它?)

我猜我的問題,可能沒有很精準地問到重點,這邊先跟作者道歉一下

我要留言

立即登入留言