聚合与聚合根:聚合的定义、聚合根的选择原则、实体与值对象的区分
在领域驱动设计中,聚合(Aggregate)是事务一致性的最小边界,而聚合根(Aggregate Root)是访问该边界的唯一入口。支付订单领域对数据一致性要求极高,错误的设计会导致资金差错或订单状态混乱。本节将深入剖析聚合的定义、聚合根的选择原则,以及实体与值对象的本质区别。
1. 聚合的定义与边界
聚合是一组相关对象的集合,这些对象在业务上必须保持事务一致性。聚合外部只能通过聚合根访问内部对象,内部对象之间的引用仅限于聚合内部。
聚合的核心特征:
- 事务边界:一次操作要么全部成功,要么全部回滚,不允许部分更新。
- 一致性边界:聚合内的所有对象必须满足业务不变量(Invariant)。
- 持久化单元:聚合作为一个整体被加载、修改和保存。
- 引用限制:外部只能持有聚合根的引用,不能直接引用内部实体或值对象。
在支付订单上下文中,一个典型的聚合边界示例:
| 聚合名称 | 包含对象 | 事务一致性要求 |
|---|---|---|
| 订单聚合 | 订单根、订单项集合、支付信息、配送信息 | 订单状态变更时,所有子对象必须同步更新 |
| 支付聚合 | 支付单根、支付流水列表、退款记录列表 | 支付金额与流水总额必须相等,不可出现部分支付 |
| 账户聚合 | 账户根、余额、冻结金额、交易记录 | 余额变动必须与交易记录一一对应 |
设计提示:聚合不宜过大也不宜过小。过大的聚合会导致性能瓶颈和并发冲突;过小的聚合则无法保证业务一致性。支付领域通常将“订单+支付信息”作为一个聚合,而将“账户余额”单独作为一个聚合,因为账户的并发操作频率远高于订单。
2. 聚合根的选择原则
聚合根是聚合中唯一能被外部访问的对象。选择正确的聚合根是DDD建模的关键步骤。以下是支付订单领域常用的选择原则:
- 全局标识原则:聚合根必须拥有全局唯一的标识(ID),且该标识在整个系统中可被外部引用。
- 生命周期原则:聚合根负责管理聚合内所有对象的生命周期,内部对象的创建、修改、删除都必须通过聚合根。
- 业务不变量原则:聚合根必须确保聚合内的所有业务不变量不被破坏。例如,订单总金额必须等于所有订单项金额之和。
- 最小化原则:尽量选择业务上最稳定、变化频率最低的对象作为聚合根。
- 事务边界原则:聚合根的操作范围就是事务的边界,超出该范围的操作应通过领域服务或应用服务协调。
常见误区:不要将数据库表的主键直接作为聚合根的标识。聚合根的ID应该是业务含义明确的标识,如订单号(OrderNo)、支付单号(PaymentNo),而非自增ID或UUID。业务标识有助于分布式追踪和故障排查。
以下SVG图展示了支付订单聚合中聚合根与内部对象的关系:
3. 实体与值对象的区分
实体(Entity)和值对象(Value Object)是DDD中两种最基本的领域对象类型。它们的区分直接决定了聚合内部的结构设计。
| 对比维度 | 实体(Entity) | 值对象(Value Object) |
|---|---|---|
| 标识 | 拥有唯一标识(ID),标识在生命周期内不变 | 没有标识,由属性值定义相等性 |
| 可变性 | 可变(Mutable),属性可以修改 | 不可变(Immutable),修改时生成新实例 |
| 相等性 | 通过标识判断相等(ID相同即相同) | 通过所有属性值判断相等 |
| 生命周期 | 有独立生命周期,可被创建、修改、删除 | 无独立生命周期,随所属实体创建和销毁 |
| 共享方式 | 通常不共享,每个实体独立存在 | 可以共享,多个实体可引用同一个值对象实例 |
| 支付领域示例 | 订单、订单项、支付单、退款单 | 金额、货币、地址、支付状态、时间范围 |
支付领域实战判断方法:
- 问自己:“这个对象如果属性变了,还是同一个业务对象吗?” 如果是 → 实体;如果否 → 值对象。
- 例如:订单的“收货地址”变了,订单本身还是同一个订单,所以地址是值对象。订单的“订单项”变了(如修改数量),订单项本身还是同一个业务对象,所以订单项是实体。
- 金额(Money)是典型的值对象:100元就是100元,没有身份概念。即使两个订单共享同一个金额对象实例,也是安全的。
4. 支付订单聚合中的实体与值对象设计
以下代码示例展示了支付订单聚合中实体与值对象的Go实现:
// 值对象:金额(不可变)
type Money struct {
amount decimal.Decimal
currency string // ISO 4217 货币代码
}
func NewMoney(amount decimal.Decimal, currency string) Money {
return Money{amount: amount, currency: currency}
}
func (m Money) Amount() decimal.Decimal { return m.amount }
func (m Money) Currency() string { return m.currency }
// 值对象相等性比较:所有属性相等即相等
func (m Money) Equals(other Money) bool {
return m.amount.Equal(other.amount) && m.currency == other.currency
}
// 值对象运算:返回新实例
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, errors.New("currency mismatch")
}
return NewMoney(m.amount.Add(other.amount), m.currency), nil
}
// 实体:订单项(拥有局部标识)
type OrderLineItem struct {
lineItemID string // 在订单聚合内唯一
productID string
quantity int
unitPrice Money // 值对象作为属性
}
func NewOrderLineItem(lineItemID, productID string, quantity int, unitPrice Money) *OrderLineItem {
return &OrderLineItem{
lineItemID: lineItemID,
productID: productID,
quantity: quantity,
unitPrice: unitPrice,
}
}
// 实体相等性:通过标识判断
func (item *OrderLineItem) Equals(other *OrderLineItem) bool {
return item.lineItemID == other.lineItemID
}
// 聚合根:订单
type Order struct {
orderNo string // 全局唯一标识
items []*OrderLineItem // 内部实体列表
total Money // 值对象
status OrderStatus // 值对象(枚举)
createdAt time.Time
}
func NewOrder(orderNo string) *Order {
return &Order{
orderNo: orderNo,
items: make([]*OrderLineItem, 0),
total: NewMoney(decimal.Zero, "CNY"),
status: OrderStatusPending,
createdAt: time.Now(),
}
}
// 聚合根方法:添加订单项(维护业务不变量)
func (o *Order) AddItem(productID string, quantity int, unitPrice Money) error {
lineItemID := fmt.Sprintf("%s-%d", o.orderNo, len(o.items)+1)
item := NewOrderLineItem(lineItemID, productID, quantity, unitPrice)
o.items = append(o.items, item)
// 重新计算总金额(维护不变量)
newTotal, _ := o.total.Add(unitPrice.Multiply(decimal.NewFromInt(int64(quantity))))
o.total = newTotal
return nil
}
设计要点:
- 值对象
Money没有ID,所有方法返回新实例,保证不可变性。 - 实体
OrderLineItem拥有lineItemID,该ID仅在订单聚合内唯一,外部无需感知。 - 聚合根
Order通过AddItem方法维护业务不变量:总金额始终等于所有订单项金额之和。 - 外部代码只能通过
Order的方法操作内部对象,不能直接修改items切片。
5. 聚合设计中的常见陷阱
在支付订单领域,以下陷阱经常导致设计返工:
- 陷阱一:将值对象设计为实体——例如给“金额”加上ID,导致每次金额比较都需要查数据库,增加复杂度且无业务价值。
- 陷阱二:聚合边界过大——将订单、支付、账户、物流全部放在一个聚合中,导致每次订单状态变更都要锁定大量数据,并发性能急剧下降。
- 陷阱三:聚合根选择错误——将“订单项”作为聚合根,导致外部可以直接操作订单项,绕过订单的业务规则(如总金额校验)。
- 陷阱四:忽略值对象的不可变性——在Go中直接修改值对象的字段,导致多个实体共享同一个值对象实例时出现数据污染。
避坑指南:在Go中实现值对象时,务必使用值接收者(Value Receiver)而非指针接收者。值接收者会复制整个结构体,天然保证不可变性。如果值对象包含引用类型(如slice、map),则需要深拷贝或使用不可变集合。
以下SVG图展示了实体与值对象在聚合中的不同角色: