资讯动态

DDD聚合根与聚合服务设计:电商订单案例实践

发布时间:2026/10/14 22:50:44 来源:尧图企业网站定制
做后端这些年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 ListOrderItem 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 ListDomainEvent events new ArrayList(); public void submit() { // 状态校验与状态修改... this.status OrderStatus.SUBMITTED; events.add(new OrderSubmittedEvent(this.id, itemsSnapshot())); } public ListDomainEvent getEvents() { return events; } }在应用服务中保存聚合后要把事件一并发布出去。这里有个实操建议事件发布和聚合保存要尽量保证原子性。如果先保存订单、后发布事件发布失败会导致下游永远收不到通知反过来先发事件后保存消费者可能读到不存在的订单。我常用的做法是事务性发件箱Transactional Outbox模式在同一个本地事务里把领域事件写入一张 outbox 表再由一个后台任务轮询发布。这个模式虽然多了一张表和一个小任务但换来的是事件不丢不重配合幂等消费我认为非常值得。注意跨聚合协作的底线是不能直接修改对方聚合的内部状态。如果你发现自己在一个聚合的服务里调用了另一个聚合的 addXxx 方法然后又调了第三个聚合的方法来联动先停一下——这通常是应该引入领域事件信号的时刻。5. 常见设计误区与实战排查速查5.1 六个高频设计误区复盘这几年见过和踩过的坑聚合设计的高频误区大概有六类。第一聚合根退化成绩效工具。有人嫌聚合加载太慢于是跳过聚合根直接写仓储查询改字段。短期看确实高效但每做一次规则就漏一个口子。等规则漏得差不多了聚合就名存实亡。第二聚合内对象被外部直接引用。订单改状态不调用 order.submit()而是通过一个查询方法拿到订单行再改订单行的字段。这等于把聚合边界拆成了摆设。第三全项目只有一个大聚合。所有模型都挂在同一个聚合根下面美其名曰一切都在掌控中。结果就是任何小操作都要加载整棵树锁竞争和性能问题迟早爆发。第四把数据库表结构直接映射成聚合。一个表一个类、主键就是聚合根 ID这其实是数据模型思维不是领域模型思维。聚合是先有业务规则、再映射到存储顺序反了设计就会走样。第五聚合根暴露了 setter 和无参构造。一个公开的 setStatus 比十个设计文档都更能说明问题聚合根的保护机制已经失效了。第六服务层不管三七二十一全用事务。每个方法都加 Transactional跨聚合操作也硬塞。这是典型的把事务当成万能胶的思维问题短期看不出来等到流量上来或者服务拆分的时候代价会非常大。5.2 排查聚合设计问题的三张清单如果你接手一个代码库想快速判断它的聚合设计有没有出问题我建议用三张清单过一遍。第一张看聚合根类的公开方法。如果公开方法大半是 getter/setter没有几个业务方法那这个聚合根基本是失格的。第二张看应用服务层的代码行数和方法数。如果一个类有上千行、每个方法里有大量状态判断和字段赋值说明业务规则没有下沉到聚合而是在服务层裸奔。第三张看两个聚合之间是如何协作的。如果互相直接调用对方的仓储修改数据或者用一个大事务包住所有跨聚合操作那说明聚合边界没有被尊重。现场观测判断结论聚合根里一堆 public setter聚合保护失效应用服务方法体超过几十行且全是判断业务规则未下沉跨聚合操作放在一个大事务里边界被破坏聚合查询方法返回实体集合供外部修改聚合入口形同虚设这三张清单过完哪里有问题基本就暴露了。修的时候别想着一次性大重构先挑一个最核心的聚合把规则收拢到聚合根里把服务层的重复逻辑清理出来弄完这一个再推下一个。我自己的体会是聚合设计这个东西看一百篇文章不如完整重构一个模块来得深刻。哪怕第一次设计得不完美只要每次重构都比上一版更贴近业务本质DDD 的价值就会慢慢显现出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑