领域驱动设计(四):领域事件与事件溯源——用事件驱动业务

写在前面

聚合设计有个铁律:一个事务只改一个聚合。那"下单后要扣库存、加积分、发通知"这种跨聚合的联动怎么办?答案就是——领域事件(Domain Event)

聚合做完自己的事,发一个事件声明"发生了什么",至于谁来响应、怎么响应,它一概不管。这种解耦是 DDD 和微服务架构的黏合剂。这篇讲领域事件,并引出它的进阶玩法——事件溯源(Event Sourcing)

这篇和微服务系列第五篇(数据一致性)是一对:那篇讲"事件如何可靠跨服务投递(Outbox)",这篇讲"事件本身怎么设计、怎么用"。


一、领域事件是什么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
领域事件:领域中"已经发生、业务关心"的事。

  特征:
    - 过去式命名(OrderPlaced、PaymentReceived、ItemShipped)
      → "已下单""已付款""已发货",是事实,不是请求
    - 不可变(发生过的事不能改)
    - 自描述(带够重建上下文所需的信息)

  价值:解耦
    聚合 A 完成变更 → 发事件
    聚合 B/C/D 各自订阅,独立响应
    A 不需要知道有谁在听、有几个、在哪
1
2
3
4
5
6
7
为什么用"过去式":
  OrderPlaced(订单已创建)—— 事实,谁都不能撤销
  PlaceOrder(去下单)—— 这是"命令",不是事件

  事件 = 已经发生的客观事实 → 订阅者只能反应,不能拒绝
  命令 = 要求做某事 → 可以被拒绝(余额不足、库存不够)
  两者别混(下一节细讲)。

二、事件 vs 命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
命令(Command):表达"意图",可被拒绝
  名字:动词原形 / 祈使(PlaceOrder、CancelOrder)
  方向:外部 → 领域
  结果:可能成功,可能失败(业务规则不允许)
  例:用户点"下单" → 发 PlaceOrder 命令 → 系统校验,成功则订单创建

事件(Event):表达"事实",不可拒绝
  名字:过去式(OrderPlaced、OrderCancelled)
  方向:领域 → 外部
  结果:已成事实,订阅者只能据此反应
  例:订单创建后 → 发 OrderPlaced → 库存扣减、积分、通知据此触发

  链条:
    命令 PlaceOrder →(校验通过)→ 订单创建 + 事件 OrderPlaced
    → 订阅者据此发新命令(如 ReserveStock)→ 成功 + StockReserved
    → ... 一个业务的完整流程 = 命令与事件交替推进

三、领域事件的设计

 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
// 领域事件基类
public interface IDomainEvent
{
    DateTime OccurredOn { get; }
}

// 具体事件:过去式 + 自描述(带够订阅者需要的信息)
public sealed record OrderPlaced(
    OrderId OrderId,
    CustomerId CustomerId,
    IReadOnlyCollection<OrderLine> Lines,
    Address ShippingAddress,
    Money Total,
    DateTime OccurredOn
) : IDomainEvent;

public sealed record OrderCancelled(OrderId OrderId, string Reason, DateTime OccurredOn)
    : IDomainEvent;

// 聚合根内:变更完成后"收集"事件,由外部统一分发
public class Order : Entity<OrderId>, IAggregateRoot
{
    private readonly List<IDomainEvent> _events = new();
    public IReadOnlyCollection<IDomainEvent> DequeueEvents()
    { var e = _events.ToList(); _events.Clear(); return e; }

    public void Cancel(string reason)
    {
        if (Status >= OrderStatus.Shipped)
            throw new DomainException("已发货不能取消");
        Status = OrderStatus.Cancelled;
        _events.Add(new OrderCancelled(Id, reason, DateTime.UtcNow));  // 只收集,不直接处理
    }
}
1
2
3
4
5
6
设计要点:
  1. 事件是 record(不可变)+ 过去式命名
  2. 带够信息:订阅者不该为了处理事件再去回查聚合
     (但也不必塞全部字段,按订阅者需要)
  3. 聚合只"收集"事件,不直接处理(保持聚合纯净、可测)
  4. 事件的分发由应用层统一做(下一节)

四、进程内事件分发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 应用层:保存聚合后,把收集到的事件分发出去
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IDomainEventDispatcher _dispatcher;

    public async Task CancelAsync(OrderId id, string reason, CancellationToken ct)
    {
        var order = await _repo.Find(id, ct) ?? throw new NotFoundException();
        order.Cancel(reason);                 // 聚合内变更 + 收集事件
        await _repo.Save(order, ct);          // 先持久化(保证数据落库)

        foreach (var evt in order.DequeueEvents())
            await _dispatcher.DispatchAsync(evt, ct);  // 再分发
    }
}

// 分发器:可以用 MediatR(进程内发布/订阅的标准选择)
public sealed record OrderCancelledHandler(IInventoryService Inventory)
    : INotificationHandler<OrderCancelledNotification>
{
    public Task Handle(OrderCancelledNotification n, CancellationToken ct)
        => Inventory.ReleaseAsync(n.OrderId, ct);  // 库存释放
}
1
2
3
4
5
关键顺序:先存库,再分发
  如果先分发再存库:事件处理了,但聚合没存上 → 不一致
  所以一定 先 Save 聚合 → 再 Dispatch 事件

  进程内的同步分发,简单可靠,但有个隐患(下一节)。

五、可靠投递:跨进程用 Outbox

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
进程内分发的隐患:
  聚合存了 ✅ → 分发事件时崩了 ❌
  → 库存没释放、积分没加,事件丢了

  跨服务(微服务)更严重:事件要发到消息队列,队列和网络都可能失败。

  解决方案:Outbox 模式(微服务系列第五篇讲过)
    1. 聚合变更 + 事件记录,写进同一个数据库事务(原子)
    2. 一个独立 worker 轮询 outbox 表,把事件投递到消息队列
    3. 投递成功标记已发送;失败重试

  → 业务操作和事件发布,获得 ACID 级别的原子性。
  → 跨服务的领域事件,必须走 Outbox,否则一定会丢。

六、事件溯源(Event Sourcing)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
传统持久化:存"当前状态"
  订单表:status=Shipped, total=299
  → 只知道"现在是什么",历史丢了(怎么变到这一步的?不知道)

事件溯源:存"导致状态变化的所有事件"
  事件流:
    OrderPlaced(total=299)
    PaymentReceived(amount=299)
    OrderShipped(carrier=SF)
  → "当前状态" = 把事件流从头"重放"计算出来

  订单的当前状态,就是按顺序应用这些事件得到的结果。
  完整历史永久保留:能回溯到任意时间点、能审计、能做时态查询。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
事件溯源的好处:
  ✓ 完整审计轨迹(发生了什么,一清二楚)
  ✓ 时态查询("上周三这个订单是什么状态?")
  ✓ 和 DDD 天然契合(领域事件本就是"发生的事")
  ✓ 模型与存储解耦(存事件,状态是派生的)

事件溯源的代价:
  ✗ 复杂度飙升(重放、快照、版本演进、并发)
  ✗ 查询难(要查"当前所有已发货订单"得重放全部 → 需要读侧投影,第五篇 CQRS)
  ✗ 事件 schema 演进困难(老事件怎么重放?)

  结论:大多数业务不需要事件溯源。
       只在"强审计/合规/时态"需求(金融、计费、医疗)才值得上。
       别因为"看起来高级"就用。

6.1 一个最小示意

 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
// 事件流持久化(伪代码)
public interface IEventStore
{
    Task AppendAsync(OrderId id, IEnumerable<IDomainEvent> events, int expectedVersion, CancellationToken ct);
    Task<IReadOnlyList<IDomainEvent>> LoadAsync(OrderId id, CancellationToken ct);
}

// 重建聚合:重放事件流
public class OrderRepository
{
    private readonly IEventStore _store;
    public async Task<Order?> Find(OrderId id, CancellationToken ct)
    {
        var events = await _store.LoadAsync(id, ct);
        if (events.Count == 0) return null;
        var order = new Order();                       // 空聚合
        foreach (var e in events) order.Apply(e);      // 逐个重放
        return order;
    }
}

// 聚合内的 Apply:根据事件设置状态(不校验,因为是已发生的事实)
public class Order
{
    public void Apply(IDomainEvent e)
    {
        switch (e)
        {
            case OrderPlaced p: Id = p.OrderId; Status = OrderStatus.Placed; Total = p.Total; break;
            case PaymentReceived: Status = OrderStatus.Paid; break;
            case OrderShipped: Status = OrderStatus.Shipped; break;
            case OrderCancelled: Status = OrderStatus.Cancelled; break;
        }
    }
}
1
2
3
4
快照(Snapshot):重放太慢的优化
  每 N 个事件存一个"状态快照"
  重建时:从最近快照开始 + 重放之后的少量事件
  → 长事件流的聚合才需要,先别过度设计。

七、何时用领域事件 / 事件溯源

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
领域事件:强烈推荐,几乎必用
  ✓ 跨聚合解耦(聚合不直接调聚合)
  ✓ 副作用隔离(通知、积分、统计 = 订阅者,不污染核心)
  ✓ 跨服务异步协作(配 Outbox)

事件溯源:谨慎,按需
  ✓ 金融/计费/医疗等强审计场景
  ✗ 普通 CRUD 业务(用状态持久化就够)
  ✗ 团队没准备好应对复杂度

  事件 ≠ 事件溯源。
  用领域事件解耦,是基本操作;
  用事件溯源存状态,是高级选项,别混为一谈。

八、小结

  • 领域事件:已发生的事实,过去式命名,不可变,自描述
  • 事件 vs 命令:命令=意图可拒绝(外部→领域);事件=事实不可拒绝(领域→外部)
  • 设计:事件用 record,带够信息;聚合只"收集"事件,分发交给应用层
  • 顺序:先持久化聚合,再分发事件(保证一致)
  • 可靠投递:跨进程/跨服务必须走 Outbox(业务与事件同事务)
  • 事件溯源:存事件流而非当前状态;审计强但复杂度高,按需采用
  • 关键认知:领域事件(解耦,必用)≠ 事件溯源(存状态,慎用)

下一篇讲 CQRS——读写分离架构,和领域事件/事件溯源天然搭档。