06-聚合与一致性边界-DDD领域驱动设计


title: "06 聚合(Aggregate)与一致性边界"


学习目标

  • 理解聚合(Aggregate)的目的:用边界控制一致性,降低模型耦合与并发冲突
  • 能区分"聚合根(Aggregate Root)"与聚合内部对象,并落实到访问规则
  • 能用 Vaughn Vernon 的聚合设计原则解释:为什么"跨聚合强一致"往往是坏主意
  • 能把"事务边界、并发、领域事件"与聚合边界串成一个完整的设计理由链

核心概念

聚合是什么

聚合是一组对象(实体/值对象)的组合,它们在一个边界内保持一致性。聚合有两条课堂口径:

  • 外部只能通过聚合根访问聚合内对象
  • 聚合边界通常就是事务边界(一次命令一次事务,修改一个聚合)

聚合根是什么

聚合根是聚合的入口(通常是实体),负责:

  • 维护聚合内不变量(Invariant)
  • 对外暴露命令方法(例如 pay/cancel/addItem
  • 产生并收集领域事件(Domain Events)

本书示例中:Order 是订单聚合的聚合根。

课堂讲授:一致性有成本,边界是为了控制成本

如果你把太多对象放进一个"超级聚合",会发生:

  • 单事务写入范围扩大,锁竞争/并发冲突增加
  • 任何小改动都要加载巨量数据,性能下降
  • 团队协作成本上升:一个聚合成为"共享修改热点"

如果你把边界切得太碎,又会发生:

  • 需要大量跨聚合的强一致检查(最终写回到 Service 大杂烩)
  • 业务规则无法落地在模型内部(模型表达力下降)

所以聚合设计的本质是:在正确的地方"付出一致性成本",并用领域事件处理跨边界协作。

经典原则(Vaughn Vernon):聚合设计的教学版 4 条

注意:这里是教学口径的简化表述,用来指导"如何划边界",不是法律条文。

  1. 在一个事务中只修改一个聚合(一次命令 → 一次事务 → 一次聚合)
  2. 聚合尽量小:只包含维护不变量所必需的对象
  3. 聚合之间用标识关联 :用 OtherAggregateId 引用,而不是对象引用
  4. 跨聚合一致性用最终一致性:通过领域事件/流程编排完成协作

伪代码示例:订单聚合的边界形状(示例)

示例订单聚合根体现了几件关键事:

  • 不变量: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:聚合根暴露内部集合可变引用:外部绕过不变量修改内部状态(建议只暴露只读视图或副本)

课堂练习

以"订单域"为例,做一次边界推理(写出答案与理由):

  1. Order 聚合里,OrderItem 应该是值对象还是实体?理由是什么?
  2. "订单 + 支付 + 发货"是否应该放在同一个聚合?如果不是,跨边界一致性怎么做?
  3. 选择一个不变量(例如"订单行数 ≤ 50"),写出它应该由谁守护:应用服务、聚合根、值对象,为什么?

自测题

  1. 为什么说"聚合边界通常就是事务边界"?
  2. Vernon 的原则中,哪一条最能直接降低并发冲突?为什么?
  3. 你能举例说明"跨聚合最终一致性"的一次典型协作吗(用事件/命令描述即可)?

延伸阅读

  • 参考资料索引:references.md
  • Vaughn Vernon:Effective Aggregate Design(含聚合设计原则)
相关推荐
老王熬夜敲代码2 小时前
C++新特性:string_view
开发语言·c++·笔记
像风一样的男人@2 小时前
python --生成ico图标
java·python·spring
lsx2024063 小时前
Ionic 卡片组件深度解析
开发语言
技术小泽3 小时前
DDD领域设计精讲
java·后端·设计模式·架构
多打代码3 小时前
2026.1.2 删除二叉搜索树中的节点
开发语言·python·算法
一路往蓝-Anbo3 小时前
STM32单线串口通讯实战(二):链路层核心 —— DMA环形缓冲与收发切换时序
c语言·开发语言·stm32·单片机·嵌入式硬件·物联网
萧曵 丶3 小时前
MQ 业务实际使用与问题处理详解
开发语言·kafka·消息队列·rabbitmq·rocketmq·mq
Geoking.3 小时前
简单工厂模式介绍
设计模式·简单工厂模式
kylezhao20193 小时前
第三节、C# 上位机面向对象编程详解(工控硬件封装实战版)
开发语言·前端·c#