4. 聚合(Aggregate)设计原则:聚合根、聚合边界、一致性边界、Go中聚合的封装策略

聚合是领域驱动设计中用于管理数据一致性的核心战术模式。它将一组具有紧密业务关联的实体和值对象封装为一个整体,通过聚合根作为唯一的外部访问入口,确保边界内的所有变更都满足业务不变量。本章将深入剖析聚合的设计原则,并给出在 Go 语言中的具体实现策略。

4.1 聚合的核心概念

聚合由三个关键要素构成:聚合根(Aggregate Root)、聚合边界(Aggregate Boundary)和一致性边界(Consistency Boundary)。理解这三者的关系是设计高质量聚合的前提。

概念 定义 职责
聚合根 聚合中唯一的根实体,拥有全局标识(ID) 作为外部访问聚合的唯一入口,负责维护聚合内部的一致性
聚合边界 聚合内包含的实体和值对象的集合范围 定义哪些对象属于同一个聚合,边界外的对象不能直接引用边界内的非根对象
一致性边界 在业务操作中必须同时满足的约束范围 确保聚合内的所有变更在事务提交时都处于有效状态,不变量不被破坏
核心原则:一个事务只修改一个聚合。这是 DDD 中保证最终一致性的基本法则。跨聚合的修改应通过领域事件或 Saga 模式实现。

4.2 聚合设计原则

遵循以下原则可以避免常见的聚合设计陷阱:

  • 原则一:小聚合优先 —— 聚合不应过大。过大的聚合会导致性能瓶颈和并发冲突。优先将聚合设计为只包含真正需要强一致性的对象。
  • 原则二:通过聚合根访问 —— 外部对象只能持有聚合根的引用,不能直接获取内部实体。所有修改必须通过聚合根的方法进行。
  • 原则三:边界内强一致性,边界外最终一致性 —— 聚合内部使用本地事务保证强一致性;聚合之间通过事件驱动保证最终一致性。
  • 原则四:避免跨聚合的引用 —— 聚合之间通过 ID 引用,而不是对象引用。这有助于保持聚合边界的清晰和独立。
实战建议:在电商系统中,订单聚合包含订单项实体和地址值对象。订单项不能脱离订单独立存在,地址的变更必须与订单状态一致。这就是一个典型的聚合设计。

4.3 聚合边界与一致性边界的关系

聚合边界和一致性边界通常是重合的,但理解它们的细微差别有助于处理复杂业务场景。

聚合边界与一致性边界关系图 聚合边界 一致性边界 聚合根 (Order) 实体 (OrderItem) 值对象 (Address) 外部实体 (Customer) 通过 CustomerID 引用 设计要点 • 外部只能看到聚合根 • 内部实体对外不可见 • 一致性边界内保证强一致

上图中,聚合边界(蓝色虚线框)定义了哪些对象属于同一个聚合;一致性边界(红色虚线框)则定义了在单个事务中必须保持一致的变更范围。在大多数情况下,两者重合,但在某些复杂场景中,一致性边界可能小于聚合边界(例如,聚合内的某些历史数据允许异步更新)。

4.4 Go 中聚合的封装策略

Go 语言没有类继承,但通过结构体、接口和包级别的可见性控制,可以很好地实现聚合的封装。以下是推荐的实现策略:

4.4.1 使用包结构强制聚合边界

将每个聚合放在独立的包中,聚合根类型首字母大写(公开),内部实体和值对象首字母小写(私有)。

// 目录结构示例
// order/
//   ├── order.go          // 聚合根 Order,公开
//   ├── order_item.go     // 实体 orderItem,私有
//   ├── address.go        // 值对象 address,私有
//   └── order_repository.go // 仓储接口,公开

4.4.2 聚合根作为唯一入口

聚合根暴露业务方法,内部状态通过方法修改,不允许直接访问字段。

package order

import "errors"

// Order 是聚合根,公开
type Order struct {
    id        string
    items     []orderItem
    address   address
    status    OrderStatus
}

// NewOrder 是聚合根的工厂方法
func NewOrder(id string, addr address) (*Order, error) {
    if id == "" {
        return nil, errors.New("order id cannot be empty")
    }
    return &Order{
        id:      id,
        address: addr,
        status:  StatusPending,
    }, nil
}

// AddItem 是聚合根提供的业务方法
func (o *Order) AddItem(productID string, quantity int, price float64) error {
    if o.status != StatusPending {
        return errors.New("can only add items to pending order")
    }
    o.items = append(o.items, newOrderItem(productID, quantity, price))
    return nil
}

// ID 提供只读访问
func (o *Order) ID() string {
    return o.id
}

4.4.3 内部实体与值对象的私有化

内部实体和值对象不对外暴露,外部只能通过聚合根的方法间接操作。

// orderItem 是内部实体,首字母小写,包外不可见
type orderItem struct {
    productID string
    quantity  int
    price     float64
}

func newOrderItem(productID string, quantity int, price float64) orderItem {
    return orderItem{
        productID: productID,
        quantity:  quantity,
        price:     price,
    }
}

// address 是值对象,不可变
type address struct {
    province string
    city     string
    detail   string
}

func NewAddress(province, city, detail string) address {
    return address{
        province: province,
        city:     city,
        detail:   detail,
    }
}

4.4.4 使用接口定义仓储

仓储接口定义在聚合包内,实现放在基础设施层,保持依赖倒置。

// OrderRepository 定义在 order 包中
type OrderRepository interface {
    Save(order *Order) error
    FindByID(id string) (*Order, error)
}
常见错误:在聚合外部直接修改内部实体的状态。例如,通过 order.Items[0].Quantity = 10 直接赋值。这破坏了聚合的封装性,可能导致一致性边界失效。正确的做法是通过聚合根的方法,如 order.UpdateItemQuantity(itemID, 10)。

4.5 聚合设计实战:订单聚合

以下是一个完整的订单聚合设计示例,展示了如何在 Go 中应用上述原则。

订单聚合封装结构 order 包(聚合边界) Order (聚合根) +ID() +AddItem() +ChangeAddress() orderItem (内部实体) -productID -quantity -price address (值对象) -province -city -detail OrderRepository (接口) +Save() +FindByID() MySQLOrderRepository 基础设施层实现 依赖 外部只能通过 Order 聚合根操作 内部实体和值对象对外不可见

4.6 聚合设计检查清单

在设计聚合时,可以使用以下清单进行自检:

  1. 聚合根是否唯一? 每个聚合只有一个聚合根。
  2. 外部是否只通过聚合根访问? 不允许直接获取内部实体引用。
  3. 一致性边界是否清晰? 一个事务只修改一个聚合。
  4. 聚合是否足够小? 只包含真正需要强一致性的对象。
  5. 跨聚合引用是否使用 ID? 避免对象引用导致边界模糊。
  6. 内部实体是否私有? Go 中通过首字母小写实现。
  7. 业务不变量是否在聚合根方法中校验? 确保状态变更合法。
总结:聚合是 DDD 中保证数据一致性的核心机制。在 Go 中,通过包结构、可见性控制和聚合根方法的设计,可以有效地实现聚合的封装。记住:小聚合、强边界、单事务修改是设计高质量聚合的三大法宝。