聚合設計四條規則,以及 AI 生成程式碼最常違反的那一條
領域驅動設計的聚合是保護業務不變量的邊界,Milan Jovanović 在文中整理了 Vaughn Vernon 提出的四條設計規則。AI 生成程式碼經常把跨聚合的狀態修改直接寫進應用服務,並將業務規則塞入服務層,導致貧血模型。本文對照四條規則與常見違規樣貌,並給出讓 AI 正確生成聚合的約束條件。
🔖標籤 #ddd #ddd思考 #ai
[!AI] 本文摘要
領域驅動設計的聚合是保護業務不變量的邊界,Milan Jovanović 在文中整理了 Vaughn Vernon 提出的四條設計規則。AI 生成程式碼經常把跨聚合的狀態修改直接寫進應用服務,並將業務規則塞入服務層,導致貧血模型。本文對照四條規則與常見違規樣貌,並給出讓 AI 正確生成聚合的約束條件。
聚合是領域驅動設計裡用來保護業務不變量的邊界。Milan Jovanović 在 Aggregate Design in DDD - Rules, Boundaries, and Consistency 一文中指出,聚合把必須在同一交易內保持一致的領域物件群聚在一起,並由聚合根控制所有變更。如果沒有聚合,領域規則會散落在各個服務裡,物件之間的修改失去協調,最終退化成貧血領域模型。
現在越來越多團隊用 AI 生成程式碼。AI 很擅長產出語法正確的類別與方法,但對「邊界」與「交易一致性」這類需要領域理解才能判斷的設計決策,往往缺乏敏感度。最常見的結果是 AI 產出的程式碼看起來有聚合的樣子,實際上卻把跨聚合的狀態修改與業務規則全部塞進應用服務,聚合根淪為只有屬性的資料容器。
聚合的四條設計規則
Milan Jovanović 在文中引用 Vaughn Vernon 的 Effective Aggregate Design 一文,整理出四條聚合設計規則。這四條規則是判斷聚合邊界是否合理的依據,也是我們檢視 AI 生成程式碼時的檢查清單。
第一條是保護聚合邊界內的業務不變量。不變量是必須永遠為真的業務規則,聚合根負責強制執行這些規則。Milan Jovanović 在文中以 Order 聚合為例,AddLineItem 方法會檢查訂單狀態是否為 Draft、數量是否為正數、價格幣別是否為 USD,並在加入項目時重新計算總金額。這些規則全部封裝在聚合根的方法裡,外部程式碼無法繞過。
第二條是設計小的聚合。文中指出,過大的聚合會導致效能問題、並行衝突與相依性糾纏。他舉了一個反面例子:Customer 聚合直接持有 Orders、Addresses、PaymentMethods、ShoppingCart、LoyaltyAccount 等集合,每次操作都要載入大量關聯資料。正確做法是把每個概念拆成獨立的小聚合,彼此之間只透過識別碼引用。
第三條是透過識別碼引用其他聚合。Milan Jovanović 在文中示範,Order 聚合不應直接持有 Customer 物件的導覽屬性,而應只持有 CustomerId。這樣每個聚合可以獨立載入、儲存與推理。如果某個使用案例需要其他聚合的資料,應在應用層分別載入,而不是在同一個物件圖裡直接操作。
第四條是使用最終一致性更新其他聚合。當跨聚合的變更不需要在同一交易內完成時,應透過領域事件來通知其他聚合做後續更新。文中指出,這讓每個聚合維持獨立,同時允許系統在交易邊界外達成一致性。
AI 生成程式碼最常違反的樣貌
AI 生成程式碼時,最容易違反的是第一條與第三條的組合:跨聚合直接改狀態,同時把規則塞進服務。以下是示意,展示 AI 常見的產出樣貌。
// AI 常見的寫法:應用服務直接操作多個聚合的內部狀態
public class OrderService
{
public void SubmitOrder(Guid orderId)
{
var order = _orderRepo.GetById(orderId);
var customer = _customerRepo.GetById(order.CustomerId);
order.Status = OrderStatus.Submitted; // 繞過聚合根的驗證
customer.LoyaltyPoints += 10; // 跨聚合直接改狀態
_orderRepo.Save(order);
_customerRepo.Save(customer);
}
}
這段程式碼有兩個問題。第一,order.Status 被直接設值,繞過了聚合根應有的狀態轉換驗證;如果 Submit 需要檢查訂單是否處於可提交狀態,這條規則就漏掉了。第二,customer.LoyaltyPoints 被直接修改,等於在一個交易裡同時改了兩個聚合,違反了一次交易只改一個聚合的原則。
Derek Comartin 在 Aggregate Design: Using Invariants as a Guide 一文中也強調,聚合設計的核心在於理解不變量。不變量決定了邊界,邊界決定了哪些物件該聚在一起。AI 產出的程式碼通常不會先分析不變量,而是直接依照功能描述產生物件與方法,因此規則自然會外溢到服務層。
另一個常見樣貌是貧血領域模型。Milan Jovanović 在文中指出,沒有聚合會導致貧血領域模型,業務規則外洩到應用服務。AI 生成程式碼時,預設傾向把驗證邏輯寫在 Service 方法裡,聚合根只剩下屬性與 getter/setter。這正是貧血模型的特徵:資料與行為分離,聚合根失去保護不變量的能力。
跨聚合不變量的設計難題
有時候一條業務規則需要多個聚合的資訊才能驗證,這時該怎麼設計?Stack Overflow 上有一個相關討論。發問者在 How should I design DDD aggregate with an invariant based on information from multiple aggregates? 中描述,檢查憑證條件需要訂單季節、供應商國家、憑證清單等資訊,而這些資料分散在不同限界上下文裡。
發問者想確認是否應該把所有必要資料放進同一個聚合以確保強一致性。回答者建議使用 Order 與 Supplier 兩個限界上下文,各自作為獨立聚合,並透過應用服務從倉儲拉取所需資料,在應用服務組裝資料後驗證憑證不變量,再交由領域服務決定訂單處理。留言區讀者 mtj 建議透過領域事件來處理跨上下文的變更分發,但另一位讀者 Levi Ramsey 指出領域事件意味著最終一致性,而這在該問題中已被排除。
這個討論帶出一個重點:跨聚合的強一致性需求會讓設計變得複雜。如果規則必須在交易內立即成立,且資料分散在不同聚合,就必須在應用層組裝資料,再交由領域服務執行驗證。AI 生成程式碼時通常不會區分這些層次,而是直接在服務方法裡寫死驗證邏輯,導致規則既不在聚合根內,也不在領域服務裡,而是散落在應用服務中。
讓 AI 正確生成聚合的約束
要讓 AI 產出符合聚合設計規則的程式碼,關鍵在於給予足夠的約束。AI 不會自行推導不變量與邊界,這些必須由人類在提示中明確交代。以下是示意,展示在提示 AI 之前應該先整理好的約束清單。
請根據以下約束生成 Order 聚合:
1. 聚合根:Order,對外唯一入口。
2. 不變量:
- 只有 Draft 狀態的訂單可以加入項目。
- 數量必須大於零。
- 總金額必須等於所有項目小計之和。
3. 跨聚合引用:只持有 CustomerId,不持有 Customer 物件。
4. 狀態變更:所有修改只能透過聚合根的方法,屬性 setter 設為 private。
5. 跨聚合更新:提交訂單後透過領域事件通知 Customer 聚合更新會員點數,不在同一交易內直接修改。
這份清單把四條規則轉化成具體的生成約束。AI 收到這些約束後,產出的程式碼會把驗證邏輯放在聚合根的方法裡,跨聚合只使用識別碼,並透過領域事件處理後續更新。如果省略這些約束,AI 會退回預設行為,把規則寫進服務層,產生貧血模型。
重點
- 聚合的四條規則是:保護邊界內的不變量、設計小的聚合、透過識別碼引用其他聚合、使用最終一致性跨聚合更新。這四條規則來自 Vaughn Vernon 的 Effective Aggregate Design 一文,由 Milan Jovanović 在文中整理。
- AI 生成程式碼最常違反的是跨聚合直接改狀態,以及把業務規則塞進應用服務。這兩個問題的根源是 AI 不會自行分析不變量與邊界,需要人類在提示中明確給予約束。
- 讓 AI 正確生成聚合的做法是在提示中交代聚合根、不變量、跨聚合引用方式、屬性可見性與跨聚合更新策略。沒有這些約束,AI 產出的程式碼會退化成貧血領域模型。
- 跨聚合的強一致性需求應在應用層組裝資料並交由領域服務驗證,而非在同一交易內直接修改多個聚合。如果可以接受最終一致性,則透過領域事件通知其他聚合做後續更新。