4. 聚合之间的关联方式:通过ID引用 vs 对象引用,以及各自的适用场景

在领域驱动设计中,聚合是数据一致性的最小单元。当两个聚合需要协作时,如何建立它们之间的关联,是API契约治理中最关键的决策之一。错误的关联方式会导致聚合边界被破坏、事务蔓延、性能退化,最终使领域模型退化为贫血的数据容器。

聚合之间的关联只有两种合法方式:通过ID引用和通过对象引用。理解两者的本质差异与适用边界,是设计高内聚、低耦合聚合的前提。

4.1 两种关联方式的核心差异

对比维度 ID引用 对象引用
耦合程度 弱耦合,仅依赖标识符 强耦合,依赖整个对象结构
事务边界 天然隔离,各自独立事务 容易跨越聚合边界,导致事务扩散
加载策略 按需加载(Lazy Load) 通常立即加载(Eager Load)
序列化开销 极小,仅传递ID 较大,需序列化整个对象图
API契约稳定性 高,ID格式极少变更 低,对象结构变更会波及所有消费者
领域语义表达 弱,仅表达"有关系" 强,可直接表达业务规则
核心原则:聚合之间的关联,默认使用ID引用。对象引用仅限同一聚合内部使用。这是DDD聚合设计的第一条纪律。

4.2 通过ID引用:推荐的默认方式

ID引用是指在一个聚合中,仅保存另一个聚合的标识符(通常是UUID或业务主键),而不是持有整个聚合对象的引用。这种方式在API契约中表现为:请求体或响应体中只包含关联聚合的ID字段。

4.2.1 典型代码结构

// 订单聚合 - 通过ID引用客户聚合
public class Order {
    private OrderId id;
    private CustomerId customerId;  // 仅保存客户ID,而非Customer对象
    private List<OrderLineItem> items;
    private Money totalAmount;
    
    // 领域方法
    public void addItem(ProductId productId, int quantity, Money unitPrice) {
        // 业务逻辑...
    }
}

// 客户聚合
public class Customer {
    private CustomerId id;
    private String name;
    private Email email;
    private Address shippingAddress;
}

4.2.2 适用场景

  • 跨聚合查询:订单需要知道属于哪个客户,但不需要在订单上下文中操作客户属性
  • 高并发写入:两个聚合各自有频繁的写操作,需要独立的事务边界
  • 分布式部署:聚合可能部署在不同的微服务或限界上下文中
  • 大数据量关联:一个聚合关联大量其他聚合(如订单关联多个商品)
  • API网关聚合:对外暴露的API契约中,使用ID引用保持接口稳定

4.2.3 优势与风险

优势 风险与应对
事务边界清晰,避免跨聚合事务 需要额外查询才能获取关联聚合的详细信息 → 使用BFF或查询模型
聚合独立演化,变更互不影响 ID可能指向不存在的聚合(悬空引用) → 使用外键约束或最终一致性校验
序列化/反序列化性能高 领域逻辑中无法直接导航到关联对象 → 通过领域服务或仓储协调
API契约稳定,消费者不受内部变更影响 批量操作时需要多次查询 → 使用批量ID查询接口

4.3 通过对象引用:谨慎使用的特例

对象引用是指在一个聚合中,直接持有另一个聚合的完整对象实例。这种方式在DDD中通常被视为反模式,但在某些特定场景下是合理的。

4.3.1 典型代码结构

// 注意:以下代码仅用于演示对象引用的反模式,不推荐实际使用
public class Order {
    private OrderId id;
    private Customer customer;  // 直接持有Customer对象 - 不推荐
    private List<OrderLineItem> items;
    
    public void applyDiscount() {
        // 直接访问Customer的属性,跨越了聚合边界
        if (customer.isVip()) {
            this.totalAmount = this.totalAmount.multiply(0.9);
        }
    }
}

4.3.2 适用场景(极少)

  • 值对象引用:引用的对象是值对象(不可变、无标识),如Address、Money
  • 同一限界上下文内的强关联:两个聚合在业务上几乎总是同时被访问,且变更频率极低
  • 只读引用:引用的聚合在业务上下文中只读,不会通过当前聚合修改它
  • 性能敏感路径:经过严格压测证明,ID引用导致的额外查询成为瓶颈,且无法通过缓存解决
警告:对象引用是DDD中最容易被滥用的模式。在API契约治理中,对象引用意味着消费者必须理解被引用对象的完整结构,任何被引用对象的字段变更都会导致契约断裂。除非有极其充分的理由,否则坚持使用ID引用。

4.4 两种方式的决策流程

以下SVG图展示了在API契约设计中,如何根据业务场景选择聚合之间的关联方式:

聚合关联方式决策流程 确定聚合关联需求 是否属于同一聚合? 是 对象引用 否 是否是不可变值对象? 是 对象引用 否 是否强关联且只读? 是 对象引用 否 ID引用 决策要点: • 默认选择ID引用,仅在值对象、同一聚合内部或经过严格评估的只读强关联场景下使用对象引用 • API契约中优先暴露ID引用,避免将内部聚合结构泄露给外部消费者

4.5 API契约中的最佳实践

4.5.1 请求/响应契约设计

// 推荐:ID引用方式
POST /api/orders
{
  "customerId": "c7b3d8e0-5f1a-4b2c-9d3e-8f0a1b2c3d4e",
  "items": [
    {
      "productId": "p9a8b7c6-d5e4-f3a2-b1c0-d9e8f7a6b5c4",
      "quantity": 2,
      "unitPrice": 29.99
    }
  ]
}

// 不推荐:对象引用方式
POST /api/orders
{
  "customer": {  // 暴露了Customer的完整结构
    "id": "c7b3d8e0-...",
    "name": "张三",
    "email": "zhangsan@example.com",
    "shippingAddress": { ... }
  },
  "items": [...]
}

4.5.2 查询接口的补偿策略

使用ID引用后,消费者可能需要获取关联聚合的详细信息。此时不应在聚合内直接暴露对象引用,而应通过专门的查询接口或BFF(Backend For Frontend)层来组装数据:

// 聚合仓储接口 - 只返回聚合本身
public interface OrderRepository {
    Order findById(OrderId id);
    List<Order> findByCustomerId(CustomerId customerId);
}

// 查询服务 - 专门用于组装展示数据
public class OrderQueryService {
    public OrderDetailDto getOrderDetail(OrderId orderId) {
        Order order = orderRepository.findById(orderId);
        CustomerDto customer = customerService.getCustomer(order.getCustomerId());
        List<ProductDto> products = productService.getProducts(
            order.getItems().stream()
                .map(OrderLineItem::getProductId)
                .collect(Collectors.toList())
        );
        return OrderDetailDto.assemble(order, customer, products);
    }
}

4.6 常见误区与避坑指南

误区一:"对象引用可以简化代码,减少查询次数。"
纠正:对象引用虽然减少了代码行数,但引入了隐式的事务边界和加载策略问题。正确的做法是使用ID引用 + 显式的查询服务,代码更清晰,事务边界更可控。
误区二:"ORM框架支持懒加载,所以对象引用没问题。"
纠正:懒加载只是延迟了问题暴露的时间。在序列化、跨进程调用或并发写入时,懒加载会导致N+1查询、会话管理异常和事务扩散。ORM的懒加载机制不应成为跨越聚合边界的理由。
避坑清单:
  • 不要在聚合根中直接注入其他聚合的仓储或服务
  • 不要在同一个事务中修改多个聚合(使用领域事件实现最终一致性)
  • API契约中避免出现嵌套的聚合对象结构
  • ID引用中的ID应使用业务无关的UUID,避免使用自增ID暴露业务信息
  • 批量操作时,提供批量ID查询接口,避免循环调用

4.7 总结

聚合之间的关联方式选择,直接决定了API契约的稳定性和系统的可演化性。ID引用是默认选择,对象引用是经过严格评估后的特例。在API契约治理中,坚持ID引用意味着:

  • 每个聚合保持独立的事务边界
  • API契约不因被引用聚合的内部变更而断裂
  • 消费者按需获取数据,避免过度加载
  • 系统具备独立演化和独立部署的能力

当你在设计API契约时遇到"是否要暴露关联聚合的字段"的困惑时,回到这个决策流程:先问自己"它们是否属于同一个聚合",再问"是否是不可变值对象",最后问"是否经过严格评估的只读强关联"。绝大多数情况下,答案都会指向ID引用。