DDD学习第3课设计聚合与聚合根

这节课非常关键,它决定后续所有的:

  • 事务边界
  • 状态流转
  • 业务规则封装位置
  • 领域事件触发点
  • Repository 设计方式
  • CQRS 与事件驱动的上下文切割

"聚合设计"是 DDD 战术建模中最难也是最重要的部分。


聚合设计原则总结

复制代码
1. 聚合边界由强一致性决定  
2. 聚合之间不能相互持有实体引用  
3. 事务范围 = 聚合边界  
4. 修改聚合必须通过聚合根  
5. 不变量必须在聚合内部保持  

第3课:聚合与聚合根(Aggregate, Aggregate Root)

本节目标

明确聚合边界

学会判断 Entity / Value Object

理解事务一致性边界

让模型"不会不小心写错业务规则"

设计 Order 聚合的正确结构


聚合(Aggregate)

很多人以为"聚合"就是"一组对象",但在 DDD 中有明确定义:

聚合是:领域中保持强一致性的边界。
聚合根是:唯一允许外部修改此聚合状态的入口。

因此,一个正确设计的聚合必须满足:

  1. 所有业务不变量(Invariants)必须在聚合内部保持成立
    例如:订单支付时总金额不能为 0
  2. 聚合的修改只能通过聚合根完成
  3. 聚合之间不能直接引用内部实体,只能引用 ID
  4. 事务范围必须落在单个聚合内,不允许跨聚合事务

聚合根(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);

因为菜单变化不影响订单历史

因为订单不应该与菜单事务绑定

因为跨聚合事务是 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

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;
    }
}

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;
    }
}

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;
    }
}

我们不直接存 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或值对象)交互,而不是直接调用方法
  • 每个聚合都独立持久化

相关推荐
ByteX21 小时前
DDD学习第5课应用层与领域服务
ddd
better_liang1 天前
每日Java面试场景题知识点之-DDD领域驱动设计
java·ddd·实体·领域驱动设计·架构设计·聚合根·企业级开发
怀川2 天前
开源 NamBlog:一个博客外壳下的体验编译器
docker·ai·.net·博客·ddd·graphql·mcp
没有bug.的程序员5 天前
业务中台设计原则:从理念到落地的系统性工程
ddd·微服务架构·稳定性·中台设计·业务中台·能力复用·扩展点设计
职业码农NO.112 天前
系统架构设计中的 15 个关键取舍
设计模式·架构·系统架构·ddd·架构师·设计规范·领域驱动
彷徨的蜗牛22 天前
六边形架构补充 - 第五章 - DDD领域模型
架构·领域模型·ddd
rolt1 个月前
[漫画]《软件方法》微服务的遮羞布
微服务·ddd·领域驱动设计
彷徨的蜗牛1 个月前
六边形架构代码设计及实现 - 第四章 - DDD领域模型
架构·领域模型·ddd
彷徨的蜗牛1 个月前
六边形架构的调用流程 - 第三章 - DDD领域模型
架构·领域模型·ddd