Aggregate(聚合)
是什麼?
Aggregate 是一組相關物件(Entity 和 Value Object)的集合,定義了一個一致性邊界(Consistency Boundary)。外部只能透過 Aggregate Root(聚合根) 來存取和修改內部物件,確保業務規則和資料一致性。
ℹ️DDD 交易邊界
Aggregate 是 DDD 中定義交易邊界的核心概念。一個 Aggregate 內的所有變更在同一個交易中完成,跨 Aggregate 的一致性則透過 Domain Events 實現最終一致性。
核心觀念
- Aggregate Root:Aggregate 的入口點,外部只持有 Root 的參考,透過 Root 操作內部物件。辨識方式:如果一個 Entity 會被外部直接查詢和操作,它就是 Aggregate Root。例如
Order是 Root,OrderLineItem不是 - 一致性邊界:Aggregate 內部的所有不變量(Invariants,即「任何時刻都必須成立的業務規則」)在每次操作後都必須成立。例如「訂單總額必須等於各明細小計之和」就是一個不變量
- 交易邊界:一個資料庫交易(Transaction)只修改一個 Aggregate,不跨 Aggregate 做交易。如果需要跨 Aggregate 的一致性,用 Domain Event + 最終一致性(Eventual Consistency)處理
- 不要跨 Aggregate 直接修改:Aggregate 之間只能透過 ID 參考,不能持有物件參考。例如
Order存CustomerId(ID)而不是Customer(物件),這避免了跨 Aggregate 的直接修改 - 小而精:Aggregate 越小越好,只包含必須一起保持一致的物件。判斷標準:如果兩個 Entity 不需要在同一個交易中保持一致,就不要放在同一個 Aggregate
常見誤區
⚠️常見誤區
誤區一:把整個系統塞進一個巨大的 Aggregate。Aggregate 越大,併發衝突越嚴重,效能越差。只把「必須一致」的東西放在一起。
誤區二:Aggregate 之間直接持有物件參考。正確做法是只存 ID,需要時透過 Repository 載入。
誤區三:在一個交易中同時修改多個 Aggregate。這會造成鎖定範圍過大。跨 Aggregate 的一致性應該用 Domain Events 和最終一致性來處理。
設計流程
識別不變量
找出哪些業務規則必須在任何時刻都成立(如訂單總額 = 各明細小計之和)
劃定邊界
把必須一起滿足不變量的物件放進同一個 Aggregate
選定 Aggregate Root
選出一個 Entity 作為入口,所有外部操作都透過它
限制外部存取
內部 Entity 不對外暴露,外部只能透過 Root 的方法操作
跨 Aggregate 用事件
需要跨 Aggregate 同步的邏輯用 Domain Events 實現
程式碼範例
C# 版本
// Aggregate Root:訂單
public class Order : Entity<Guid>
{
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
private readonly List<OrderLineItem> _lineItems = new();
public IReadOnlyList<OrderLineItem> LineItems => _lineItems.AsReadOnly();
public Order(Guid id, CustomerId customerId) : base(id)
{
CustomerId = customerId;
Status = OrderStatus.Created;
TotalAmount = Money.Zero();
}
// 所有修改都透過 Aggregate Root 的方法
public void AddLineItem(ProductId productId, string productName, Money unitPrice, int quantity)
{
if (Status != OrderStatus.Created)
throw new InvalidOperationException("Cannot modify a placed order.");
var existing = _lineItems.FirstOrDefault(li => li.ProductId == productId);
if (existing != null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_lineItems.Add(new OrderLineItem(productId, productName, unitPrice, quantity));
}
RecalculateTotal(); // 確保不變量:總額 = 各明細之和
}
public void RemoveLineItem(ProductId productId)
{
var item = _lineItems.FirstOrDefault(li => li.ProductId == productId)
?? throw new InvalidOperationException("Line item not found.");
_lineItems.Remove(item);
RecalculateTotal();
}
public void Place()
{
if (!_lineItems.Any())
throw new InvalidOperationException("Cannot place an empty order.");
Status = OrderStatus.Placed;
// 可以在這裡發出 Domain Event: OrderPlaced
}
private void RecalculateTotal()
{
TotalAmount = _lineItems.Aggregate(
Money.Zero(),
(sum, item) => sum.Add(item.Subtotal));
}
}
// 內部 Entity:訂單明細(不對外暴露修改方法)
public class OrderLineItem
{
public ProductId ProductId { get; }
public string ProductName { get; }
public Money UnitPrice { get; }
public int Quantity { get; private set; }
public Money Subtotal => UnitPrice.Multiply(Quantity);
internal OrderLineItem(ProductId productId, string productName, Money unitPrice, int quantity)
{
ProductId = productId;
ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
internal void IncreaseQuantity(int amount)
{
Quantity += amount;
}
}
// 使用 — 外部只能透過 Order(Root)操作
var order = new Order(Guid.NewGuid(), customerId);
order.AddLineItem(productId, "鍵盤", new Money(2500, "TWD"), 1);
order.Place();
// ❌ 錯誤:不能直接操作 LineItem
// order.LineItems[0].IncreaseQuantity(5); // internal 方法,外部無法呼叫TypeScript 版本
class Order {
private lineItems: OrderLineItem[] = [];
private status: OrderStatus = OrderStatus.Created;
private _totalAmount: Money = Money.zero();
constructor(
public readonly id: string,
public readonly customerId: string,
) {}
addLineItem(productId: string, name: string, unitPrice: Money, quantity: number): void {
if (this.status !== OrderStatus.Created) {
throw new Error("Cannot modify a placed order.");
}
const existing = this.lineItems.find(li => li.productId === productId);
if (existing) {
existing.increaseQuantity(quantity);
} else {
this.lineItems.push(new OrderLineItem(productId, name, unitPrice, quantity));
}
this.recalculateTotal();
}
place(): void {
if (this.lineItems.length === 0) {
throw new Error("Cannot place an empty order.");
}
this.status = OrderStatus.Placed;
}
get totalAmount(): Money { return this._totalAmount; }
private recalculateTotal(): void {
this._totalAmount = this.lineItems.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero(),
);
}
}Python 版本
from dataclasses import dataclass, field
from typing import List, Optional
from uuid import UUID
class Order:
def __init__(self, id: UUID, customer_id: UUID):
self.id = id
self.customer_id = customer_id
self._status = OrderStatus.CREATED
self._line_items: List[OrderLineItem] = []
self._total_amount = Money.zero()
def add_line_item(self, product_id: UUID, name: str, unit_price: "Money", quantity: int) -> None:
if self._status != OrderStatus.CREATED:
raise ValueError("Cannot modify a placed order.")
existing = next((li for li in self._line_items if li.product_id == product_id), None)
if existing:
existing._increase_quantity(quantity)
else:
self._line_items.append(OrderLineItem(product_id, name, unit_price, quantity))
self._recalculate_total()
def place(self) -> None:
if not self._line_items:
raise ValueError("Cannot place an empty order.")
self._status = OrderStatus.PLACED
@property
def total_amount(self) -> "Money":
return self._total_amount
def _recalculate_total(self) -> None:
self._total_amount = sum(
(item.subtotal for item in self._line_items),
Money.zero()
)概念圖
此圖展示 Aggregate 的內部結構:Root 是唯一的入口,內部包含 Entity 和 Value Object,Root 負責維護不變量,Repository 只存取 Root。外部想操作內部 Entity 必須透過 Root 的方法,不能繞過去。
何時把東西放進 Aggregate,何時拆出去
| 情境 | 放進同一個 Aggregate | 拆成不同 Aggregate | |------|---------------------|-------------------| | Order 和 OrderLineItem | 是——「總額 = 各明細之和」必須即時一致 | - | | Order 和 Product | - | 是——訂單不需要和商品即時一致,商品改價格不影響已下的訂單 | | Order 和 Payment | - | 是——付款可以晚幾秒確認,用 Domain Event 通知 | | ShoppingCart 和 CartItem | 是——購物車內的商品列表必須與總計即時一致 | - |
實戰補充
💡資深開發者筆記
設計 Aggregate 最常犯的錯是邊界太大。一個經典的判斷法則:如果兩個東西不需要在同一個交易中保持一致,就不要放在同一個 Aggregate。
例如:Order 和 Product 不需要在同一個 Aggregate。訂單只需要存 ProductId 和下單時的商品名稱和價格(快照),不需要持有 Product 物件的參考。
EF Core 的實作技巧:Aggregate Root 對應一個 DbSet<T>,內部 Entity 用 OwnsMany 或 Navigation Property 配置,不給獨立的 DbSet。這自然就限制了外部直接存取內部 Entity。
理解測驗
🤔 為什麼外部不能直接修改 Aggregate 內部的 Entity?
🤔 Aggregate 之間應該用什麼方式互相參考?
🤔 一個交易應該修改幾個 Aggregate?
重點整理
💡一句話記住
口訣:「一道門進出、一筆交易、越小越好」—— Aggregate 設計三原則。
| 概念 | 說明 | |------|------| | Aggregate Root | 聚合的入口點,外部唯一的操作對象 | | 一致性邊界 | 不變量必須在每次操作後成立 | | 交易邊界 | 一個交易只修改一個 Aggregate | | ID 參考 | Aggregate 之間只存 ID,不存物件參考 | | 越小越好 | 只把必須一致的物件放在一起 |