聚合根的选择策略:如何识别聚合根,避免贫血模型与过大聚合
聚合根(Aggregate Root)是领域驱动设计中事务一致性的边界守卫。选错聚合根会导致两个极端:贫血模型(业务逻辑散落在 Service 中)或过大聚合(单次事务锁住过多数据,性能崩塌)。本节提供一套可操作的识别策略与校验清单。
3.1 聚合根的核心职责
聚合根不是“实体列表的管理员”,而是业务不变量的守护者。它必须满足以下三条硬约束:
| 约束 | 说明 | 反例 |
|---|---|---|
| 事务边界 | 聚合内所有对象在同一个事务中提交,聚合之间通过最终一致性 | 跨聚合使用 @Transactional 强行合并提交 |
| 唯一入口 | 外部只能通过聚合根的方法修改内部状态,不能直接操作子实体 | Service 直接调用 order.items.add(item) |
| 业务不变量 | 聚合根负责保证内部数据始终满足业务规则 | 允许订单总金额为负数而不校验 |
判断口诀:“谁拥有不变量,谁就是聚合根。” 如果某个实体离开另一个实体就无法保证自身业务规则,它就不应该成为聚合根。
3.2 四步识别法
以下方法从业务事件出发,逐步收敛到聚合根候选,避免凭直觉拍脑袋。
- 列出业务事件 —— 从 Event Storming 或用例中提取所有“用户触发”或“系统触发”的事件,例如“订单已提交”“库存已锁定”。
- 找出被修改的数据 —— 每个事件对应一组被创建、更新或删除的数据对象,将这些对象圈在一起。
- 标记事务一致性需求 —— 哪些数据必须同时更新或同时不更新?如果允许最终一致,则拆成不同聚合。
- 验证不变量归属 —— 对每个候选聚合根,问:“如果去掉这个根,业务规则还能被强制吗?” 不能则保留。
案例:“订单已支付”事件涉及 Order、PaymentRecord、OrderItem。业务规则要求“支付金额必须等于订单总额”,且“支付成功后订单状态必须变为已支付”。这两个不变量要求 Order 和 PaymentRecord 在同一个事务边界内,因此 Order 是聚合根,PaymentRecord 是内部实体。
3.3 避免贫血模型:聚合根必须承载行为
贫血模型的典型特征是聚合根只有 getter/setter,业务逻辑全部放在 XXXService 中。判断标准很简单:如果聚合根的方法列表与属性列表几乎一样长,就是贫血模型。
| 对比维度 | 贫血模型 | 充血模型(正确) |
|---|---|---|
| 聚合根方法 | getId(), setStatus(), getItems() |
submit(), pay(), cancel() |
| 业务逻辑位置 | OrderService.submitOrder() | Order.submit() |
| 数据暴露 | 外部直接读取并判断状态 | 聚合根返回业务结果,不暴露内部状态 |
| 可测试性 | 需要 mock 大量依赖 | 直接构造聚合根实例调用方法 |
// 贫血模型 —— 不推荐
public class Order {
private String status;
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
// 充血模型 —— 推荐
public class Order {
private OrderStatus status;
public void submit() {
if (this.status != OrderStatus.DRAFT) {
throw new IllegalStateException("只有草稿订单可以提交");
}
this.status = OrderStatus.SUBMITTED;
// 触发领域事件
DomainEventPublisher.publish(new OrderSubmittedEvent(this.id));
}
}
3.4 避免过大聚合:按事务边界拆分
过大聚合的典型症状:一个聚合包含 10 个以上实体、单次加载需要 5 张表 JOIN、事务锁冲突频繁。拆分的核心原则是“一个聚合只保证一个业务不变量”。
常见陷阱:把“关联关系”误认为“事务一致性需求”。订单与用户有关联,但用户修改地址时不需要锁定订单,因此 User 和 Order 必须是两个独立聚合。
以下场景建议拆分:
- 独立生命周期 —— 子实体可以脱离聚合根单独创建或删除(例如“评论”可以脱离“文章”聚合,因为评论有自己的生命周期)。
- 不同变化频率 —— 部分数据每天更新多次,部分数据每月更新一次,放在一起会导致不必要的锁竞争。
- 跨限界上下文 —— 如果两个实体属于不同的限界上下文(如“订单”和“库存”),它们天然不应在同一个聚合内。
3.5 聚合根粒度校验清单
完成初步识别后,使用以下清单逐项检查,避免遗漏或过度设计:
| 检查项 | 通过标准 | 失败处理 |
|---|---|---|
| 事务范围最小化 | 聚合内所有对象必须在一个事务中,缺一不可 | 移除可通过最终一致性同步的对象 |
| 聚合根有业务方法 | 至少有一个方法包含 if/else 或校验逻辑 | 将 Service 中的业务逻辑下沉到聚合根 |
| 子实体不暴露给外部 | 外部代码无法直接获取子实体引用并修改 | 将子实体集合设为 private,只通过聚合根方法操作 |
| 聚合根 ID 全局唯一 | 使用 UUID 或业务标识,不使用数据库自增 ID | 替换为全局唯一标识 |
| 引用其他聚合根 | 只存储对方 ID,不存储实体引用 | 将实体引用替换为 ID |
实战建议:第一次设计时,宁可拆得偏小,也不要贪大。小聚合可以通过领域事件组合实现复杂业务流程,而大聚合拆分成本极高。后续通过性能监控发现频繁跨聚合查询时,再考虑通过“读模型”或“物化视图”优化,而不是合并聚合。
3.6 典型反模式与修正
以下是团队实践中反复出现的三种错误模式,以及对应的修正方案:
- 上帝聚合 —— 一个聚合包含所有关联数据(如 Order 包含 User、Address、Payment、Logistics)。
修正:按事务一致性拆分为 Order、Payment、Logistics 三个独立聚合,通过 OrderId 关联。 - 数据袋子聚合 —— 聚合根只有 add/remove/get 方法,业务逻辑在 Service 中。
修正:将 Service 中的业务规则(如“订单金额超过 10000 需要审批”)移到聚合根的 submit() 方法中。 - 隐形聚合根 —— 没有显式定义聚合根,所有实体都是独立的 Repository。
修正:从业务不变量出发,找到“拥有最多不变量”的实体作为聚合根,其他实体降级为子实体。
最后提醒:聚合根的选择不是一次性的。随着业务演进,原来合理的聚合边界可能变得臃肿。建议每个迭代结束后,用校验清单重新审视一次聚合根设计,保持边界的“新鲜度”。