DDD 领域驱动设计的聚合和限界上下文
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 账户上下文 │ │ 订单上下文 │ │ 库存上下文 │ │ (Account) │ │ (Order) │ │ (Inventory) │ │ │ │ │ │ │ │ • 用户信息 │ │ • 订单创建 │ │ • 库存扣减 │ │ • 登录认证 │ │ • 订单修改 │ │ • 仓储管理 │ │ • 权限管理 │ │ • 订单查询 │ │ • 补货计划 │ └──────────────┘ └──────────────┘ └──────────────┘
在这个划分中:
- **账户上下文**的“商品”可能只关心商品 ID 和名称,用于给用户授权后台管理。
- **订单上下文**的“商品”关心价格、标题和购买数量。
- **库存上下文**的“商品”关心 SKU、仓库存放位置和保质期。
三个上下文中虽然都有“商品”,但它们的属性和行为完全不同。强行合并会导致无尽的条件判断和业务冲突。限界上下文让每个“商品”都在自己正确的范围内演化。
### 上下文映射与集成
现实系统中,上下文之间不可能完全孤立。DDD 通过**上下文映射(Context Map)**来描述上下文之间的关系模式,常见的有:
- **共享内核(Shared Kernel)**:两个上下文共享一部分模型,需要密切协作,通常只用于短期内。
- **客户-供应商(Customer-Supplier)**:上游上下文提供数据,下游接收并适配,上游需要兼顾下游需求。
- **防腐层(Anti-Corruption Layer, ACL)**:下游通过转化层隔离上游模型的影响,保持自身纯净。
- **开放主机服务(Open Host Service)**:提供一套公开的 API(如 REST 或 gRPC),供多个下游消费。
- **发布语言(Published Language)**:定义一套标准消息格式(如 JSON Schema),供各上下文订阅。
良好的集成方式应当避免一个上下文的模型直接侵入另一个上下文。例如,订单上下文需要查询用户信息,不应直接访问账户上下文的数据库,而应通过账户上下文提供的 API 获取一个仅包含必要数据的 DTO。
---
## 聚合:维护业务不变量的边界
### 什么是聚合?
即使在一个清晰的限界上下文内部,实体与值对象之间依然存在复杂的引用网。如果允许外部对象随意修改内部实体,关键的业务规则(即**不变量**)就很容易被破坏。
**聚合(Aggregate)** 就是一组关联对象的集群,我们将它们视作一个数据修改的单元。每个聚合都有一个根实体,称为**聚合根(Aggregate Root)**,对聚合内部对象的任何访问都必须通过聚合根进行。
聚合的核心原则:
1. **业务不变量强制**:一切修改必须保证聚合内规则始终一致。
2. **外部只持有根引用**:其他对象只能持有聚合根的 ID,不能直接引用内部实体。
3. **事务边界对齐**:一个事务只修改一个聚合,保证一致性与高性能。
### 聚合根与实体、值对象
我们用订单聚合的例子来理解:
┌───────────────────────────────────┐ │ 订单聚合 │ │ 聚合根: Order │ │ ┌─────────────────────────────┐ │ │ │ Order │ │ │ │ - orderId │ │ │ │ - customerId │ │ │ │ - orderStatus │ │ │ │ - totalAmount │ │ │ │ - items: List │ │ │ │ + addItem() │ │ │ │ + pay() │ │ │ └──────────┬──────────────────┘ │ │ │ │ │ ┌──────────▼──────────────────┐ │ │ │ OrderItem (实体) │ │ │ │ - productId │ │ │ │ - price │ │ │ │ - quantity │ │ │ └─────────────────────────────┘ │ │ ┌─────────────────────────────┐ │ │ │ Address (值对象) │ │ │ │ - street, city, zip │ │ │ └─────────────────────────────┘ │ └───────────────────────────────────┘
- **Order** 是聚合根,拥有全局唯一标识 `orderId`。
- **OrderItem** 是内部实体,它的标识仅在订单聚合内唯一(如 item 序号)。外部永远不直接拿着 OrderItem 对象进行操作,只能通过 Order 根调用 `addItem()` 等方法。
- **Address** 是值对象,它没有标识,通过属性值是否相等判断,通常是不可变的。
当外部需要修改订单状态(如支付)时,必须加载整个 Order 聚合,调用 `order.pay()`,由聚合根检查当前状态是否允许支付,再决定是否变更。这样,**“订单总金额等于所有项金额之和”**这样的不变量就可以在 `addItem()` 内部保证,而不会因外界随意修改 `price` 字段而失效。
### 如何设计聚合(原则与示例)
很多团队设计的聚合过大,导致事务冲突、性能低下;或者过小,导致不变量得不到保证。以下原则有助于设计出合理的聚合:
1. **围绕真正的不变量设计**
不要为了设计而设计。只把那些必须立刻、原子性保持一致的对象放在同一个聚合内。例如,订单项必须和订单头保持总金额一致,因此放在一起;但客户姓名不需要和订单在同一事务内强一致,只需通过 `customerId` 引用即可。
2. **设计小聚合**
大聚合容易产生并发冲突。大多数时候,聚合内只有一个实体,加上几个值对象。比如,一个“商品评论”聚合可以只包含一个 Comment 根和它的若干值对象(评分、内容),通过 `productId` 引用商品,而不是将商品聚合进来。
3. **通过 ID 引用其他聚合**
```java
// 推荐:引用 ID
public class Order {
private OrderId id;
private CustomerId customerId;
// ...
}