写在前面
聚合设计有个铁律:一个事务只改一个聚合。那"下单后要扣库存、加积分、发通知"这种跨聚合的联动怎么办?答案就是——领域事件(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——读写分离架构,和领域事件/事件溯源天然搭档。