DDD 聚合根设计实战——如何划定边界,我经历过的三次重构
DDD 聚合根设计实战——如何划定边界,我经历过的三次重构
适读人群:正在实践 DDD 的工程师,对聚合边界感到困惑的技术 TL | 阅读时长:约17分钟 | 核心价值:聚合边界划定没有公式,只有权衡——用三次真实重构经历说清楚这件事
聚合边界是 DDD 里最难的事
我敢说,聚合根的边界划定,是 DDD 实践中最让人纠结的事情,没有之一。
有人把一整个"订单 + 订单项 + 支付记录 + 物流信息 + 退款记录"全部塞进一个巨大的订单聚合,结果聚合对象序列化到 Redis 要 20KB,加载一次要 150ms,每次修改任何一个状态都要锁这个大对象。
也有人把每个数据库表都对应一个聚合根,Order、OrderItem、Payment、Shipment 全是聚合根,结果一个简单的"下单"操作要协调 5 个聚合,代码复杂度飙升,到处是分布式事务。
我自己在三个不同的项目里,对聚合边界做过三次大的重构,每次的原因和结论都不一样。
先说清楚聚合边界的划定原则
聚合边界的核心原则:聚合内的数据必须在同一个事务内保持一致。
两个数据,如果它们需要在业务上"同时"保证正确(不能一个更新了另一个没更新),那它们就应该在同一个聚合里。
反过来说:如果两个数据允许"最终一致"(可以有短暂的不一致窗口),那它们就不应该在同一个聚合里,因为把它们放在一起只会让聚合越来越大。
第一次重构:从"上帝聚合"到合理边界
背景
第一个项目是一个保险核心系统,我接手时的代码是这样的:
// "上帝聚合"——把所有相关数据都塞进来
public class InsurancePolicy { // 保单
private PolicyId id;
private String policyNumber;
private PolicyHolder policyHolder; // 投保人信息
private Insured insured; // 被保险人信息
private List<Beneficiary> beneficiaries; // 受益人列表
private CoverageDetail coverageDetail; // 保障详情
private List<Premium> premiums; // 保费缴纳记录
private List<Claim> claims; // 理赔记录
private List<Endorsement> endorsements; // 批注记录
private PolicyStatus status;
// 加载一张保单,要 JOIN 7 张表,数据量随时间线性增长
}问题
一张保单在系统里存了 5 年后,premiums 可能有 60 条(每月一条),claims 可能有 10 条,endorsements 可能有 20 条。加载一张保单要拉取几百条记录,内存占用几 MB,响应时间超过 500ms。
更严重的是:理赔员要修改理赔状态,系统需要加载整个保单聚合(含所有缴费记录、批注等),持有大对象锁,导致其他人对同一张保单的操作全部排队。
重构方案
分析各部分数据的"一致性边界":
PolicyHolder+Insured+Beneficiaries:需要和保单本身同时一致(投保人变更必须更新保单)CoverageDetail:属于保单的核心部分,需要强一致Premium缴费记录:可以独立,缴费是否成功不影响保单的其他状态Claim理赔记录:完全独立的生命周期,理赔处理与保单其他信息无关Endorsement批注:独立流程,可以最终一致
重构后:
// 保单聚合(核心,轻量)
public class InsurancePolicy {
private PolicyId id;
private String policyNumber;
private PolicyHolder policyHolder;
private Insured insured;
private List<Beneficiary> beneficiaries; // 通常 1-3 个,不会很多
private CoverageDetail coverageDetail;
private PolicyStatus status;
// 不再包含 premiums、claims、endorsements
}
// 理赔聚合(独立)
public class InsuranceClaim {
private ClaimId id;
private PolicyId policyId; // 只持有引用,不持有对象
private ClaimStatus status;
public void approve(String reviewerId, String reason) {
if (this.status != ClaimStatus.UNDER_REVIEW) {
throw new DomainException("只有审核中的理赔单可以批准");
}
this.status = ClaimStatus.APPROVED;
DomainEvents.raise(new ClaimApprovedEvent(this.id, this.policyId));
}
}注意:InsuranceClaim 里只存 PolicyId(值对象),不存 InsurancePolicy 对象的引用。跨聚合通过 ID 引用,不通过对象引用——这是 DDD 的基本规则。
效果
保单加载从 500ms 降到 30ms,理赔操作并发冲突消失,代码可读性大幅提升。
第二次重构:从"太细"到适度合并
背景
第二个项目吸取了第一次的教训,把一切都拆得很细。结果物极必反。
一个"创建订单"的业务操作变成了这样:
// 应用服务里的"下单"方法
public OrderId placeOrder(PlaceOrderCommand cmd) {
// 1. 创建订单聚合根
Order order = Order.create(cmd.getUserId(), cmd.getItems());
orderRepository.save(order);
// 2. 创建独立的优惠聚合根
Discount discount = Discount.apply(order.getId(), cmd.getCouponId());
discountRepository.save(discount);
// 3. 创建独立的运费聚合根
ShippingFee fee = ShippingFee.calculate(order.getId(), cmd.getAddressId());
shippingFeeRepository.save(fee);
// 4. 创建独立的支付意向聚合根
PaymentIntent intent = PaymentIntent.create(order.getId());
paymentIntentRepository.save(intent);
// 上面 4 个都需要在一个事务里!!!
// 结果:@Transactional 把所有聚合的 save 包在一起
// 这和没有拆分有什么区别?
}把 Order、Discount、ShippingFee、PaymentIntent 拆成 4 个聚合,但实际上它们在下单时必须同时创建,必须在同一个事务里,根本拆不开。
重构方案
重新思考"一致性边界":
- 订单金额 = 商品总价 - 优惠金额 + 运费。这三个数字在下单时必须一起确定,不允许其中一个更新了另外两个没更新。
- 因此,订单金额明细(包含商品金额、优惠减免、运费)应该在订单聚合内部。
public class Order {
private OrderId id;
private List<OrderItem> items;
private Money subtotal; // 商品总价
private Money discountAmount; // 优惠减免
private Money shippingFee; // 运费
private Money totalAmount; // 实付金额
// 下单时,在聚合内部计算并存储所有金额
public static Order place(UserId userId, List<OrderItemCmd> items,
Coupon coupon, ShippingAddress address) {
Order order = new Order(userId);
items.forEach(item -> order.addItem(item.getProductId(), item.getQuantity(), item.getPrice()));
order.subtotal = order.calculateSubtotal();
order.discountAmount = coupon != null ? coupon.calculateDiscount(order.subtotal) : Money.ZERO;
order.shippingFee = ShippingFeeCalculator.calculate(address, order.subtotal);
order.totalAmount = order.subtotal.subtract(order.discountAmount).add(order.shippingFee);
DomainEvents.raise(new OrderPlacedEvent(order.id, order.totalAmount));
return order;
}
}教训
聚合不是越小越好。聚合的本质是"事务边界",如果你发现自己一直在用 @Transactional 把多个聚合的操作包在一起,说明这些聚合的边界划错了,它们应该是同一个聚合。
第三次重构:引入快照解决大聚合的性能问题
背景
第三个项目是一个 ERP 系统,生产工单聚合(ProductionOrder)需要记录完整的操作历史(每次状态变更、每个工序的完工记录、每次质检结果),这些历史记录是业务上不可缺少的。
但随着工单执行,历史记录越来越多,加载聚合越来越慢。
重构方案
引入快照(Snapshot)机制:
public class ProductionOrder {
private ProductionOrderId id;
private ProductionStatus currentStatus;
// 完整历史(只在需要审计时加载)
private List<ProductionRecord> fullHistory;
// 当前关键状态的快照(总是最新的,轻量)
private ProductionSnapshot latestSnapshot;
}
// Repository 支持两种加载方式
public interface ProductionOrderRepository {
// 轻量加载:只加载聚合根 + 快照,不加载历史记录(用于业务操作)
Optional<ProductionOrder> findById(ProductionOrderId id);
// 完整加载:加载包含历史记录的完整聚合(用于审计查询)
Optional<ProductionOrder> findByIdWithFullHistory(ProductionOrderId id);
void save(ProductionOrder order);
}大多数业务操作只需要轻量加载,审计功能才需要完整加载,两者分离,日常操作性能正常。
踩坑记录
踩坑一:聚合内直接调用外部服务
// 错误做法:聚合内部调用外部服务
public class Order {
@Autowired
private InventoryService inventoryService; // 不要这么做!!!
public void place() {
inventoryService.reserve(this.items); // 聚合内部发 HTTP 调用
}
}聚合应该是纯粹的内存对象,不依赖外部服务。外部服务的调用放在应用服务里。
踩坑二:聚合边界跨微服务了
分布式系统里,一个聚合必须在同一个服务内,不能让一个聚合的数据分布在两个微服务的数据库里。如果发现聚合边界跨了微服务,要么合并两个服务,要么重新划定聚合边界。
聚合边界的判断清单
判断两个对象是否属于同一个聚合:
1. 它们是否必须在同一个事务内保证一致?
是 → 考虑同一聚合
否 → 不同聚合
2. 业务操作是否经常需要同时修改这两个对象?
是 → 考虑同一聚合
否 → 不同聚合
3. 一个对象数据量是否会随时间无限增长?
是 → 单独聚合,通过 ID 引用
4. 两个对象是否有独立的生命周期?
是 → 不同聚合
否 → 考虑同一聚合聚合边界没有标准答案,只有适合当前业务的答案。这也是为什么 DDD 是需要持续迭代的,而不是一次性设计完就固化的。
