title: "06 聚合(Aggregate)与一致性边界"
学习目标
- 理解聚合(Aggregate)的目的:用边界控制一致性,降低模型耦合与并发冲突
- 能区分"聚合根(Aggregate Root)"与聚合内部对象,并落实到访问规则
- 能用 Vaughn Vernon 的聚合设计原则解释:为什么"跨聚合强一致"往往是坏主意
- 能把"事务边界、并发、领域事件"与聚合边界串成一个完整的设计理由链
核心概念
聚合是什么
聚合是一组对象(实体/值对象)的组合,它们在一个边界内保持一致性。聚合有两条课堂口径:
- 外部只能通过聚合根访问聚合内对象
- 聚合边界通常就是事务边界(一次命令一次事务,修改一个聚合)
聚合根是什么
聚合根是聚合的入口(通常是实体),负责:
- 维护聚合内不变量(Invariant)
- 对外暴露命令方法(例如
pay/cancel/addItem) - 产生并收集领域事件(Domain Events)
本书示例中:Order 是订单聚合的聚合根。
课堂讲授:一致性有成本,边界是为了控制成本
如果你把太多对象放进一个"超级聚合",会发生:
- 单事务写入范围扩大,锁竞争/并发冲突增加
- 任何小改动都要加载巨量数据,性能下降
- 团队协作成本上升:一个聚合成为"共享修改热点"
如果你把边界切得太碎,又会发生:
- 需要大量跨聚合的强一致检查(最终写回到 Service 大杂烩)
- 业务规则无法落地在模型内部(模型表达力下降)
所以聚合设计的本质是:在正确的地方"付出一致性成本",并用领域事件处理跨边界协作。
经典原则(Vaughn Vernon):聚合设计的教学版 4 条
注意:这里是教学口径的简化表述,用来指导"如何划边界",不是法律条文。
- 在一个事务中只修改一个聚合(一次命令 → 一次事务 → 一次聚合)
- 聚合尽量小:只包含维护不变量所必需的对象
- 聚合之间用标识关联 :用
OtherAggregateId引用,而不是对象引用 - 跨聚合一致性用最终一致性:通过领域事件/流程编排完成协作
伪代码示例:订单聚合的边界形状(示例)
示例订单聚合根体现了几件关键事:
- 不变量:
MAX_ORDER_LINES = 50 - 状态机:
OrderStatus约束状态转换(例如只有PAID才能ship) - 规约:
OrderPaymentSpecification/OrderCancellationSpecification - 领域事件收集:
domainEvents列表(OrderPaidEvent/OrderCancelledEvent)
text
class Order { // Aggregate Root
orderNumber: OrderNumber
customerId: CustomerId
status: OrderStatus
items: List<OrderItem>
domainEvents: List<DomainEvent>
addItem(item):
require status.canPay()
require items.size < 50 // invariant
items.add(item)
pay():
require OrderPaymentSpecification().isSatisfiedBy(this)
status = status.transitionToPaid()
domainEvents.add(OrderPaidEvent(orderNumber.value, customerId.value, totalAmount))
cancel(reason):
require OrderCancellationSpecification().isSatisfiedBy(this)
status = status.transitionToCancelled()
domainEvents.add(OrderCancelledEvent(orderNumber.value, customerId.value, reason))
}
Mermaid 图:一次命令的典型边界(单聚合 + 事件协作)
DomainEventPublisher OrderRepository Order Aggregate Application Service DomainEventPublisher OrderRepository Order Aggregate Application Service 用户/上游调用方 PayOrder(orderId) 1 findById(OrderNumber.of(orderId)) 2 Order 3 order.pay() 4 domainEvents += OrderPaidEvent 5 save(order) 6 publish(event) 7 clearDomainEvents() 8 用户/上游调用方
聚合之间如何"引用"
在 DDD 教学中,一个可复用的规则是:跨聚合只传递标识。
例如,"支付聚合(Payment)"不应该直接持有 Order 对象,而应持有 OrderNumber:
text
class Payment { // another aggregate root
paymentId: PaymentId
orderNumber: OrderNumber // reference by identity (VO)
}
这样做的直接收益:避免"意外把两个聚合放进同一事务/同一对象图"。
并发与事务:为什么"一次命令只改一个聚合"
事务边界(Transaction Boundary)
课堂建议口径:
- 一个命令(Command)由一个应用服务方法处理
- 这个方法在一个事务里修改一个聚合并保存
- 跨聚合的后续动作用领域事件触发
应用服务处理 PayOrder 的典型形状是:加载 Order → 调 order.pay() → save → 发布事件。
并发控制(概念)
聚合根通常会配合"乐观锁版本号(version)"做并发保护:
text
repository.save(order):
require order.version matches DB version
update ... set version = version + 1
这一点在教材里需要掌握"为什么",即:
- 聚合是并发冲突的单位
- 聚合越大,并发冲突越频繁
常见误区
- 误区 1:把整个业务域做成一个聚合:任何写操作都互相抢锁,系统越做越慢
- 误区 2:跨聚合强一致检查写在应用服务里:用例编排膨胀,规则难复用,测试困难
- 误区 3:聚合之间直接对象引用:一不小心就把多个聚合拉进同一事务
- 误区 4:聚合根暴露内部集合可变引用:外部绕过不变量修改内部状态(建议只暴露只读视图或副本)
课堂练习
以"订单域"为例,做一次边界推理(写出答案与理由):
Order聚合里,OrderItem应该是值对象还是实体?理由是什么?- "订单 + 支付 + 发货"是否应该放在同一个聚合?如果不是,跨边界一致性怎么做?
- 选择一个不变量(例如"订单行数 ≤ 50"),写出它应该由谁守护:应用服务、聚合根、值对象,为什么?
自测题
- 为什么说"聚合边界通常就是事务边界"?
- Vernon 的原则中,哪一条最能直接降低并发冲突?为什么?
- 你能举例说明"跨聚合最终一致性"的一次典型协作吗(用事件/命令描述即可)?
延伸阅读
- 参考资料索引:
references.md - Vaughn Vernon:Effective Aggregate Design(含聚合设计原则)