Aggregate(聚合)

是什麼?

Aggregate 是一組相關物件(Entity 和 Value Object)的集合,定義了一個一致性邊界(Consistency Boundary)。外部只能透過 Aggregate Root(聚合根) 來存取和修改內部物件,確保業務規則和資料一致性。

ℹ️DDD 交易邊界

Aggregate 是 DDD 中定義交易邊界的核心概念。一個 Aggregate 內的所有變更在同一個交易中完成,跨 Aggregate 的一致性則透過 Domain Events 實現最終一致性。

核心觀念

常見誤區

⚠️常見誤區

誤區一:把整個系統塞進一個巨大的 Aggregate。Aggregate 越大,併發衝突越嚴重,效能越差。只把「必須一致」的東西放在一起。

誤區二:Aggregate 之間直接持有物件參考。正確做法是只存 ID,需要時透過 Repository 載入。

誤區三:在一個交易中同時修改多個 Aggregate。這會造成鎖定範圍過大。跨 Aggregate 的一致性應該用 Domain Events 和最終一致性來處理。

設計流程

1

識別不變量

找出哪些業務規則必須在任何時刻都成立(如訂單總額 = 各明細小計之和)

2

劃定邊界

把必須一起滿足不變量的物件放進同一個 Aggregate

3

選定 Aggregate Root

選出一個 Entity 作為入口,所有外部操作都透過它

4

限制外部存取

內部 Entity 不對外暴露,外部只能透過 Root 的方法操作

5

跨 Aggregate 用事件

需要跨 Aggregate 同步的邏輯用 Domain Events 實現

程式碼範例

C# 版本

csharp
// 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 版本

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 版本

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(聚合)
入口點
Aggregate Root
維護
內部 Entity
Value Object
不變量 (Invariants)
Repository

此圖展示 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。

例如:OrderProduct 不需要在同一個 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,不存物件參考 | | 越小越好 | 只把必須一致的物件放在一起 |

你可能也想看

Value Object(值物件)Domain Events

按 ← → 鍵切換課程