
圖片來源:ChatGPT 生成
農曆年後開工至今差不多過了一個禮拜,還是來聊聊軟體設計吧!這篇來聊一下 Aggregate,但不是聊 Aggregate 有什麼好處,要怎麼設計,這些內容在很多書和網路的文章都有,不用我再贅述,而是聊怎麼誤打誤撞把原本不是 Aggregate 的設計變成類 Aggregate 的設計。
起源
故事的起源是一個單純的功能,想像一下,在系統中,主管能發送 Google 表單,然後員工能填寫表單,而且表單發送出去後無法修改,不同員工之間在填寫時也沒有相互關係,因此一開始的設計很單純,由一個 Entity 代表表單:Survey,另一個 Entity 代表填寫的內容:Submission,並有 service 提供 use cases:發送表單、填寫表單和編輯已填寫的表單。
一致性問題
初期運作良好,直到第一個需要處理一致性問題的需求誕生:表單內容錯了怎麼辦?即便 UI/UX 有再次確認等避免發送錯誤內容的設計,但有設計過系統的都知道,使用者通常都沒在看,等到發現錯了再修改。
如果 Survey 和 Submission 之間是彼此獨立,這其實也沒什麼,就直接修改 survey 物件即可,但偏偏 Survey 和 Submission 是關聯的。
舉個簡單的例子,例如,原先有個問題是「你想搭配什麼飲料?」,選項有「可樂、橘子汁和氣泡水」,已經有一位使用者填寫橘子汁。後來發現選項有錯,要改成「可樂、季節果汁和氣泡水」,修改問題容易,但已經填寫的內容怎麼辦?保留不變更?自動改成季節果汁?
這恐怕不是系統能決定的,最理想的方式就是整個調查作廢,讓使用者再填寫一次,而這次用的是比較資料庫導向的做法,是的,這裡並沒有使用比較符合 Domain Driven Design 的做法,而是在 SubmissionRepository 中新增一個特別的函式 remove(surveyCriterion),然後 SurveyService 修改 survey 後,把已經填寫的內容全部作廢。
任何設計都是一種 trade off,這作法的優點是施工簡單,且當下並沒有使用該設計會有致命問題的缺點,是一個可以考慮的作法。
更複雜的一致性
最近,在收集到更多需求後,對這功能增加了許多設定與規範,這也導致不止 Survey 和 Submission 關聯更深,連 submission 物件間也出現了關聯,一個員工填寫的內容,會影響到之後員工填寫的內容,為了確保能符合這些規則,有幾個選項:
- 悲觀鎖 — 在處理 Submission 時,必須先取得鎖,所有人都排隊一個一個處理
- 樂觀鎖 — 先處理,當儲存時發現狀態已經變動,撤回再來一次 (參閱 閒談軟體設計:樂觀鎖 )
在考慮併發的頻率 (多少員工同時送出表單) 後,想先用樂觀鎖,但要使用樂觀鎖面臨到的一個問題是:誰擁有樂觀鎖?讓 Submission 持有?顯然行不通,一個 submission 的樂觀鎖無法確保另一個 submission 的一致性。
讓 Survey 持有?確實是一個選項,但卻讓我有點猶豫,如果讓 Survey 持有樂觀鎖,則勢必要讓 survey 持有所有的 submission 物件,這讓已經夠複雜的 survey 物件增加複雜度。
於是想到新增一個物件完成以下幾件事:
- 持有樂觀鎖 — 確保規則不滿足時,會撤回儲存的動作
- 持有 survey 物件 — 用來檢查規則
- 持有所有的 submission 物件 — 用來檢查規則
- 檢查所有要滿足的規則 — 確保一致性
但怎麼命名?這邊很難找到更好的名詞,因為 Survey 本身就是最合適的,和 ChatGPT 反覆討論十幾個名詞後,最後決定用 SurveyCampaign 作為這個物件的名稱,一個由 Survey 發起的活動。
此時,原本放在 service 的邏輯都被移到這個新的 SurveyCampaign 物件裡,第 8 行確保填寫的內容都符合表單的規範,第 10 行確保填寫的內容彼此之間沒有違反規範,如果有違反,都會拋出對應的 Exception (這裡為了排版長度簡化成一個 exception,實際是好幾個不同的 domain exception)。
此外,新增 SurveyCampaignRepository,負責檢查樂觀鎖,讓 service 只需要取出 campaign,呼叫 campaign.submit(request),最後再存回,如果同時有另一個員工先送出了,樂觀鎖會阻擋此次的儲存。
未完待續
就這樣,新增的需求都滿足了。當時順便問了一下 ChatGPT,這樣的設計符合什麼 pattern?此時 ChatGPT 洋洋灑灑地說了幾個 pattern,但仔細檢驗會發現有問題,例如,我就找不到 Derived Aggregate 的出處,接著問出處,沒想到 ChatGPT 的回覆讓我傻眼:
「Derived Aggregate」不是一個正式、標準化、可被引用的經典 pattern 名稱。
可見任何 AI 的回答都是需要自己去考證的,一臉正經胡說八道可是 AI 的強項。
先不管 Derived Aggregate 是否存在,目前的設計離真正的 Aggregate 其實還有一段距離,在《Domain-Driven Design — Tackling Complexity in the Heart of Software》書中對於 Aggregate 有提到幾個規則:
- 是有全域 ID 的 root entity 並負責檢查不變量
- 內部的 entities 在 aggregate 範圍內有內部 ID 確保唯一性
- Aggregate 邊界外部不能直接持有內部 entities 的參考
- 只能從資料庫取得 Aggregate root,內部的 entities 都只能透過關聯取得
- Aggregate 內部的物件可以持有其他 aggregate 物件
- 刪除 Aggregate 時須確保邊界內的內部物件都被清除
- 任何 Aggregate 內部物件的修改都須確保整個 Aggregate 的不變量被滿足
目前的設計是否同時滿足上述規則有很大的討論空間,特別是第三點和第四點,由於是透過重構的方式調整設計,在沒有修改原有的 service 的情況下,系統是允許繞過 SurveyCampaign 取得 Survey 和 Submission 物件。
要考量到效率時,不是所有 Survey 的查詢都會同時要取得 Submission,反之,有時會需要取得單一筆 Submission,而不需要 Survey。如果要符合上述規則,就必須將原有的查詢都改成 Read Model (參閱 閒談軟體設計:Read Model),不然有機會違反最後一條規則,這重構的範圍又太大了。
最後一條規則又更麻煩,Submission 有提供函式作為使用者編輯時修改內容之用,如果要滿足這規則,一種方是是將這個函式的可見度,從 public 降成 package 或 protected,只有同個層級的 Aggregate 可以操作。
但同個層級的不一定只有 Aggregate,要真的滿足,Aggregate 回傳 entities 時,要進行一次 clone 的動作或是回傳 immutable interface (參閱 閒談軟體設計:Immutable Interface)。
所以,目前的設計只能說是有 Aggregate 精神的不完整實作,在滿足系統需求下的特殊解,如果要變成真正的 Aggregate 還有不少地方要調整。
小節
其實一開始並沒有要用 Aggregate,單純是想要有一個樂觀鎖的載體,只是沒想到最後形成的設計,有那麼一點 Aggregate 的精神在裡面,從這例子可以說明:沒有什麼設計是一定要一次到位的,在符合需求的情況下慢慢重構優化都是有可能的,雖然重構有時候會比較花時間 (文章中只討論到物件設計上的修改,但其實背後還有 database schema 的搭配調整),與其擔心設計不到位,過度設計往往是更頭痛難以處理的。
























