上一篇文章中,我们学习了实体和值对象。实体看身份,值对象看属性,这两个概念算是 DDD 战术设计里面最基础的砖块。
但是只知道实体和值对象还不够。
真实业务里面,一个订单不可能只有一个 Order 类。它可能包含订单明细、收货地址、金额、优惠信息、状态、支付记录等一堆东西。如果这些对象都可以被外部代码随便改,那最后还是会回到以前 Service 到处 set 值的老路。
所以这一篇我们继续往下看:实体和值对象应该怎么组织在一起,业务规则应该由谁来守住。
这就要说到聚合和聚合根了。
一、为什么需要聚合
先从一个很普通的订单场景说起。
用户下单时,系统至少要做几件事:
- 创建订单;
- 添加订单明细;
- 计算订单总金额;
- 填写收货地址;
- 校验订单状态是否允许支付、取消、发货。
如果按照以前比较常见的写法,我们可能会在 Service 里面写很多这样的代码:
java
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setTotalAmount(totalAmount);
orderItem.setOrderId(order.getId());
orderItemMapper.insert(orderItem);
orderMapper.updateById(order);
代码能跑,但是问题也很明显。
订单金额到底是谁算的?订单明细能不能脱离订单单独修改?订单已经支付后还能不能继续加商品?这些规则如果散落在多个 Service 方法里面,时间一长就很难保证一致。
聚合要解决的问题,就是把强相关的业务对象组织在一个边界内,并且在这个边界内保证业务规则的一致性。
简单来说就是:不要让一堆对象各玩各的,而是让它们围绕一个明确的业务边界协作。
二、什么是聚合
聚合是一组业务上强相关、需要一起维护一致性的对象集合。
这组对象里面可以有实体,也可以有值对象。它们不是因为数据库外键关联才放在一起,而是因为业务规则要求它们一起变化、一起校验。
以订单为例,一个订单聚合里面可以包含:
| 对象 | 类型 | 说明 |
|---|---|---|
| Order | 聚合根 / 实体 | 订单本身,负责维护订单生命周期 |
| OrderItem | 实体 | 订单明细,有自己的明细编号或行号 |
| Address | 值对象 | 收货地址,只表达一组地址信息 |
| Money | 值对象 | 金额和币种组合成完整业务含义 |
| OrderStatus | 枚举值对象 | 表达订单当前状态 |
这里要注意一点:聚合不是表关系的翻译,也不是把所有有外键关系的表都塞进一个对象里。
比如订单和用户之间有关系,但用户通常不应该放进订单聚合里面。订单只需要记录下单用户的 userId,至于用户昵称、会员等级、手机号这些信息,属于用户聚合自己的事情。
如果订单每次支付都要把用户对象也加载出来,再顺手改一下用户积分,那订单聚合就开始变胖了,边界也开始乱了。
三、什么是聚合根
聚合里面通常会有多个对象,那外部代码应该操作谁?
答案是聚合根。
聚合根是聚合对外暴露的唯一入口,它负责保护聚合内部对象的一致性。
还是订单例子。外部代码不应该直接拿到 OrderItem 然后修改数量,也不应该直接修改订单状态,而是应该通过 Order 提供的业务方法来完成。
可以先看一个简化版本:
java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 订单聚合根
*
* 订单是下单、支付、取消等业务行为的入口,订单明细和收货地址都由订单统一维护。
* 外部代码只能通过订单暴露的业务方法修改聚合内部状态,避免绕过订单规则直接改明细或金额。
*/
public class Order {
/** 订单编号,用于在业务范围内唯一识别一笔订单 */
private final String orderNo;
/** 订单状态,决定当前订单允许执行哪些业务动作 */
private OrderStatus status;
/** 订单明细列表,明细必须跟随订单一起创建、修改和校验 */
private final List<OrderItem> items = new ArrayList<>();
/** 收货地址值对象,地址变更时整体替换,避免出现半更新状态 */
private Address address;
/** 订单总金额,由订单明细计算得到,不能由外部直接传入覆盖 */
private Money totalAmount;
public Order(String orderNo, Address address) {
if (orderNo == null || orderNo.isBlank()) {
throw new IllegalArgumentException("订单编号不能为空");
}
if (address == null) {
throw new IllegalArgumentException("收货地址不能为空");
}
this.orderNo = orderNo;
this.address = address;
this.status = OrderStatus.PENDING_PAYMENT;
this.totalAmount = Money.zero();
}
/**
* 添加订单明细
*
* 只有待支付订单允许继续调整商品明细。添加成功后立即重新计算总金额,
* 保证订单明细和订单总金额始终处在同一个业务一致性边界内。
*/
public void addItem(String skuId, String skuName, Money price, int quantity) {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("只有待支付订单可以添加商品");
}
OrderItem item = new OrderItem(skuId, skuName, price, quantity);
this.items.add(item);
recalculateTotalAmount();
}
/**
* 支付订单
*
* 支付前必须确保订单至少包含一条明细,并且订单仍处于待支付状态。
* 状态流转由聚合根控制,避免外部代码直接 set 状态导致跳过业务校验。
*/
public void pay() {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("当前订单状态不允许支付");
}
if (items.isEmpty()) {
throw new IllegalStateException("空订单不能支付");
}
this.status = OrderStatus.PAID;
}
/**
* 修改收货地址
*
* 地址是值对象,变更时使用新地址整体替换,不能逐个字段修改。
*/
public void changeAddress(Address newAddress) {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("只有待支付订单可以修改地址");
}
if (newAddress == null) {
throw new IllegalArgumentException("新地址不能为空");
}
this.address = newAddress;
}
/**
* 对外返回订单明细只读视图
*
* 这里不暴露可修改集合,防止调用方绕过 addItem 等业务方法直接操作明细。
*/
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
/**
* 根据订单明细重新计算总金额
*
* 金额计算属于订单聚合内部规则,外部调用方不需要也不应该手动维护。
*/
private void recalculateTotalAmount() {
this.totalAmount = items.stream()
.map(OrderItem::subtotal)
.reduce(Money.zero(), Money::add);
}
}
这段代码重点不是写得多漂亮,而是表达一个边界:
- 添加明细必须通过
Order.addItem(); - 支付必须通过
Order.pay(); - 总金额由订单自己计算;
- 明细集合不能直接暴露给外部修改;
- 地址作为值对象整体替换。
聚合根存在的意义,就是让业务规则有一个稳定的入口。
如果外部代码还能随便 order.getItems().add(),或者直接 order.setStatus(),那聚合根基本就只是一个普通 JavaBean,DDD 的味道也就没了。
四、聚合根、实体和值对象的关系
上一篇文章已经单独讲过实体和值对象,这里我们把它们放到聚合里面再看一遍。
一个比较常见的关系是:
text
Order 聚合
├── Order 聚合根
│ ├── OrderItem 实体
│ ├── Address 值对象
│ ├── Money 值对象
│ └── OrderStatus 状态
它们之间大概有几个规则。
1. 聚合根本身一定是实体
聚合根需要被外部识别和持久化,所以它一定有自己的业务身份。
订单聚合根通过订单号识别,用户聚合根通过用户编号识别,账户聚合根通过账户编号识别。
如果一个对象没有独立身份,只靠属性表达业务含义,它就不适合作为聚合根。
2. 聚合内部实体的生命周期由聚合根维护
订单明细是实体,因为同一笔订单里面的每一行明细可以有自己的行号,业务上也可能需要区分它们。
但是订单明细一般不应该脱离订单独立存在。它创建于订单,修改于订单,删除也跟随订单业务规则。
所以 OrderItem 可以是实体,但它不是聚合根。
通俗一点讲:订单明细有身份,但它的身份只在订单内部有意义。
3. 值对象尽量不可变
地址、金额、时间范围这类值对象,不需要被单独跟踪生命周期。
修改地址时,不建议这样写:
java
order.getAddress().setCity("杭州");
order.getAddress().setDetail("xxx小区");
更推荐这样写:
java
order.changeAddress(new Address("浙江省", "杭州市", "西湖区", "xxx小区"));
原因很简单:值对象整体替换,可以减少业务过程中的临时不一致状态。
如果地址被拆开逐项修改,中间任何一步失败,都可能留下一个半新半旧的地址。
五、聚合边界怎么划分
聚合最难的地方,不是写代码,而是划边界。
很多人一开始会把相关对象都放在一个大聚合里面,感觉这样操作起来方便。但聚合越大,加载越重,锁冲突越多,业务边界也越模糊。
划分聚合时,我觉得可以先问几个问题。
1. 是否需要强一致
聚合边界首先是事务一致性边界。
也就是说,在一个聚合内部,我们通常希望一次业务操作完成后,内部状态就是一致的。
例如订单添加明细后,总金额必须马上正确。如果明细加上了,但总金额没更新,这就是明显的业务错误。因此订单明细和订单总金额适合放在同一个订单聚合里。
但是订单支付成功后,给用户增加积分,不一定要和订单支付处在同一个强一致事务里。
订单支付成功是订单聚合自己的事情,积分增加是积分聚合自己的事情。它们可以通过应用服务编排,或者后续通过领域事件协作。至于领域事件怎么落地,后面会单独写,这里先不展开。
2. 是否有独立生命周期
如果一个对象可以脱离当前对象独立存在、独立修改、独立被查询,那它很可能应该是另一个聚合根。
比如商品。
下单时订单明细里面会保存商品名称、商品单价、商品数量。但是商品本身有上下架、改价、库存维护等生命周期,这些不应该由订单聚合负责。
订单只需要保存下单时的商品快照,或者引用商品编号。
不要因为两个对象有关联,就把它们放进同一个聚合。关联关系不等于聚合关系。
3. 修改入口是否应该统一
如果多个对象必须通过同一个业务入口修改,才能保证规则不被破坏,那它们更适合在一个聚合里。
还是订单明细。外部代码不应该单独写一个 OrderItemService.updateQuantity() 去改数量,因为数量一改,总金额、优惠、订单状态都可能受影响。
更合理的方式是:
java
order.changeItemQuantity(itemId, quantity);
orderRepository.save(order);
明细数量的修改入口仍然在订单聚合根上。
4. 边界是否会导致对象太大
聚合不是越完整越好。
如果一个订单聚合里面同时塞进用户、商品、库存、优惠券、支付单、物流单,那每次加载订单都要带出一大堆对象,性能和复杂度都会上来。
更麻烦的是,多个业务同时改这个大聚合时,很容易互相影响。
所以聚合要尽量小,但不能小到失去一致性边界。这个尺度没有标准答案,只能结合业务规则慢慢调整。
我自己比较喜欢的判断方式是:只把必须同步保持一致的对象放在一个聚合里,其他对象优先通过 ID 引用或事件协作。
六、代码里怎么体现聚合边界
前面讲了概念,下面再补一点代码习惯。
1. 不要给聚合根随便生成 setter
如果聚合根上全是 setter,外部代码就能绕过业务方法直接修改状态。
这种写法最好少出现:
java
order.setStatus(OrderStatus.PAID);
order.setTotalAmount(new Money(new BigDecimal("1.00"), Currency.CNY));
更推荐把状态变化包装成有业务含义的方法:
java
order.pay();
order.cancel("用户主动取消");
order.changeAddress(newAddress);
这样代码读起来也更像业务语言。
2. 集合属性不要暴露可变引用
聚合内部如果有集合,尽量不要直接返回原始集合。
java
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
这不是为了写得高级,而是为了防止外部代码绕过聚合根的方法直接修改内部状态。
3. 聚合之间通过 ID 引用
如果订单需要知道用户是谁,通常保存 userId 就够了。
java
/**
* 下单用户编号
*
* 订单只保存用户聚合的身份引用,不直接持有用户对象,避免订单聚合和用户聚合生命周期耦合。
*/
private Long userId;
这样订单聚合不会因为用户信息变化而被迫跟着变化。
如果确实需要用户昵称、手机号这种下单时信息,可以保存一份订单快照值对象,例如 BuyerSnapshot。这样后续用户改了昵称,也不会影响历史订单展示。
4. 一个事务尽量只修改一个聚合
这个规则不是绝对不能破,但它很重要。
如果一个业务操作需要同时修改多个聚合,要先警惕边界是不是划错了,或者一致性要求是不是被想重了。
比如订单支付后扣库存,到底要不要和订单在同一个事务里?
这要看业务要求。如果库存扣减失败,订单一定不能支付成功,那应用服务可以在一个用例里编排;如果订单成功后异步发放积分、发送通知,这些就不应该强行塞进订单事务里。
后面的应用服务、领域服务、领域事件章节会继续讲这种跨聚合协作,这里先记住一句话:聚合根负责自己边界内的一致性,不负责统治整个系统。
七、几个常见误区
1. 一个表就是一个聚合
这是最容易出现的问题。
表是数据存储结构,聚合是业务一致性边界。两者可能接近,但不是一回事。
一个订单聚合可以对应订单表和订单明细表,也可以把地址字段平铺在订单表里。反过来,一个带主键的表,也不一定对应一个独立聚合。
不要用表结构倒推聚合边界。 表结构可以作为参考,但最终还是要看业务规则。
2. 聚合越大越完整
大聚合看起来省事,实际上很容易把边界搞乱。
例如订单聚合里面直接持有完整的用户对象、商品对象、库存对象、支付对象。短期写代码可能方便,长期就会遇到加载慢、事务大、并发冲突多、职责不清的问题。
能用 ID 引用的,不要直接对象嵌套;能用快照表达历史信息的,不要依赖外部聚合当前状态。
3. 聚合根只是一个带 ID 的类
聚合根不是 DTO,也不是 DO。
如果一个 Order 类里面只有字段、getter、setter,没有任何业务方法,那么它虽然叫 Order,但实际上没有承担聚合根职责。
聚合根至少应该负责:
- 维护自身状态流转;
- 保护内部实体和值对象;
- 执行边界内的业务校验;
- 提供有业务语义的方法。
4. 内部实体可以被外部随便操作
有些代码会把内部实体直接返回出去,然后在应用服务里改:
java
OrderItem item = order.getItem(itemId);
item.changeQuantity(quantity);
这要看情况。
如果 changeQuantity 不影响订单总金额、优惠、状态,那问题不大。但现实里,订单明细数量通常会影响订单整体规则,所以更稳妥的方式还是让订单聚合根提供入口:
java
order.changeItemQuantity(itemId, quantity);
只要一个内部对象的变化会影响聚合整体一致性,就应该优先由聚合根来协调。
八、一个简单的落地流程
如果现在要从一个普通 CRUD 订单模块里抽聚合,我大概会按这个顺序来。
1. 先找业务主语
先看这个功能到底围绕谁展开。
比如下单、支付、取消、修改地址,主语基本都是订单,那么订单大概率就是聚合根。
如果是维护商品价格、上下架、商品分类,那主语就不是订单,而是商品。
2. 再找必须一起一致的对象
围绕订单继续看,哪些对象必须和订单一起保证一致?
订单状态、订单明细、订单总金额、收货地址,这些一般比较适合放进订单聚合。
用户、商品、库存、积分、优惠券,则要继续判断它们是不是独立聚合。
3. 把业务规则收到聚合根方法里
不要一开始就想着把所有代码重构得很完美。
可以先从最明显的规则开始,比如:
- 已支付订单不能添加明细;
- 空订单不能支付;
- 修改明细后必须重新计算金额;
- 已发货订单不能修改地址。
这些规则都可以逐步收到 Order 的业务方法中。
4. 最后再考虑持久化
聚合建模先关注业务规则,持久化放到后面。
订单聚合最终怎么落库,是一个订单表加明细表,还是订单表里存 JSON,或者通过 Repository 做模型转换,这是后面仓储章节要重点讲的内容。
这里先记住:聚合建模不要一上来就被表结构牵着走。
九、总结
这一篇主要学习了聚合和聚合根。
简单总结一下:
- 聚合是一组需要共同维护业务一致性的对象集合;
- 聚合根是聚合对外暴露的唯一入口,外部代码应该通过聚合根修改内部状态;
- 聚合根本身是实体,内部可以包含实体和值对象;
- 聚合边界优先看一致性要求、生命周期和修改入口,不要只看表关系;
- 聚合之间优先通过 ID 引用,不要随便对象嵌套;
- 聚合根要提供有业务语义的方法,而不是只暴露 getter、setter。
到这里,实体、值对象、聚合和聚合根就串起来了。
下一篇文章继续看应用服务和领域服务。聚合根负责自己边界内的业务规则,那多个聚合之间怎么协作、哪些逻辑该放应用服务、哪些逻辑才适合抽领域服务,就放到下一篇再整理。