本节摘要:聚合(Aggregate)是战术设计的最终考题:它划出事务一致性的边界,划错一处,要么并发卡死、要么数据漏洞。本节不做理论综述,直接把青柚商城白板上那道争议题完整推演一遍——订单聚合该不该包住库存扣减——用四条判据对比三轮划分方案,给出最终方案的代码骨架,再留两个变式练习供读者动手验证。
题目来自第 3 章开头的那场动工会:交易上下文要动工了,订单聚合的边界怎么画?争议聚焦在一个具体问题上:下单要扣库存,这个"扣库存"是订单聚合内部的一步,还是库存聚合自己的事?
争议双方各有直觉。主张"包进去"的理由听起来很稳:下单必须保证库存足够,这是业务规则,规则就该在一个事务里原子完成,不包进去怎么保证一致性?主张"拆开"的理由也很硬:库存是仓储上下文的资产,交易上下文凭什么直接改它?两边的直觉各对一半,恰好说明聚合边界不能靠直觉——需要判据。
先把判据摆上桌,四条,每条都可以独立检验:
方案 A:全家桶——订单聚合包住订单、订单行、库存扣减、支付记录。不变量检验:扣库存时订单必须知道吗?不必——"库存不为负"是库存的自有属性,订单只是触发者。事务检验:一笔下单锁住商品行、订单表、支付表,大促热销品的下单事务串成一条长队。并发检验:两个用户买同一个热销品,争的是同一行库存锁,与彼此的订单毫无关系,却互相卡住。删除检验:删订单要删支付记录吗?财务说绝对不行。四条判据三条亮红灯。这个方案把"业务上有关联"误解成了"事务上该同框"——一致性要求强关联,不要求同事务。
方案 B:原子狂——吸取教训反向用力,把订单拆到最细:订单是聚合、订单行各是聚合、优惠构成又是一个聚合,互相用 id 引用。不变量检验立刻失守:"行合计等于总额"这条不变量横跨订单与所有行,拆开后没有任何一个聚合能守它——总额变了,行不知道;行改了,总额不知情。不变量跨界的聚合边界等于没有边界:你得到了无数小事务,失去了所有业务保证。
方案 C:不变量同框,其余靠事件——订单聚合 = 订单 + 订单行 + 券的使用记录(三者共同守"金额恒等"与"每单限用一张");库存扣减是库存聚合的自治事务;两者靠领域事件衔接,接受短暂的不一致窗口。

四条判据走完,答案自己浮出来:扣库存不进订单聚合。它不是订单的不变量,事务锁不住这么大的范围,并发上互相拖累,删除上毫无级联。真正属于订单聚合的,是那些离开订单就无从谈起的规则。
// 订单聚合:只包住与订单根共命运的部分 public class TradeOrder { // 聚合根 private final TradeOrderId id; private final List<OrderLine> lines; // 行合计恒等式:在聚合内 private CouponUsage couponUsage; // 每单限用一张:在聚合内 private Money payable; private TradeStatus status; private final List<DomainEvent> events = new ArrayList<>(); public void pay(PaymentReceipt receipt) { // 支付结果作为外部事实传入 require(receipt.orderId().equals(id), "回执与订单不匹配"); require(status == TradeStatus.WAIT_PAYMENT, "仅待支付可支付"); this.status = TradeStatus.PAID; this.events.add(OrderPaid.of(this, receipt.paidAt())); } // 注意:没有 deductInventory 方法——库存不是订单的事 } // 库存聚合:自己的自治事务,属于仓储上下文 public class Inventory { // 另一个聚合,另一个上下文 private final SkuId skuId; private int reserved; public StockReserved reserve(int qty) { // 预留库存,自有不变量自守 if (qty <= 0 || reserved + qty > capacity) { throw new InsufficientStockException(skuId); } reserved += qty; return new StockReserved(skuId, qty); } } // 应用服务:两个聚合之间的事件衔接(用例编排,不属于任何聚合) public class PlaceOrderUseCase { public TradeOrderId execute(CartSnapshot cart, CouponView coupon) { TradeOrder order = orderFactory.createFrom(cart, coupon); // 出厂即合法 orderRepository.save(order); // 订单事务:小而完整 eventBus.publish(new OrderPlaced(order.id(), cart.items().stream().map(i -> new SkuQuantity(i.skuId(), i.qty())).toList())); return order.id(); // 库存上下文订阅 OrderPlaced,自行预留;预留失败发 StockDenied, // 交易侧订阅它来关单——一致性是最终一致,补偿是显式设计的(详见第 6 章) } }
推演到代码,白板问题的答案可以完整交卷了:订单聚合包住"一张订单必须自洽"的规则(金额恒等、限用一张、状态迁移合法),库存扣减是库存聚合的自治事务,两者用事件与补偿衔接,接受秒级的不一致窗口。 大促限流时扣库存失败,订单不会卡在事务里,而是收到拒绝事实、走关单流程——这正是运营主管那个问题想要的答案。
判据要上手才长成本能。留两道题,建议先自己划再对照思考。
变式一:购物车。购物车要不要是聚合?提示:拿判据一去问——"购物车合计"是不变量吗?业务允许购物车里躺着失效商品、过期券吗?如果购物车只是"尚未承诺的意向清单",它的约束密度根本撑不起聚合的身份,一个轻量对象足矣;把意向当承诺守,是购物车建模最常见的过度设计。
变式二:退款单。退款单聚合该不该包住原订单?提示:用判据四——退掉订单,原订单消失吗?不消失。退款单引用原订单的 id、快照当时的金额构成(3.2 的分摊结果在这里派上用场),然后自己守自己的不变量:"退款总额不得超过原单实付减已退"。原订单是退款单的事实来源,不是它的聚合成员。
至此交易上下文的房子完工。第 4 章抬头看整个建筑:分层怎么立、依赖往哪指、以及什么时候值得把一个模块真的拆成服务。