聚合根的选择策略:如何识别聚合根,避免贫血模型与过大聚合

聚合根(Aggregate Root)是领域驱动设计中事务一致性的边界守卫。选错聚合根会导致两个极端:贫血模型(业务逻辑散落在 Service 中)或过大聚合(单次事务锁住过多数据,性能崩塌)。本节提供一套可操作的识别策略与校验清单。

3.1 聚合根的核心职责

聚合根不是“实体列表的管理员”,而是业务不变量的守护者。它必须满足以下三条硬约束:

约束 说明 反例
事务边界 聚合内所有对象在同一个事务中提交,聚合之间通过最终一致性 跨聚合使用 @Transactional 强行合并提交
唯一入口 外部只能通过聚合根的方法修改内部状态,不能直接操作子实体 Service 直接调用 order.items.add(item)
业务不变量 聚合根负责保证内部数据始终满足业务规则 允许订单总金额为负数而不校验
判断口诀:“谁拥有不变量,谁就是聚合根。” 如果某个实体离开另一个实体就无法保证自身业务规则,它就不应该成为聚合根。

3.2 四步识别法

以下方法从业务事件出发,逐步收敛到聚合根候选,避免凭直觉拍脑袋。

  1. 列出业务事件 —— 从 Event Storming 或用例中提取所有“用户触发”或“系统触发”的事件,例如“订单已提交”“库存已锁定”。
  2. 找出被修改的数据 —— 每个事件对应一组被创建、更新或删除的数据对象,将这些对象圈在一起。
  3. 标记事务一致性需求 —— 哪些数据必须同时更新或同时不更新?如果允许最终一致,则拆成不同聚合。
  4. 验证不变量归属 —— 对每个候选聚合根,问:“如果去掉这个根,业务规则还能被强制吗?” 不能则保留。
案例:“订单已支付”事件涉及 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 必须是两个独立聚合。

以下场景建议拆分:

  • 独立生命周期 —— 子实体可以脱离聚合根单独创建或删除(例如“评论”可以脱离“文章”聚合,因为评论有自己的生命周期)。
  • 不同变化频率 —— 部分数据每天更新多次,部分数据每月更新一次,放在一起会导致不必要的锁竞争。
  • 跨限界上下文 —— 如果两个实体属于不同的限界上下文(如“订单”和“库存”),它们天然不应在同一个聚合内。
聚合根选择策略:从业务事件到聚合边界 业务事件 订单已提交 / 库存已锁定 数据圈 Order + Payment + Items 事务一致性 必须同时成功/失败 不变量验证 去掉根还能保证规则? 是/否 聚合根候选 保留不变量 拆分或降级为实体 缩小事务边界 关键原则: 一个聚合只保证一个业务不变量;事务一致性是聚合边界,关联关系不是。 如果两个数据可以接受最终一致,它们就不应该在同一个聚合内。

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。
    修正:从业务不变量出发,找到“拥有最多不变量”的实体作为聚合根,其他实体降级为子实体。
聚合根粒度对比:过小 vs 适中 vs 过大 过小聚合(碎片化) Order Item Payment Address ❌ 事务分散,无法保证一致性 适中聚合(推荐) Order(聚合根) OrderItem(子实体) ✅ 事务边界清晰,业务内聚 过大聚合(上帝类) Order(聚合根) User + Address + Payment Logistics + Invoice ❌ 事务范围过大,锁冲突严重
最后提醒:聚合根的选择不是一次性的。随着业务演进,原来合理的聚合边界可能变得臃肿。建议每个迭代结束后,用校验清单重新审视一次聚合根设计,保持边界的“新鲜度”。