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() {
        // 验证库存、验证地址、创建支付单、生成物流单...
    }
}

问题很快暴露了

  1. 并发冲突:客服修改备注和用户修改地址会产生乐观锁冲突,因为它们属于同一个聚合根,共享同一个版本号。
  2. 事务膨胀:一个简单的备注修改都要加载整个聚合根(包含数百个订单项),性能极差。
  3. 代码耦合:聚合根内的实体之间互相引用,修改一个地方牵连一片。

修正后的设计

// 正确示范:按一致性边界拆分聚合
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;
}

这导致了两个严重问题:

  1. 加载爆炸:加载一个 Order 时,连带加载了所有 OrderItem 引用的 Product 对象。而 Product 又引用了 Category、Brand 等聚合根。一个简单的 orderRepository.findById() 几乎加载了半个数据库。

  2. 边界模糊:开发人员会顺手在 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 实践中少走一些弯路。