做后端这些年,DDD 这个概念几乎成了每份简历上的标配,可真要问"聚合根怎么设计、聚合服务到底该干什么",能讲清楚的人不多。前段时间帮某个中型项目做代码评审,看到一个印象深刻的场景:几十个领域类堆在那里,没有一个能看出谁是聚合根,应用服务里塞满了 SQL 和更新逻辑,所谓的实体基本已经退化成数据容器。这种状态其实非常普遍——不是大家不愿意用 DDD,而是从"懂概念"到"会设计"之间,缺的正是聚合这条线的完整思考。这篇文章我就拿一个电商订单的经典场景来逐步拆解,把聚合根识别的依据、聚合服务实现的方式以及常见的坑都讲透,适合刚开始落地 DDD、或者已经在项目里被"服务越写越肥"困扰的同学参考。
1. 聚合根与聚合服务:先把职责边界画清楚
1.1 聚合的本质是一致性边界
很多人第一次学 DDD 的时候,容易把聚合理解成"一组对象的集合"。这个理解不算错,但不够准确。聚合的本质不是"谁包含谁",而是一致性边界。
那"一致性边界"怎么理解?我一般用一个生活化的类比:你去一家餐厅点餐,菜单上的套餐是一组固定的搭配——主菜、汤、甜品。你说"我要把甜品换成小菜",这个操作是允许的,但你不会跑去厨房直接把某个厨师手里的甜品方案改掉,而是通过服务员来协调。服务员所代表的,就是套餐这个聚合的边界;套餐内部怎么调整,由它自己说了算,外面的人只能通过服务员这一个人口操作。
对应到代码里,聚合就是一组有着共同业务规则、必须保持事务一致性的对象的集合。聚合根是这个集合里唯一允许被外部引用的对象,外部想对聚合内的任何数据做修改,都必须通过聚合根提供的方法来发起。这样一来,业务规则就有了唯一的落点,不会出现"A 类直接改了 B 类的字段,而 B 校验的规则没触发"这种失控局面。
我见过太多项目,问题就出在边界混乱:订单头、订单行、库存、物流状态全部揉在一个类里,或者反过来,每个小对象都能被外部 Service 直接改属性。前者是"巨型聚合",改起来牵一发动全身;后者是"无效聚合",规则形同虚设。这两种情况,本质上都是没有把一致性边界想清楚。
1.2 聚合服务是个什么角色
说清楚了聚合,再来看聚合服务。很多人会把"聚合服务"和"应用服务"混作一个概念,实际在 DDD 的分层里,它们是有明确分工的。
聚合服务(我更愿意叫它领域服务或聚合的应用入口)承担的是跨实体的业务编排工作。它知道"要调用聚合根的哪个方法、按什么顺序调用、用什么参数",但它不关心"数据存到哪个表、SQL 怎么写"。真正把数据读出来、写进去的工作,由仓储(Repository)承担。聚合服务不直接操作数据库,这是一个非常重要的原则。
举个例子:用户提交订单这个动作。聚合服务要做的事情是:从仓储里取出 Order 聚合根,调用 order.submit() 方法,这个方法内部由 Order 自己校验金额、校验商品可售状态、把状态从 Draft 改成 Submitted,然后把订单保存回去。整个过程中,聚合服务只是"牵线人",订单里的任何业务规则都不应该散落到服务里。
这一点我强调再多都不为过。你去看那些 DDD 写失败的代码仓库,十有八九是应用服务里堆了 500 行业务规则——判断状态、改字段、算金额全在里面。之所以会这样,通常是因为一开始做设计的时候没想清楚:哪条规则是聚合内部的,哪条规则是聚合间的协作规则。前者必须收敛到聚合根,后者才适合留在服务层做编排。
提示:判断一个聚合设计有没有成功,最简单的办法就是看应用服务的方法体。如果方法体里出现了状态判断、金额计算、字段赋值这些"业务动作",那规则就已经从聚合根泄漏出来了。
2. 聚合根设计的关键决策:大小、身份与不变式
2.1 大聚合还是小聚合
"一个聚合应该多大?"这是我被问得最多的问题。很多人希望有一个标准答案,比如"订单聚合包含订单头和订单行就够了"。但坦率讲,聚合大小没有标准答案,它取决于业务约束和一致性窗口。
大聚合的优点是一致性好管。订单和订单行放在同一个聚合里,修改订单行数量时,订单总金额可以同步校验、同步更新,不需要跨聚合协调。缺点是并发冲突概率高、加载成本高。比如一个订单有 200 个明细行,每次只想改其中一行的数量,却要把整个订单全部加载出来,性能压力会很明显。
小聚合的优点是灵活、并发粒度细,缺点是把原本全局的不变式拆成了多个局部不变式,需要通过其他手段(比如领域事件、最终一致性)来兜底。比如把订单行拆成独立聚合,那"订单总金额不能少于订单行金额之和"这种约束就没法在一个事务里保证了。
我给团队定的判断方法是:先列业务约束清单,凡是"必须在一个事务里保证"的约束,相关对象就该放同一个聚合;凡是"可以容忍短暂不一致、后面有对账或补偿机制"的约束,就可以拆成多个聚合。用这个标准去过一遍业务,聚合边界自然就浮出来了。
| 维度 | 大聚合 | 小聚合 |
|---|---|---|
| 一致性 | 容易保证,强一致 | 需要最终一致方案兜底 |
| 并发性 | 冲突多,锁粒度大 | 并发友好,冲突少 |
| 加载成本 | 每次操作加载整棵树 | 只加载需要的部分 |
| 适用场景 | 业务约束强、变更低频 | 高频操作、可容忍短暂不一致 |
2.2 怎么识别一个对象该不该当聚合根
聚合根不是一个"觉得重要"的头衔,它有一套判断标准。我总结下来是三条。
第一,外部是否直接访问它。聚合根是聚合唯一的外部入口。如果外部代码要拿到某个对象做操作,而这个对象又没有承载完整的一致性边界,那它多半是个实体或值对象,算不上聚合根。
第二,它是否拥有独立生命周期。一个对象的创建、变更、删除能否脱离其他对象独立存在?如果能,它大概率是聚合根。订单行离开订单没有意义,所以它是实体不是聚合根;商品离开某个订单行依然有意义,所以商品可以是独立聚合根。
第三,它是否是不变式的承载点。聚合根存在的第一理由,是它要对聚合内所有对象的业务规则负责。如果一个对象只是"数据的外壳",没有任何规则需要它来维护,那也没必要把它设计成聚合根。
这三个条件都满足了,才把它当成聚合根对待。否则就降级为实体、值对象,或者直接退化成普通的数据模型。这个判断过程很枯燥,但恰恰是设计质量的分水岭。
2.3 不变式是聚合根存在的全部理由
不变式(Invariant)这个词听起来抽象,其实就是"无论什么操作,这个规则都不能被破坏"。比如:订单总额必须等于所有订单行金额之和;已提交的订单不能修改商品数量;已发货的订单不能取消。这些规则就是不变式。
聚合根的核心职责之一,就是维护这些不变式。怎么维护?把修改聚合状态的逻辑封装成业务方法。外部调用 order.addItem(...),内部会先校验状态是不是 Draft,再计算总额,再决定是否允许添加。外部调用 order.cancel(...),内部会先确认是否在允许取消的时间窗口内。这样,规则和状态修改是绑定在一起的,没有任何外部代码可以绕过校验直接改字段。
这引出一个很关键的实践约束:聚合根绝对不能暴露公有的 setter。一旦外部能随意调 order.setStatus(...),等于把规则保护全部拆掉了。我评审代码时,看到一个聚合类里有一堆 public setter,基本可以直接判定这个聚合设计失败了。
3. 完整案例:订单聚合从建模到落地
3.1 业务约束梳理
讲道理说完了,拿一个订单场景来实操。假设我们在做一个典型电商系统,需要设计订单相关的聚合。先把业务约束列出来:
- 一个订单包含多个订单行,订单行记录商品 ID、商品名称、单价、数量、行小计金额。
- 订单总额必须等于所有订单行小计之和。
- 只有"待支付"状态的订单可以修改订单行(增删改)。
- 只有"已支付"状态的订单可以被取消。
- 取消订单时,如果部分订单行已经发货,需要走特殊流程。
- 订单创建后,金额和商品快照不允许被修改。
基于这些约束,用前面说的方法判断:订单总额和订单行金额必须实时一致,要在同一个事务里保证,所以"订单头 + 订单行"应该放进同一个聚合。商品本身有独立生命周期,可以被其他订单引用,所以商品是另一个独立聚合。发货单是否要拆出去,取决于"发货后订单还能否改行"——在这个场景里约束只有一条,发货后的订单行不允许再变更,那么只需在订单聚合内部用状态来控制即可,暂不拆出发货聚合。
3.2 聚合根与实体的代码骨架
先定义两个值对象:Money 和 Address。值对象的特点是"无身份、按值相等",把它们单独抽象出来,可以让金额计算和地址校验的逻辑复用。
然后是订单行的实体定义。订单行是实体,有 ID,但它依附于订单存在,不能独立被外部访问。我把它的关键方法设计成包内可见,外部只能通过 Order 聚合根来间接操作。
最后是聚合根 Order。在 Java 中,我用 private 构造方法加工厂方法的方式创建订单,确保任何订单创建进来时,初始状态都是"待支付"并且总额是 0。对外只暴露 submit、pay、cancel、addItem、removeItem 这些业务方法,每个方法内部先做状态校验,再执行修改,再维护总额不变式。
public class Order { private OrderId id; private OrderStatus status; private Money totalAmount; private Address address; private List<OrderItem> items; private Order(OrderId id, Address address) { this.id = id; this.status = OrderStatus.DRAFT; this.totalAmount = Money.ZERO; this.address = address; this.items = new ArrayList<>(); } public static Order create(OrderId id, Address address) { return new Order(id, address); } public void addItem(OrderItem item) { if (status != OrderStatus.DRAFT) { throw new BusinessException("只有待支付订单可以添加商品"); } items.add(item); totalAmount = totalAmount.add(item.getSubtotal()); } public void removeItem(OrderItemId itemId) { if (status != OrderStatus.DRAFT) { throw new BusinessException("只有待支付订单可以移除商品"); } OrderItem target = items.stream() .filter(i -> i.getId().equals(itemId)) .findFirst() .orElseThrow(() -> new BusinessException("订单行不存在")); items.remove(target); totalAmount = totalAmount.subtract(target.getSubtotal()); } public void submit() { if (status != OrderStatus.DRAFT) { throw new BusinessException("订单已提交,不能重复提交"); } if (items.isEmpty()) { throw new BusinessException("订单不能为空"); } status = OrderStatus.SUBMITTED; } public void cancel() { if (status != OrderStatus.SUBMITTED && status != OrderStatus.PAID) { throw new BusinessException("当前状态不允许取消"); } status = OrderStatus.CANCELED; } }这里我刻意省略了部分字段和访问方法,重点想展示两个设计习惯:所有修改聚合内部状态的行为都通过业务方法;每个业务方法第一件事就是校验状态。这样写出来的聚合根,即使过半年再看,业务规则也是一目了然的。
3.3 聚合服务:粗粒度调用入口的实现
聚合根定义好了,接下来是聚合服务的实现。注意,我这里说的"聚合服务",在落地时通常表现为应用服务层的某个类,名字可以叫 OrderApplicationService,也可以叫 OrderService,名字不重要,重要的是它的定位:编排仓储和聚合根,不写业务规则。
以"添加商品"为例,聚合服务的实现大致是这样:从仓储按订单 ID 加载 Order 聚合根,构造 OrderItem,调用 order.addItem(item),再通过仓储保存订单。
@Service public class OrderApplicationService { private final OrderRepository orderRepository; public void addItem(AddItemCommand command) { Order order = orderRepository.findById(command.getOrderId()); OrderItem item = new OrderItem( OrderItemId.generate(), command.getProductId(), command.getProductName(), new Money(command.getUnitPrice()), command.getQuantity() ); order.addItem(item); orderRepository.save(order); } public void submitOrder(SubmitOrderCommand command) { Order order = orderRepository.findById(command.getOrderId()); order.submit(); orderRepository.save(order); } }这个类从头到尾没有出现任何 SQL 或者数据库字段名,也没有 if-else 的业务判断。它做的事情就是:加载、调用、保存。可能有人会觉得"这么简单?那服务层岂不是太薄了?"——对,就是要薄。如果一个服务类需要写很多判断逻辑,那说明判断逻辑放错了位置,应当收进聚合根内部。
这里还有一个细节:聚合服务的方法参数用 Command 对象而不是散落的若干参数。这样做的好处是,当参数变化时,服务方法的签名不需要频繁修改,同时 Command 本身可以承载简单的参数校验。这个习惯我建议从第一个版本就养成。
4. 跨聚合协作与最终一致性
4.1 跨聚合不能用事务硬扛
聚合是事务边界。这句话听起来简单,但很多人设计的时候会把两个聚合之间的操作硬塞进一个数据库事务里。比如"创建订单"的时候同时"扣减库存",这两个操作分属订单聚合和库存聚合,如果硬要放到同一个本地事务里,相当于承认"库存和订单必须强一致"。
问题在于,随着系统演化,订单服务和库存服务很可能拆分到不同的进程甚至不同的数据库,本地事务根本没法覆盖。就算还在同一个库,把两个聚合放进同一个事务,锁的粒度会变得很大,高并发下性能问题立刻显现。
所以我的建议是:先问自己,这个跨聚合操作能否容忍短暂的不一致?大多数业务场景是能的。比如电商里订单创建后库存的扣减,可以接受先创建订单、稍后再扣库存的设计,只是需要保证:如果库存扣减失败,订单要不被自动取消,要不转人工处理。这种"稍后处理"的机制,就是最终一致性。
4.2 用领域事件拆开聚合之间的耦合
实现最终一致性最顺手的方式之一,是引入领域事件。聚合根在状态发生变化时,对外发布一个领域事件;其他聚合(或外部系统)订阅这个事件,做出相应的处理。
还是用订单和库存的例子。订单聚合根在 submit 成功后,发布 OrderSubmittedEvent,事件里携带订单 ID 和订单行清单。库存服务订阅这个事件,执行库存扣减;如果扣减失败,再发布一个 InventoryDeductFailedEvent,订单服务收到之后把订单打回或者转入异常状态。
public class Order { private List<DomainEvent> events = new ArrayList<>(); public void submit() { // 状态校验与状态修改... this.status = OrderStatus.SUBMITTED; events.add(new OrderSubmittedEvent(this.id, itemsSnapshot())); } public List<DomainEvent> getEvents() { return events; } }在应用服务中,保存聚合后要把事件一并发布出去。这里有个实操建议:事件发布和聚合保存要尽量保证原子性。如果先保存订单、后发布事件,发布失败会导致下游永远收不到通知;反过来先发事件后保存,消费者可能读到不存在的订单。我常用的做法是事务性发件箱(Transactional Outbox)模式:在同一个本地事务里,把领域事件写入一张 outbox 表,再由一个后台任务轮询发布。这个模式虽然多了一张表和一个小任务,但换来的是事件不丢不重(配合幂等消费),我认为非常值得。
注意:跨聚合协作的底线是不能直接修改对方聚合的内部状态。如果你发现自己在一个聚合的服务里调用了另一个聚合的 addXxx 方法,然后又调了第三个聚合的方法来联动,先停一下——这通常是应该引入领域事件信号的时刻。
5. 常见设计误区与实战排查速查
5.1 六个高频设计误区
复盘这几年见过和踩过的坑,聚合设计的高频误区大概有六类。
第一,聚合根退化成绩效工具。有人嫌聚合加载太慢,于是跳过聚合根,直接写仓储查询改字段。短期看确实"高效",但每做一次,规则就漏一个口子。等规则漏得差不多了,聚合就名存实亡。
第二,聚合内对象被外部直接引用。订单改状态不调用 order.submit(),而是通过一个查询方法拿到订单行再改订单行的字段。这等于把聚合边界拆成了摆设。
第三,全项目只有一个大聚合。所有模型都挂在同一个聚合根下面,美其名曰"一切都在掌控中"。结果就是任何小操作都要加载整棵树,锁竞争和性能问题迟早爆发。
第四,把数据库表结构直接映射成聚合。一个表一个类、主键就是聚合根 ID,这其实是数据模型思维,不是领域模型思维。聚合是先有业务规则、再映射到存储,顺序反了,设计就会走样。
第五,聚合根暴露了 setter 和无参构造。一个公开的 setStatus 比十个设计文档都更能说明问题:聚合根的保护机制已经失效了。
第六,服务层不管三七二十一全用事务。每个方法都加 @Transactional,跨聚合操作也硬塞。这是典型的"把事务当成万能胶"的思维,问题短期看不出来,等到流量上来或者服务拆分的时候,代价会非常大。
5.2 排查聚合设计问题的三张清单
如果你接手一个代码库,想快速判断它的聚合设计有没有出问题,我建议用三张清单过一遍。
第一张,看聚合根类的公开方法。如果公开方法大半是 getter/setter,没有几个业务方法,那这个聚合根基本是失格的。
第二张,看应用服务层的代码行数和方法数。如果一个类有上千行、每个方法里有大量状态判断和字段赋值,说明业务规则没有下沉到聚合,而是在服务层裸奔。
第三张,看两个聚合之间是如何协作的。如果互相直接调用对方的仓储修改数据,或者用一个大事务包住所有跨聚合操作,那说明聚合边界没有被尊重。
| 现场观测 | 判断结论 |
|---|---|
| 聚合根里一堆 public setter | 聚合保护失效 |
| 应用服务方法体超过几十行且全是判断 | 业务规则未下沉 |
| 跨聚合操作放在一个大事务里 | 边界被破坏 |
| 聚合查询方法返回实体集合供外部修改 | 聚合入口形同虚设 |
这三张清单过完,哪里有问题基本就暴露了。修的时候别想着一次性大重构,先挑一个最核心的聚合,把规则收拢到聚合根里,把服务层的重复逻辑清理出来,弄完这一个再推下一个。我自己的体会是,聚合设计这个东西,看一百篇文章不如完整重构一个模块来得深刻。哪怕第一次设计得不完美,只要每次重构都比上一版更贴近业务本质,DDD 的价值就会慢慢显现出来。