DDD 实战:聚合根设计的五条血泪教训
2023 年,我主导了一个电商中台的 DDD 重构项目。从最初的「大泥球」单体架构到最终的领域驱动设计落地,我们踩了无数的坑。这篇文章不谈理论,只讲实战中用血泪换来的五条聚合根设计教训。
背景:那个让我们失眠的订单系统#
老系统是一个典型的「上帝类」架构——OrderService 有 8000 多行代码,一个 createOrder 方法就包含了库存扣减、优惠券核销、积分计算、物流单创建等 12 个领域的逻辑。任何一个小改动都可能引发蝴蝶效应。
我们决定用 DDD 重构。但理论上的 DDD 和工程实践之间的鸿沟,远比教科书中描述的要深。
教训一:聚合根不是实体,是一致性边界#
最初的设计中,我们把 Order(订单)设计成了一个巨大的聚合根,里面包含了订单项、收货地址、支付信息、物流跟踪、发票信息等所有关联实体。
// 错误示范:上帝聚合根
public class Order {
private OrderId id;
private List<OrderItem> items; // 订单项
private ShippingAddress address; // 收货地址
private PaymentInfo payment; // 支付信息
private List<LogisticsRecord> logistics;// 物流跟踪
private InvoiceInfo invoice; // 发票信息
private CouponUsage couponUsage; // 优惠券使用记录
private List<OrderRemark> remarks; // 订单备注
// 一个方法改了 7 个实体的状态...
public void confirmOrder() {
// 验证库存、验证地址、创建支付单、生成物流单...
}
}
问题很快暴露了:
- 并发冲突:客服修改备注和用户修改地址会产生乐观锁冲突,因为它们属于同一个聚合根,共享同一个版本号。
- 事务膨胀:一个简单的备注修改都要加载整个聚合根(包含数百个订单项),性能极差。
- 代码耦合:聚合根内的实体之间互相引用,修改一个地方牵连一片。
修正后的设计:
// 正确示范:按一致性边界拆分聚合
public class Order {
private OrderId id;
private OrderStatus status;
private List<OrderItem> items; // 订单项跟随订单生命周期
private Money totalAmount; // 总金额由订单项决定,强一致
// 只暴露 ID 引用,不持有其他聚合的实体
private AddressId addressId; // 引用,不是包含
private PaymentId paymentId;
}
// 独立的聚合根
public class ShippingAddress {
private AddressId id;
private OrderId orderId; // 通过 ID 关联,不是对象引用
// ...
}
public class PaymentRecord {
private PaymentId id;
private OrderId orderId;
// ...
}
核心原则:聚合根的边界由一致性需求决定,不是由业务关联决定。
「订单和收货地址有业务关联」不代表它们必须在同一个聚合根中。只有「修改订单金额时必须同时修改订单项金额」这种事务级强一致性需求,才是划入同一聚合的理由。
教训二:聚合之间用 ID 引用,不要用对象引用#
这是 DDD 社区反复强调但新手最容易犯的错误。在我们的第一版设计中,Order 聚合根直接持有 Product 对象的引用:
// 错误示范:跨聚合对象引用
public class OrderItem {
private Product product; // 直接引用了另一个聚合根!
private int quantity;
private Money subtotal;
}
这导致了两个严重问题:
-
加载爆炸:加载一个 Order 时,连带加载了所有 OrderItem 引用的 Product 对象。而 Product 又引用了 Category、Brand 等聚合根。一个简单的
orderRepository.findById()几乎加载了半个数据库。 -
边界模糊:开发人员会顺手在 Order 的业务方法中修改 Product 的属性(比如
product.decrementStock()),导致聚合之间的边界被悄悄打破。
// 正确示范:用 ID 引用
public class OrderItem {
private ProductId productId; // 只持有 ID
private String productName; // 冗余必要信息(值对象快照)
private Money unitPrice; // 下单时的价格快照
private int quantity;
}
当你需要通过 ID 去另一个仓储(Repository)查询时,这个「不方便」恰恰是在提醒你:你正在跨越聚合边界。这种跨越应该通过领域事件或应用服务来协调,而不是在聚合内部直接操作。
教训三:小聚合优于大聚合#
Eric Evans 在《领域驱动设计》中明确说过:尽量设计小聚合。但在实践中,团队总是倾向于把相关联的东西放在一起,导致聚合根越来越大。
我们的量化指标:
聚合根大小的经验法则:
- 一个聚合根包含的实体数量:建议 ≤ 5 个
- 一个聚合根加载后的对象图大小:建议 ≤ 100 个对象
- 一个聚合根的事务影响范围:建议 ≤ 3 张数据库表
大聚合的问题不仅是性能,更在于它违反了单一职责原则。一个聚合根如果承担了太多的业务规则,它的变更原因就会变多,维护成本指数级上升。
拆分大聚合的策略:
策略 1:按生命周期拆分
Order 和 OrderItem 的生命周期一致 → 同一聚合
Order 和 Invoice 的生命周期不同 → 不同聚合
策略 2:按变更频率拆分
ProductBasicInfo 很少变化
ProductInventory 频繁变化
→ 拆成两个聚合根
策略 3:按并发场景拆分
用户修改个人资料
用户修改登录密码
→ 不会同时发生,但在高并发下可能冲突 → 拆成两个聚合
教训四:用最终一致性替代跨聚合强一致#
在拆分聚合之后,最大的挑战是:原来在一个事务中完成的操作,现在分布在多个聚合根中,如何保证一致性?
以「下单扣库存」为例:
旧架构(单事务):
BEGIN TRANSACTION
INSERT INTO orders ...
UPDATE inventory SET stock = stock - 1 ...
COMMIT
新架构(跨聚合):
Order Aggregate: 创建订单
Inventory Aggregate: 扣减库存
→ 分布在不同的事务中,如何保证原子性?
我们采用了领域事件 + 最终一致性的方案:
// 1. 订单聚合根发布领域事件
public class Order {
private List<DomainEvent> events = new ArrayList<>();
public void confirmOrder() {
this.status = OrderStatus.CONFIRMED;
events.add(new OrderConfirmedEvent(this.id, this.items));
}
}
// 2. 应用服务订阅事件,协调其他聚合
@Service
public class OrderApplicationService {
@Transactional
public void confirmOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId);
order.confirmOrder();
orderRepository.save(order);
// 发布事件(事务提交后)
domainEventPublisher.publish(order.getPendingEvents());
}
}
// 3. 库存聚合订阅事件,异步处理
@Component
public class InventoryEventHandler {
@EventListener
@Transactional
public void onOrderConfirmed(OrderConfirmedEvent event) {
for (OrderItemSnapshot item : event.getItems()) {
Inventory inventory = inventoryRepository.findByProductId(item.getProductId());
inventory.decrease(item.getQuantity());
inventoryRepository.save(inventory);
}
}
}
但这里有一个关键问题:事件处理失败了怎么办?
我们的补偿策略:
场景:库存扣减失败(库存不足)
方案 A:先锁定再确认
1. 创建订单时,先发送 "预扣库存" 命令
2. 库存聚合锁定库存(不真正扣减)
3. 锁定成功 → 确认订单
4. 锁定失败 → 取消订单
方案 B:Saga 模式
1. 确认订单
2. 扣减库存失败
3. 发送补偿事件:取消订单 + 释放已锁定的资源
方案 C:定时对账
1. 每小时运行对账任务
2. 检查 "已确认但库存未扣减" 的订单
3. 触发补偿流程或人工介入
在电商场景中,我们最终选择了方案 A(先锁定再确认),因为它在用户体验和数据一致性之间取得了最好的平衡。
教训五:聚合根的行为设计比结构设计更重要#
很多团队在做 DDD 时,只关注聚合根的结构设计(有哪些字段、哪些实体、哪些值对象),却忽略了行为设计(聚合根能做什么、不能做什么、状态如何流转)。
一个没有行为设计的聚合根,本质上就是一个贫血模型(Anemic Domain Model)的「数据容器」:
// 贫血模型:聚合根只有 getter/setter,业务逻辑在 Service 中
public class Order {
private OrderStatus status;
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
}
// Service 中写满了业务逻辑
public class OrderService {
public void cancelOrder(Order order) {
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new BusinessException("已发货不能取消");
}
if (order.getStatus() == OrderStatus.COMPLETED) {
throw new BusinessException("已完成不能取消");
}
order.setStatus(OrderStatus.CANCELLED);
// ...
}
}
正确的做法是把业务规则内聚到聚合根中:
// 充血模型:业务规则在聚合根中
public class Order {
private OrderStatus status;
public void cancel(String reason) {
if (this.status == OrderStatus.SHIPPED) {
throw new BusinessException("已发货的订单无法取消,请申请退货");
}
if (this.status == OrderStatus.COMPLETED) {
throw new BusinessException("已完成的订单无法取消");
}
OrderStatus previousStatus = this.status;
this.status = OrderStatus.CANCELLED;
this.cancelReason = reason;
this.cancelledAt = Instant.now();
// 发布领域事件
this.registerEvent(new OrderCancelledEvent(this.id, reason, previousStatus));
}
}
状态机是聚合根行为设计的利器。我们在项目中用状态机显式定义了订单的生命周期:
[CREATED] ──确认──> [CONFIRMED] ──支付──> [PAID] ──发货──> [SHIPPED] ──签收──> [COMPLETED]
│ │ │
└──取消──> [CANCELLED] <──取消──┘ │
退款
↓
[REFUNDED]
每一次状态转换都是一个聚合根的行为方法,包含完整的前置校验和事件发布。这样做的好处是:业务规则不会泄漏到聚合根之外,新加入团队的开发人员只需要阅读聚合根的代码,就能理解完整的业务规则。
总结:聚合根设计检查清单#
经过一年的实战打磨,我们总结了一份聚合根设计检查清单:
| 检查项 | 标准 |
|---|---|
| 一致性边界 | 聚合根内的实体是否存在事务级强一致性需求? |
| 引用方式 | 跨聚合是否使用 ID 引用而非对象引用? |
| 聚合大小 | 实体数量 ≤ 5?加载对象图 ≤ 100? |
| 并发安全 | 乐观锁冲突率是否在可接受范围? |
| 行为内聚 | 业务规则是否内聚在聚合根中而非 Service 中? |
| 状态流转 | 是否定义了显式的状态机? |
| 事件设计 | 状态变更是否通过领域事件通知其他聚合? |
DDD 不是一套可以照搬的模板,而是一种控制复杂性的思维方式。聚合根的设计没有标准答案,只有在特定业务上下文中的最优解。希望这五条教训能帮助你在自己的 DDD 实践中少走一些弯路。