领域驱动设计(二):聚合设计精讲——一致性边界的艺术

写在前面

上一篇做了 DDD 全景导览,提到聚合(Aggregate)是 DDD 最关键、也最容易用错的概念。这篇就把它彻底讲透。

很多人把聚合理解成"一组相关的对象"——这只对了一半。聚合真正的灵魂是四个字:一致性边界。理解了这四个字,你才知道怎么划聚合、聚合根该干什么、为什么一个事务只能改一个聚合。


一、为什么需要聚合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
没有聚合会怎样?

  一个订单 Order,里面有若干订单项 OrderItem。
  如果没有边界规则:
    - 谁都能 new 一个 OrderItem 塞进去
    - 谁都能改 OrderItem 的数量、价格
    - 订单总价 = 各项小计之和,这个规则谁来保证?

  结果:
    → 任何一处漏改,总价就算错(不变量被破坏)
    → 业务规则散落各处,谁也不敢动
    → 并发修改互相覆盖

  聚合要解决的,就是"怎么让一组对象的规则不被绕过"。
1
2
3
4
5
聚合的本质:
  把"必须一起保持一致"的对象圈起来,
  指定一个入口(聚合根),
  所有修改只能从入口进,
  规则在入口内部强制,外界绕不过去。

二、聚合与聚合根的定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
聚合(Aggregate)
  一组作为整体被访问和持久化的领域对象。
  它是一个"一致性边界"——边界内的对象,在任何时刻都满足业务规则。

聚合根(Aggregate Root)
  聚合的入口对象,是外部访问聚合的唯一通道。
  - 聚合外部的代码,只能持有聚合根的引用
  - 不能直接引用聚合内部的子对象
  - 所有对内部的修改,必须经过聚合根的方法

  例:订单聚合
    Order(聚合根)
    ├── OrderItem(内部实体)
    └── ShippingAddress(内部值对象)

    外部代码:
      order.AddItem(...)        ✅ 经过聚合根
      order.Items[0].Qty = 5    ❌ 绕过聚合根直接改内部

三、聚合根的职责

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
聚合根要承担四件事:

  1. 守门人
     所有修改入口都在它身上(AddItem / Cancel / Ship ...)
     内部对象不暴露可变状态(返回只读集合、值对象不可变)

  2. 不变量维护者
     业务规则在聚合根方法内部强制
     例:总价 = Σ小计;库存≥0;状态只能正向流转
     → 任何修改完成后,聚合一定处于"合法"状态

  3. 身份与标识
     持有聚合的唯一 ID(OrderId)
     外部对聚合的引用,只用 ID,不持有对象

  4. 领域事件的发布者
     完成一次合法修改后,发出领域事件
     (例:OrderPlaced)供外部订阅

四、聚合设计四原则

这是整篇的核心,背下来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
原则 1:尽量设计小聚合
  错:把 User + 所有 Order + 所有 Review 塞进"用户聚合"
    → 改一个评论要锁整个用户,并发灾难
  对:聚合只包含"必须一起变更"的对象
    Order 聚合 = Order + OrderItem;Review 单独一个聚合
  越小越好,一个聚合根 + 少量子对象是理想形态。

原则 2:跨聚合引用,只引用 ID
  Order 持有 CustomerId,不持有 Customer 对象
  → 解耦,避免加载一个聚合时拖出整张对象图
  → 需要客户信息?另查,或读侧投影(第五篇 CQRS 讲)

原则 3:一个事务只修改一个聚合
  跨聚合的变更 → 用领域事件 + 最终一致,不在一个事务里硬保
  → 这就是微服务系列第五篇 Saga / 事件驱动的由来
  → 聚合边界 = 事务边界 = 一致性边界(三位一体)

原则 4:内部强一致,跨聚合最终一致
  聚合内:规则立即满足(强一致)
  聚合间:通过事件异步对账(最终一致)

五、怎么划聚合边界(试金石)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
判断一组对象是不是该在一个聚合里,用这个问题:

  "这几个对象,是否必须在一个事务里一起改,才能保证正确?"

  是 → 同一个聚合
  否 → 拆成不同聚合,用事件联动

  举例验证:
    Order 和 OrderItem:
      改订单项数量,必须同时重算订单总价 → 同一聚合 ✅
    Order 和 Customer:
      下单时记录客户即可,改订单不影响客户 → 不同聚合(用 CustomerId)✅
    Order 和 Inventory:
      下单要扣库存,但"扣库存"是库存聚合自己的事
      → 不同聚合,用 OrderPlaced 事件通知库存聚合 ✅
    Order 和 Payment:
      支付是独立的,订单只关心"是否已支付"
      → 不同聚合,事件联动 ✅
1
2
3
4
5
一个常被问的问题:订单项算实体还是聚合?
  OrderItem 有自己的 ID,是"实体",
  但它离开订单没有意义(没有"游离的订单项"),
  所以它不是独立聚合,而是 Order 聚合的内部实体。
  → 有 ID ≠ 是聚合。是否独立可被外部直接访问,才决定它是不是聚合根。

六、聚合的加载与持久化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
聚合作为一个整体被存取:

  加载:一次把整个聚合(聚合根 + 子对象)从库里捞出来
  保存:一次把整个聚合写回

  仓储(Repository)按"聚合根"建,不按"表"建:
    IOrderRepository.Save(order)   // 存整个订单(含订单项)
    IOrderRepository.Find(orderId) // 取整个订单

  EF Core 里:一个聚合根对应一个 DbSet,子对象用 Owned / 导航属性一起加载。
  → 不要为 OrderItem 单独建仓储/DbSet(它不独立)。

七、并发控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
多个请求同时改一个聚合,怎么不互相覆盖?

  首选:乐观锁(版本号)
    聚合带一个 Version 字段
    UPDATE ... WHERE Id = ? AND Version = ?
    → 有人在 你 之后 改过?Version 变了,你的更新影响 0 行 → 冲突,重试或报错

  为什么乐观锁够用:
    聚合边界小,冲突概率低
    而且我们坚持"一事务一聚合",锁的范围本来就小

  EF Core:[Timestamp] / IsRowVersion() 自动生成乐观锁

八、完整 .NET 示例

把上面所有原则落到一个订单聚合上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 强类型 ID(避免基础类型误用)
public readonly record struct OrderId(Guid Value);
public readonly record struct ProductId(Guid Value);

// 聚合根
public class Order : Entity<OrderId>, IAggregateRoot
{
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
    public OrderStatus Status { get; private set; }
    public Address ShippingAddress { get; private set; }
    private int _version;          // 乐观锁版本号

    // 仓储加载/EF Core 用,不对外
    private Order(OrderId id, Address shipTo) : base(id)
    {
        Status = OrderStatus.Draft;
        ShippingAddress = shipTo;
    }

    // 工厂:创建即合法(保证不变量)
    public static Order Place(OrderId id, Address shipTo, IEnumerable<(ProductId, Money, int)> lines)
    {
        var order = new Order(id, shipTo);
        foreach (var (pid, price, qty) in lines)
            order.AddItem(pid, price, qty);   // 复用规则
        if (!order._items.Any())
            throw new DomainException("订单至少要有一项");
        order.Status = OrderStatus.Placed;
        return order;
    }

    // 所有修改入口:内部强制不变量
    public void AddItem(ProductId product, Money price, int qty)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("非草稿态不能加项");
        if (qty <= 0) throw new DomainException("数量必须 > 0");
        if (price.Amount <= 0) throw new DomainException("价格必须 > 0");

        var existing = _items.FirstOrDefault(i => i.ProductId == product);
        if (existing is not null) existing.Increase(qty);     // 内部实体也只暴露方法
        else _items.Add(new OrderItem(product, price, qty));
    }

    public void Cancel()
    {
        if (Status >= OrderStatus.Shipped)
            throw new DomainException("已发货不能取消");
        Status = OrderStatus.Cancelled;
        // 发领域事件(第四篇展开)
        AddDomainEvent(new OrderCancelled(Id));
    }

    // 派生值:不变量的体现,不存储
    public Money Total => _items.Aggregate(
        new Money(0m, _items[0].Price.Currency),
        (sum, i) => sum.Add(i.SubTotal));
}

// 内部实体:不独立、不对外,只通过聚合根改
public class OrderItem : Entity<Guid>
{
    public ProductId ProductId { get; }
    public Money Price { get; }
    public int Qty { get; private set; }
    public Money SubTotal => new(Price.Amount * Qty, Price.Currency);  // 派生

    internal void Increase(int n)   // internal,外部调不到
    {
        if (n <= 0) throw new DomainException("增量必须 > 0");
        Qty += n;
    }
}

public enum OrderStatus { Draft, Placed, Paid, Shipped, Cancelled }
1
2
3
4
5
6
7
8
这段代码体现了全部原则:
  - 聚合根守门:_items 私有,只暴露 AsReadOnly,外部改不了
  - 不变量:AddItem 内校验数量/价格/状态;Total 永远 = Σ小计
  - 工厂保证创建即合法(空订单建不出来)
  - 内部实体 OrderItem 的修改方法 internal,只能被聚合根调
  - 跨聚合用 ID:OrderId/ProductId,不持有对方对象
  - 领域事件:Cancel 后发 OrderCancelled,不直接去改别的聚合
  - 乐观锁:_version 字段

九、常见错误

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
错误 1:大聚合(God Aggregate)
  把所有相关对象塞一个聚合 → 锁大、并发差、加载慢
  → 回到原则 1:小聚合,跨聚合用 ID + 事件

错误 2:贫血聚合
  聚合根只有 getter/setter,规则写在 Service 里
  → 不变量形同虚设。规则必须在聚合根方法内。

错误 3:跨聚合事务
  为了"图省事",一个事务改订单+库存+积分
  → 破坏一致性边界,回到分布式事务地狱
  → 跨聚合坚决用最终一致

错误 4:直接暴露内部集合
  public List<OrderItem> Items { get; } → 外部 order.Items.Clear() 绕过规则
  → 返回 IReadOnlyCollection,或私有 + 显式方法

错误 5:按表建聚合/仓储
  OrderItem 单独建仓储 → 它不独立,违反"整体存取"
  → 仓储按聚合根建

十、小结

  • 聚合 = 一致性边界:把必须一起变更的对象圈起来,规则内部强制
  • 聚合根是唯一入口:守门、维护不变量、持身份、发事件
  • 四原则:小聚合、跨聚合用 ID、一事务一聚合、内部强一致跨聚合最终一致
  • 划界试金石:是否必须一个事务一起改?是→同聚合,否→拆开用事件
  • 持久化:按聚合根整体存取,仓储对应聚合根而非表
  • 并发:乐观锁(版本号)足够,因为坚持一事务一聚合
  • 五大错误:大聚合、贫血、跨聚合事务、暴露内部集合、按表建仓储

下一篇讲建模的基础积木——实体与值对象:怎么选、怎么用 .NET 实现、怎么映射到数据库。