Skip to content
第 5 章 架构 ⏱ 12 分钟阅读

第 5 章:聚合根 ​

学习目标 ​

  • 理解聚合根的本质:一致性边界
  • 掌握聚合设计的"小而美"原则
  • 学会用仓储管理聚合的整个生命周期
  • 避开大聚合、跨聚合事务的常见坑

一、聚合的本质 ​

聚合 = 一致性边界,边界内强一致,边界外最终一致。一个聚合 = 一个事务。

text
聚合
├── 聚合根(对外唯一入口)
├── 实体(内部成员)
└── 值对象(内部属性)

经典订单聚合:

java
public class Order {
    private final OrderId id;
    private OrderStatus status;
    private final BuyerId buyerId;
    private final List<OrderItem> items;     // 实体
    private final Address shippingAddress;   // 值对象
    private final Money paidAmount;          // 值对象

    // 唯一改 items 的入口
    public void addItem(SkuId skuId, int quantity, Money unitPrice) {
        if (status != OrderStatus.CREATED) {
            throw new OrderImmutableException("订单已提交,不能加购");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量必须 > 0");
        }
        // 找已有 item?累加:新增
        items.stream()
            .filter(i -> i.skuId().equals(skuId))
            .findFirst()
            .ifPresentOrElse(
                i -> i.increase(quantity),
                () -> items.add(new OrderItem(skuId, quantity, unitPrice))
            );
    }

    public void pay(Money amount) {
        if (status != OrderStatus.CREATED) {
            throw new OrderImmutableException("订单状态不对:" + status);
        }
        if (!amount.equals(this.totalAmount())) {
            throw new PaymentAmountMismatchException();
        }
        this.status = OrderStatus.PAID;
        this.paidAmount = amount;
    }

    public Money totalAmount() {
        return items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.zero("CNY"), Money::add);
    }
}

二、聚合设计的"小而美" ​

聚合越小越好。一个聚合应该能在几毫秒内完成加载和保存。

java
// ✅ 小聚合:订单本身
public class Order {
    private List<OrderItem> items;       // 只存必要的
}

// ❌ 大聚合:订单 + 收货地址簿 + 优惠券 + 推荐列表
public class BigOrder {
    private List<OrderItem> items;
    private AddressBook addressBook;     // 不必每次都加载
    private List<Coupon> coupons;         // 单独的优惠券聚合
    private List<Product> recommendations; // 推荐系统独立
}

⚠️ 坑 1:聚合太大,每次只改一个字段也要加载全部数据,加锁时间长,高并发下锁竞争严重。

判断标准:这些对象是否必须一起修改、一起提交?

java
// 订单 + 订单项 → 一起改 → 同一聚合
// 订单 + 库存 → 各自独立 → 不同聚合,异步协调
// 订单 + 用户 → 各自独立 → 不同聚合,用 ID 引用

三、聚合内不变量(Invariant) ​

聚合根守护一组业务不变量:

java
public class Order {
    private final List<OrderItem> items;

    // 不变量 1:订单至少 1 个商品
    public void submit() {
        if (items.isEmpty()) {
            throw new EmptyOrderException();
        }
        this.status = OrderStatus.SUBMITTED;
    }

    // 不变量 2:商品总价 = 各项之和
    public Money totalAmount() {
        return items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.zero("CNY"), Money::add);
    }

    // 不变量 3:未支付订单 30 分钟过期
    public boolean isExpired(LocalDateTime now) {
        return createdAt.plus(30, ChronoUnit.MINUTES).isBefore(now);
    }
}

外部任何修改都要走聚合根的方法,绕开方法直接改字段 = 破坏不变量。

⚠️ 坑 2:items 没有 private final 或者暴露 getItems() 返回可变 List,外部可以 add/remove 绕过校验。严格封装。

四、聚合根 vs 仓储 ​

仓储只针对聚合根,不针对聚合内实体:

java
// ✅ 正确:只提供 OrderRepository
public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
}

// ❌ 错误:提供 OrderItemRepository
public interface OrderItemRepository {
    void save(OrderItem item);   // 加了等于绕过聚合根
}

加载 Order 时,内部的 OrderItem 自动加载(同一 SQL JOIN 即可);要查 OrderItem,只能通过 Order。

五、跨聚合协调:领域事件 ​

订单支付后,要扣库存、发通知、发积分——不能塞同一事务:

java
@Transactional
public class OrderApplicationService {
    @Resource private OrderRepository orderRepository;
    @Resource private ApplicationEventPublisher publisher;

    public void pay(OrderId orderId, Money amount) {
        // 1. 改订单状态(订单聚合内)
        Order order = orderRepository.findById(orderId);
        order.pay(amount);
        orderRepository.save(order);

        // 2. 发领域事件,异步处理其他聚合
        order.pullEvents().forEach(publisher::publishEvent);
    }
}

// 库存上下文订阅
@EventListener
@Transactional
public void onOrderPaid(OrderPaidEvent event) {
    inventoryService.deduct(event.skuIds(), event.quantities());
}

⚠️ 坑 3:用 @Transactional 把订单 + 库存 + 积分全包了,3 个表全部回滚。Saga/事件才是正解。

六、聚合根的生命周期 ​

java
public class Order {
    public static Order create(BuyerId buyerId, Address address) {
        // 工厂方法集中创建逻辑
        OrderId id = OrderId.generate();
        Instant now = Instant.now();
        Order order = new Order(id, buyerId, address, now);
        order.addDomainEvent(new OrderCreatedEvent(id, buyerId, now));
        return order;
    }

    private Order(OrderId id, BuyerId buyerId, Address address, Instant createdAt) {
        this.id = id;
        this.buyerId = buyerId;
        this.shippingAddress = address;
        this.createdAt = createdAt;
        this.status = OrderStatus.CREATED;
    }
}

构造函数私有,必须走 create() 工厂方法,保证不变量初始成立。

⚠️ 坑 4:new Order() 满大街散布,业务规则无法统一。强制工厂方法 才能保证对象创建时一定合法。

本章小结 ​

原则做法
聚合要小只放必须一起改的实体
聚合根唯一入口私有构造 + 工厂方法
一致性边界聚合内强一致,聚合外最终一致
仓储只对聚合根不为内部实体提供仓储
跨聚合异步领域事件 + Saga
不可变快照跨聚合用 ID 引用

动手练习 ​

  1. 聚合设计:为"购物车"设计聚合,包含 Cart、CartItem,给出字段和业务方法
  2. 不变量:实现 Order.cancel() 校验"已发货订单不能取消"
  3. 事件发布:在 Order.markPaid() 里发出 OrderPaidEvent,并在应用服务里发布
  4. 仓储隔离:写 OrderRepository 接口,只暴露 findById / save / findByBuyer,不允许按 item 查询

下一章:第 6 章:领域服务 →

本站基于 VitePress 构建 · 由 StackHub 团队维护