DDD 实战:关于领域事件(Domain Events)的设计抉择与工程落地思考

在领域驱动设计(DDD)的战术落地过程中,领域事件(Domain Event) 是连接各个子域、聚合以及限界上下文的“胶水”。它不仅解耦了复杂的业务逻辑,更是实现“最终一致性”架构的关键手段。

本文基于实际开发经验,总结领域事件的建模、生成、发布及可靠性投递的最佳实践,并探讨不同方案背后的权衡。


一、 什么是领域事件?

简单来说,领域事件是聚合内已发生的业务事实

  • 业务事实:意味着它是过去式。比如“用户已注册”、“订单已支付”。
  • 命名规范:推荐使用 动词过去式(如 OrderPaid, AccountActivated)。
  • 价值
    1. 解耦:核心业务逻辑不需要知道谁在关注它。
    2. 副作用处理:触发通知、大数据分析、报表生成等非核心逻辑。
    3. 数据一致性:跨聚合、跨服务的状态同步。

二、 建模:胖消息 vs 瘦消息

领域事件通常被建模为不可变的值对象(Value Object)。但在设计消息体(Payload)时,我们面临一个经典抉择:

1. 瘦消息(Id-Only)

消息体仅包含最基础的元数据:

1
2
3
4
5
6
{
"eventType": "MobileChanged",
"entityId": "123456",
"eventId": "uuid-001",
"eventTime": "1654156165"
}

  • 优点:消息极小,传输快。
  • 缺点:消费者(Subscriber)必须拿着 entityId 反查聚合根信息,导致读放大

2. 胖消息(Event Enrichment / 事件增强)

消息体包含消费者所需的业务数据:

1
2
3
4
5
6
{
"eventType": "MobileChanged",
...
"beforeMobile": "13800000000",
"afterMobile": "18100000000" // 增强字段
}

  • 优点:消费者完全自治,无需反查,降低源端系统压力。
  • 缺点:消息体较大;可能包含消费者不应关心的敏感数据;存在数据时效性问题(如果消费者处理慢,消息里的数据可能是旧的)。

💡 思考与建议
在微服务架构下,为了减少跨服务调用带来的耦合与延迟,推荐适度的“事件增强”。即:包含消费者处理业务所必须的核心字段,而不是把整个聚合根都塞进去。


三、 生成:如何优雅地创建事件?

在代码层面,何处创建、何处发布事件,直接影响代码的整洁度(Clean Code)。我们对比四种常见方案:

方案描述评价建议
1. Service层创建业务执行完后,手动 new Event() 并发布简单粗暴。适合贫血模型,但容易导致Service层逻辑膨胀,容易遗漏。⭐️⭐️
2. 聚合根依赖Infra聚合根注入 Publisher,内部直接发布反模式。聚合根不应依赖基础设施,破坏了POJO的纯净性,且导致单一职责问题。🚫
3. 聚合根返回事件方法签名改为 List<Event> doSomething()侵入性强。强行改变了业务方法签名,调用方处理繁琐。⭐️
4. 聚合根暂存 + 抽取聚合根内部 List 暂存,Repo保存时抽取最佳实践。保证了聚合根的纯净,同时利用基础设施层自动化处理发布,业务无感知。⭐️⭐️⭐️⭐️

推荐方案代码示例

抽象聚合根基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class AbstractAggregateRoot {
// 暂存事件,不持久化到业务表
@Transient
private List<DomainEvent> domainEvents = new ArrayList<>();

protected void registerEvent(DomainEvent event) {
this.domainEvents.add(event);
}

// 提供给基础设施层调用
public List<DomainEvent> pollEvents() {
List<DomainEvent> events = new ArrayList<>(this.domainEvents);
this.domainEvents.clear();
return events;
}
}

业务聚合根:

1
2
3
4
5
6
7
public class User extends AbstractAggregateRoot {
public void changeMobile(String newMobile) {
this.mobile = newMobile;
// 仅仅是注册,不涉及IO操作
registerEvent(new MobileChangedEvent(this.id, newMobile));
}
}


四、 可靠投递:分布式事务的噩梦

最核心的工程难题在于:Database保存MQ消息发送 是两个独立的操作。
如果 DB 提交了,但 MQ 挂了(或者反过来),就会导致数据不一致。

为了解决这个问题,我们必须引入本地消息表(Transactional Outbox Pattern)

1. 核心机制

利用关系型数据库的本地事务(ACID),将“业务数据保存”和“事件数据保存”放在同一个事务中提交。

  • Step 1: 开启事务。
  • Step 2: Update 业务表。
  • Step 3: Insert into t_event (状态:PENDING)。
  • Step 4: 提交事务。

此时,业务操作和事件记录要么同时成功,要么同时失败。

2. 发送与补偿策略

保存到 t_event 后,如何发到 MQ?

  • 方案 A:应用直接发 + 轮询补偿(适合中小规模)
    • 应用在事务提交后,直接异步发送 MQ。发送成功则更新 t_eventPUBLISHED
    • 后台启动定时任务(Worker),扫描 t_event 中超过 1 分钟仍为 PENDING 的记录,进行重试。
    • 缺点:增加了数据库查询压力。
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 ApplicationService {
@Resource
private EventJdbcRepository eventJdbcRepository;

public void doBusiness (Command cmd) {
AggregateRoot root = repository.load(bizId);
entity.doBusiness(cmd.getValue());
repository.save(root);
// 发布领域事件
List<DomainEvent> domainEvents = entity.getDomainEvents();
publisher.publish(domainEvents);
// 通过事件的EntityId更新EventStore中事件的状态为已发布
List<String> eventIds = domainEvents.stream()
.map(e->e.getEventId())
.collect(Collectors.toList());
eventJdbcRepository.publishSuccess(eventIds);
}
public class Task {
// TODO 1.扫描数据库超时未发布成功的领域事件
// TODO 2.发布领域事件到消息中间件
// TODO 3.修改数据库领域事件发布状态为已发布
}
}

  • 方案 B:事务日志拖尾 / CDC(适合大规模)
    • 应用只管写库,不发消息。
    • 利用 Canal / Debezium 监听数据库的 Binlog。
    • CDC 组件解析 Binlog 中的 t_event 插入记录,自动投递到 MQ。
    • 优点:应用层完全解耦,性能极高。

五、 订阅:归属于哪一层?

在 DDD 的分层架构中,事件订阅者(Subscriber/Listener)应该放在哪里?

结论:用户接口层(User Interface / Adapters Layer)。

  • 理由:订阅 MQ 消息本质上和 Controller 接收 HTTP 请求是一样的。它们都是外部世界的输入(Input)。
  • 流程
    1. Subscriber 接收 JSON 消息。
    2. 反序列化为 DTO。
    3. 调用 Application Service 执行具体的业务命令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    /**
* 领域事件订阅者
*/
@Component
public class DomainEventSubscriber {
@Resource
private ApplicationService applicationService;

@KafkaListener(topics = "domain_event_topic",groupId = "local_consumer_group_id")
public void subscribe (String event) {
// 解析得到领域事件
DomainEvent domainEvent = JSON.parse(event,DomainEvent.class);
// 拼装Command
Command command = this.toCommand(domainEvent);
// 应用层执行领域模型状态变更
applicationService.handleCommand(command);
}
}

幂等性(Idempotency)

由于网络抖动或重试机制,消费者必然会收到重复消息。必须利用 event_id 建立唯一索引或去重表,确保同一业务逻辑不执行两次。


六、 总结

领域事件的设计不仅仅是技术实现,更是一种将业务流程显性化的手段。

  1. 设计上:优先选择 “聚合根暂存 + 基础设施抽取” 的生成模式,保持领域纯净。
  2. 数据上:权衡消息大小,推荐使用 增强型事件 减少下游依赖。
  3. 架构上:必须正视分布式一致性问题,本地消息表 是实现可靠投递的基石。

DDD 的路途虽远,但只要处理好领域事件,系统的解耦与演进就会变得从容许多。