1. 概述

在本教程中,我们将介绍如何使用 @DomainEvents 注解和 AbstractAggregateRoot 类,来方便地发布和处理由聚合(Aggregate)产生的领域事件(Domain Event)。聚合是领域驱动设计(DDD)中关键的战术模式之一。

聚合用于接收业务命令,这些命令通常会触发一个与业务相关的事件,即领域事件。

如果你对 DDD 和聚合还不太了解,建议先阅读 Eric Evans 的 原著。另外,Vaughn Vernon 写的 Effective Aggregate Design 系列文章也非常值得一读。

手动处理领域事件往往比较繁琐。幸运的是,Spring Framework 提供了便捷的机制,在使用数据仓库操作聚合根时,可以轻松地发布和监听领域事件

2. Maven 依赖

Spring Data 在 Ingalls 发布版本中引入了 @DomainEvents 注解,它适用于所有类型的 Repository。

本文中的代码示例使用的是 Spring Data JPA。最简单的集成方式是使用 Spring Boot Data JPA Starter

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

3. 手动发布事件

首先,我们尝试手动发布领域事件。在下一节中,我们将介绍 @DomainEvents 的使用方式。

为了演示需要,我们使用一个空的标记类作为领域事件 —— DomainEvent

我们使用标准的 ApplicationEventPublisher 接口来发布事件。

发布事件有两个常见的位置:服务层或聚合内部。

3.1. 服务层发布事件

可以在服务方法中调用 Repository 的 save 方法之后,手动发布事件

如果服务方法处于事务中,并且事件监听器使用了 @TransactionalEventListener 注解,那么事件将在事务成功提交后才被处理。

✅ 这样可以避免在事务回滚时处理“虚假”事件,保证系统一致性:

@Service
public class DomainService {
 
    // ...
    @Transactional
    public void serviceDomainOperation(long entityId) {
        repository.findById(entityId)
            .ifPresent(entity -> {
                entity.domainOperation();
                repository.save(entity);
                eventPublisher.publishEvent(new DomainEvent());
            });
    }
}

✅ 下面是一个测试,证明事件确实被发布了:

@DisplayName("给定一个已存在的聚合,"
    + " 当在服务中执行领域操作时,"
    + " 领域事件被成功发布")
@Test
void serviceEventsTest() {
    Aggregate existingDomainEntity = new Aggregate(1, eventPublisher);
    repository.save(existingDomainEntity);

    // when
    domainService.serviceDomainOperation(existingDomainEntity.getId());

    // then
    verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

3.2. 聚合内部发布事件

也可以在聚合内部直接发布事件

这种方式更符合面向对象的设计理念,事件的创建由聚合自己管理:

@Entity
class Aggregate {
    // ...
    void domainOperation() {
        // some business logic
        if (eventPublisher != null) {
            eventPublisher.publishEvent(new DomainEvent());
        }
    }
}

❌ 但这种方式可能不如预期那样工作,因为 Spring Data 在从 Repository 初始化实体时,不会自动注入 ApplicationEventPublisher

下面是对应的测试,展示了实际行为:

@DisplayName("给定一个已存在的聚合,"
    + " 当在其内部执行领域操作时,"
    + " 领域事件不会被发布")
@Test
void aggregateEventsTest() {
    Aggregate existingDomainEntity = new Aggregate(0, eventPublisher);
    repository.save(existingDomainEntity);

    // when
    repository.findById(existingDomainEntity.getId())
      .get()
      .domainOperation();

    // then
    verifyNoInteractions(eventHandler);
}

⚠️ 可以看到,事件根本就没有被发布。在聚合中注入依赖不是一个好主意,Spring Data 并不会自动帮你注入这些依赖。

聚合是通过默认构造函数实例化的,如果想让它正常工作,需要我们手动重建实体(比如使用自定义工厂或 AOP)。

⚠️ 此外,不要在聚合方法执行完后立即发布事件,除非你能 100% 确保该方法处于事务中。否则,可能会在变更尚未持久化时发布“虚假”事件,导致系统不一致。

✅ 因此,更好的做法是让聚合只负责管理事件集合,并在即将被持久化时返回这些事件。

在下一节中,我们将介绍如何使用 @DomainEvents@AfterDomainEventsPublication 注解来更优雅地管理事件发布。

4. 使用 @DomainEvents 发布事件

从 Spring Data Ingalls 版本开始,我们可以使用 @DomainEvents 注解来自动发布领域事件

当使用正确的 Repository 保存实体时,Spring Data 会自动调用带有 @DomainEvents 注解的方法,并将该方法返回的事件通过 ApplicationEventPublisher 接口进行发布:

@Entity
public class Aggregate2 {
 
    @Transient
    private final Collection<DomainEvent> domainEvents;
    // ...
    public void domainOperation() {
        // some domain operation
        domainEvents.add(new DomainEvent());
    }

    @DomainEvents
    public Collection<DomainEvent> events() {
        return domainEvents;
    }
}

✅ 下面是一个示例测试:

@DisplayName("给定带有 @DomainEvents 的聚合,"
    + " 当执行领域操作并保存时,"
    + " 事件被成功发布")
@Test
void domainEvents() {
 
    // given
    Aggregate2 aggregate = new Aggregate2();

    // when
    aggregate.domainOperation();
    repository.save(aggregate);

    // then
    verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

✅ 事件发布后,Spring Data 会调用带有 @AfterDomainEventsPublication 注解的方法。

这个方法通常用于清空事件列表,避免事件被重复发布:

@AfterDomainEventPublication
public void clearEvents() {
    domainEvents.clear();
}

✅ 将该方法加入 Aggregate2 类中,看看效果:

@DisplayName("给定带有 @AfterDomainEventPublication 的聚合,"
    + " 当执行领域操作并保存两次时,"
    + " 事件仅在第一次被发布")
@Test
void afterDomainEvents() {
 
    // given
    Aggregate2 aggregate = new Aggregate2();

    // when
    aggregate.domainOperation();
    repository.save(aggregate);
    repository.save(aggregate);

    // then
    verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

✅ 可以看到,事件只在第一次保存时被发布。如果去掉 @AfterDomainEventsPublication 注解,事件会在第二次保存时再次被发布

⚠️ 但是否真的会再次发布,取决于你的实现逻辑。Spring 只保证调用该方法,其他逻辑需要开发者自行处理。

5. 使用 AbstractAggregateRoot 模板类

借助 AbstractAggregateRoot 模板类,我们可以进一步简化事件发布流程

只需要在需要添加事件时调用 registerEvent 方法即可:

@Entity
public class Aggregate3 extends AbstractAggregateRoot<Aggregate3> {
    // ...
    public void domainOperation() {
        // some domain operation
        registerEvent(new DomainEvent());
    }
}

✅ 这是上一节示例的简化版本。

下面是两个测试用例验证其行为:

@DisplayName("给定继承 AbstractAggregateRoot 的聚合,"
    + " 当执行领域操作并保存两次时,"
    + " 事件仅在第一次被发布")
@Test
void afterDomainEvents() {
 
    // given
    Aggregate3 aggregate = new Aggregate3();

    // when
    aggregate.domainOperation();
    repository.save(aggregate);
    repository.save(aggregate);

    // then
    verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

@DisplayName("给定继承 AbstractAggregateRoot 的聚合,"
    + " 当执行领域操作并保存时,"
    + " 事件被成功发布")
@Test
void domainEvents() {
    // given
    Aggregate3 aggregate = new Aggregate3();

    // when
    aggregate.domainOperation();
    repository.save(aggregate);

    // then
    verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

✅ 可以看到,使用模板类可以大幅减少代码量,同时实现相同的功能。

6. 实现注意事项

虽然 @DomainEvents 看起来很好用,但也有几个需要注意的“坑”。

6.1. 事件未发布

在使用 JPA 时,我们并不总是需要显式调用 save 方法来持久化变更。

✅ 如果我们的代码处于事务中(比如加了 @Transactional 注解),并且修改了已有实体,那么通常只需让事务自动提交即可,不需要手动调用 save

⚠️ 这意味着即使聚合产生了新的事件,它们也不会被自动发布。

✅ 此外,@DomainEvents 机制仅在使用 Spring Data Repository 时生效。这是设计时必须考虑的因素。

6.2. 事件丢失

如果在事件发布过程中发生异常,监听器将永远不会被通知

即使我们能设法确保监听器被通知,目前也没有回压机制让发布者知道事件处理失败。如果监听器因异常中断,事件将永远丢失。

⚠️ 这是 Spring 开发团队已知的设计缺陷。有核心开发者曾提出过一个 解决方案

6.3. 本地上下文

领域事件默认通过 ApplicationEventPublisher 在同一个线程中发布和消费。

✅ 通常我们希望将事件通过消息中间件(如 RabbitMQ、Kafka)广播给其他系统。

在这种情况下,我们需要手动将事件转发到消息代理。也可以使用 Spring Integration 或第三方方案,如 Apache Camel

7. 总结

在本文中,我们学习了如何使用 @DomainEvents 注解管理聚合中的领域事件。

✅ 这种方式可以大大简化事件基础设施的实现,让我们更专注于业务逻辑本身。

⚠️ 但要注意,Spring 的领域事件机制并非完美方案,实际使用时仍需谨慎。

所有示例代码可在 GitHub 上获取:https://github.com/eugenp/tutorials/tree/master/persistence-modules/spring-data-jpa-annotations


原始标题:DDD aggregates and @DomainEvents