这节课非常关键,它决定后续所有的:
- 事务边界
- 状态流转
- 业务规则封装位置
- 领域事件触发点
- Repository 设计方式
- CQRS 与事件驱动的上下文切割
"聚合设计"是 DDD 战术建模中最难也是最重要的部分。
聚合设计原则总结
1. 聚合边界由强一致性决定
2. 聚合之间不能相互持有实体引用
3. 事务范围 = 聚合边界
4. 修改聚合必须通过聚合根
5. 不变量必须在聚合内部保持
第3课:聚合与聚合根(Aggregate, Aggregate Root)
本节目标
明确聚合边界
学会判断 Entity / Value Object
理解事务一致性边界
让模型"不会不小心写错业务规则"
设计 Order 聚合的正确结构
聚合(Aggregate)
很多人以为"聚合"就是"一组对象",但在 DDD 中有明确定义:
聚合是:领域中保持强一致性的边界。
聚合根是:唯一允许外部修改此聚合状态的入口。
因此,一个正确设计的聚合必须满足:
- 所有业务不变量(Invariants)必须在聚合内部保持成立
例如:订单支付时总金额不能为 0 - 聚合的修改只能通过聚合根完成
- 聚合之间不能直接引用内部实体,只能引用 ID
- 事务范围必须落在单个聚合内,不允许跨聚合事务
聚合根(Aggregate Root)
- 作为聚合的唯一入口
- 外部不能直接修改聚合内部其他对象
- 持久化、仓储都以聚合根为单位
示例:我们系统中的两个聚合
| 聚合名 | 聚合根 | 内容 | 关系 |
|---|---|---|---|
Order |
Order | OrderItem 列表、状态、总金额 | 引用 MenuItem |
Menu |
Menu | MenuItem 列表 | 提供菜品信息 |
从统一语言到聚合划分
我们从第1课的统一语言中提炼出候选概念:
- Menu
- MenuItem
- Order
- OrderItem
- Money
- Payment
- Inventory
- Delivery
接下来要判断:
- 哪些是实体(Entity)?
- 哪些是值对象(VO)?
- 哪些应该属于同一个聚合?
Entity vs Value Object 判断规则
Value Object 的特点
- 不依赖 ID
- 不可变
- 只关心属性值
- 组合、安全、可替换
符合这些的有:
- Money(金额)
- MenuItem(菜单项:当前菜品的名字、价格快照)
补充说明
MenuItem 应该是 VO,因为它属于"价格快照",不可随菜单变化而影响历史订单。
如果把 MenuItem 设计成 Entity,你的订单历史会发生"穿越":价格被更新、变更名称等。
Entity 的特点
- 有 ID
- 生命周期内可变化
- 有领域行为
- 同 ID 即同对象
符合这些的有:
- Order
- OrderItem ← 它属于 Order 聚合
- Menu(独立聚合)
确定聚合边界(核心)
我们需要回答一个问题:
Order + OrderItem?是否是一个聚合?
是的,这是一个典型"聚合 + 内部实体"的结构。
Order (AR)
├─ OrderItem (Entity)
└─ Money, TotalAmount (Value Object)
Order 是聚合根(AR, Aggregate Root),原因:
- 它控制所有订单项的增删改
- 它拥有状态机(Created / Paid / Completed)
- 它维护金额不变量
因此:
所有对 OrderItem 的修改,都必须通过 Order 来进行。
也就是说,下面的做法是非法的:
java
orderItem.setQuantity(5);
正确做法应该是:
java
order.changeItemQuantity(itemId, 5);
为什么 Menu 是另一个聚合?
因为菜单变化不影响订单历史
因为订单不应该与菜单事务绑定
因为跨聚合事务是 DDD 禁止的
这是 DDD 中非常关键的原则:
聚合之间只能通过标识(ID)协作,不允许实体对象跨聚合引用。
不允许这样写:
java
class Order {
private MenuItem menuItem; // ❌ 错:跨聚合引用
}
必须这样:
java
class Order {
private MenuItemSnapshot snapshot; // VO,记录下单时的名称、价格
}
事务一致性边界(决定聚合大小)
DDD 给了一条定律:
事务只保证单个聚合内部数据一致性。
不允许更新多个聚合的跨聚合事务。
例如:
- "下单 + 扣库存"不能放在一个聚合事务里
- "支付订单 + 发送券"不能放在一个聚合事务里
它们必须:
- 通过领域事件
- 或 Saga / 业务流程编排
- 或补偿模式
否则会导致:
- 锁竞争放大
- 事务冲突严重
- 系统无法扩展
设计订单聚合
下面是一个专业 DDD 项目中 Order 聚合的结构:
Order 聚合
├─ Order(聚合根)
│ ├─ id
│ ├─ status
│ ├─ totalAmount(Money)
│ ├─ items:List<OrderItem>
│ ├─ 行为(pay / confirm / complete)
│ └─ 不变量(金额>0、状态流转正确)
└─ OrderItem(实体)
├─ productName
├─ quantity
├─ price(Money)
└─ subtotal(Money)
行为(业务规则)应该放在哪里?
DDD 规定:
领域行为必须放在聚合根 / 实体内部,而不是 Service。
例如:
订单支付:
java
public void pay() {
if (status != CREATED) throw new IllegalStateException("...");
this.status = PAID;
raiseEvent(new OrderPaidEvent(id, totalAmount));
}
而不是:
java
orderService.pay(order);
应用服务应该像这样:
java
public void payOrder(OrderId id) {
Order order = orderRepository.load(id);
order.pay(); // ← 业务行为在领域对象中
orderRepository.save(order);
}
聚合的"正确性"来自不变量(Invariants)
订单聚合必须保证:
- 金额不能小于 0
- OrderItem 的数量必须大于 0
- OrderStatus 流转必须正确
- 订单总金额应该是所有 OrderItem 小计之和
- 已完成订单不能修改
这些规则一旦散落在多个层,系统将无法控制一致性。
DDD 的重要原则:
不变量必须由聚合根统一维护。
代码实现
项目结构新增一个聚合目录:
domain/
├── order/
│ └── model/
│ ├── Order.java
│ ├── OrderItem.java
│ ├── OrderStatus.java
│ ├── OrderId.java
│ └── ...
└── menu/
└── model/
├── Menu.java
├── MenuItem.java
└── MenuId.java
Menu.java --- 菜单聚合根
java
package com.example.ordersystem.domain.menu.model;
import java.util.ArrayList;
import java.util.List;
/**
* 聚合根:Menu
* 表示一个菜单集合(例如当天可售菜品)
* 由 MenuItem 组成。
*/
public class Menu {
private final MenuId id;
private final List<MenuItem> items = new ArrayList<>();
public Menu(MenuId id) {
this.id = id;
}
/** 添加菜品到菜单中 */
public void addItem(String name, double price) {
items.add(new MenuItem(name, price));
}
/** 查找菜品 */
public MenuItem findItem(String name) {
return items.stream()
.filter(item -> item.getName().equalsIgnoreCase(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("菜品不存在: " + name));
}
public List<MenuItem> getItems() {
return items;
}
public MenuId getId() {
return id;
}
}
MenuItem.java --- 值对象
java
package com.example.ordersystem.domain.menu.model;
/**
* 值对象:MenuItem
* 表示菜单中的单个菜品。
* 不可变,没有标识符。
*/
public class MenuItem {
private final String name;
private final double price;
public MenuItem(String name, double price) {
if (price <= 0) throw new IllegalArgumentException("价格必须大于0");
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
MenuId.java
java
package com.example.ordersystem.domain.menu.model;
import java.util.UUID;
/**
* 值对象:MenuId
* 用于唯一标识菜单。
*/
public class MenuId {
private final String value;
public MenuId(String value) {
this.value = value;
}
public static MenuId newId() {
return new MenuId(UUID.randomUUID().toString());
}
public String getValue() {
return value;
}
}
修改 Order.java --- 订单聚合根关联 MenuItem
我们不直接存
MenuItem对象,而是存储引用信息(例如菜名和价格),保持聚合独立,防止跨聚合事务。
java
package com.example.ordersystem.domain.order.model;
import com.example.ordersystem.domain.menu.model.MenuItem;
import com.example.ordersystem.domain.shared.DomainException;
import java.util.ArrayList;
import java.util.List;
/**
* 聚合根:Order
* 管理订单内的所有状态变化和行为。
*/
public class Order {
private final OrderId id;
private final List<OrderItem> items = new ArrayList<>();
private Money totalAmount = Money.zero();
private OrderStatus status = OrderStatus.CREATED;
public Order(OrderId id) {
this.id = id;
}
/**
* 从菜单项添加菜品(通过 MenuItem)
* 说明:这里接收来自 Menu 聚合的"只读副本",不跨聚合修改。
*/
public void addMenuItem(MenuItem menuItem, int quantity) {
if (status != OrderStatus.CREATED) {
throw new DomainException("订单创建后才能添加菜品");
}
OrderItem item = new OrderItem(menuItem.getName(), quantity, new Money(menuItem.getPrice()));
items.add(item);
totalAmount = totalAmount.add(item.subtotal());
}
// 状态变更操作
public void pay() {
if (status != OrderStatus.CREATED) throw new DomainException("必须先创建才能支付");
this.status = OrderStatus.PAID;
}
public void confirm() {
if (status != OrderStatus.PAID) throw new DomainException("订单必须已支付才能确认");
this.status = OrderStatus.CONFIRMED;
}
public void complete() {
if (status != OrderStatus.CONFIRMED) throw new DomainException("订单未确认无法完成");
this.status = OrderStatus.COMPLETED;
}
public void cancel() {
if (status == OrderStatus.COMPLETED) throw new DomainException("已完成订单不能取消");
this.status = OrderStatus.CANCELLED;
}
// Getter 方法
public OrderId getId() { return id; }
public List<OrderItem> getItems() { return items; }
public Money getTotalAmount() { return totalAmount; }
public OrderStatus getStatus() { return status; }
}
应用服务:OrderWithMenuService.java
这个服务会先构建菜单,再创建订单,展示聚合间的使用方式。
java
package com.example.ordersystem.application.order;
import com.example.ordersystem.domain.menu.model.Menu;
import com.example.ordersystem.domain.menu.model.MenuId;
import com.example.ordersystem.domain.order.model.Order;
import com.example.ordersystem.domain.order.model.OrderId;
import com.example.ordersystem.domain.order.repository.OrderRepository;
import org.springframework.stereotype.Service;
/**
* 应用服务:演示 Order 聚合如何使用 Menu 聚合的数据。
*/
@Service
public class OrderWithMenuService {
private final OrderRepository repository;
public OrderWithMenuService(OrderRepository repository) {
this.repository = repository;
}
/**
* 创建菜单和订单(聚合之间通过数据引用,而非对象依赖)
*/
public Order createOrderFromMenu() {
// 创建菜单(独立聚合)
Menu menu = new Menu(MenuId.newId());
menu.addItem("Burger", 25.0);
menu.addItem("Fries", 10.0);
menu.addItem("Coke", 5.0);
// 创建订单(独立聚合)
Order order = new Order(OrderId.newId());
order.addMenuItem(menu.findItem("Burger"), 1);
order.addMenuItem(menu.findItem("Fries"), 2);
// 保存订单
repository.save(order);
return order;
}
}
控制层:MenuOrderController.java
java
package com.example.ordersystem.interfaces.api;
import com.example.ordersystem.application.order.OrderWithMenuService;
import com.example.ordersystem.domain.order.model.Order;
import org.springframework.web.bind.annotation.*;
/**
* 控制层:演示从菜单创建订单
*/
@RestController
@RequestMapping("/menus")
public class MenuOrderController {
private final OrderWithMenuService service;
public MenuOrderController(OrderWithMenuService service) {
this.service = service;
}
@GetMapping("/order")
public Order createOrderFromMenu() {
return service.createOrderFromMenu();
}
}
测试
访问:
GET http://localhost:8080/menus/order
输出:
json
{
"id": {
"value": "bf04d795-d20f-4bc2-a2fb-a6ff287dcc85"
},
"items": [
{
"name": "Burger",
"quantity": 1,
"price": {
"amount": 25.0
}
},
{
"name": "Fries",
"quantity": 2,
"price": {
"amount": 10.0
}
}
],
"totalAmount": {
"amount": 45.0
},
"status": "CREATED"
}
第3课总结
你现在理解了:
- 聚合(Aggregate)定义业务一致性边界
- 聚合根(Aggregate Root)控制聚合内部行为
- 聚合之间通过引用(ID或值对象)交互,而不是直接调用方法
- 每个聚合都独立持久化