Перейти к основному содержимому

Агрегаты DDD и @DomainEvents

· 8 мин. чтения

1. Обзор

В этом руководстве мы объясним, как использовать аннотацию @DomainEvents и класс AbstractAggregateRoot для удобной публикации и обработки доменных событий, создаваемых агрегатом — одним из ключевых тактических шаблонов проектирования в доменно-ориентированном проектировании.

Агрегаты принимают бизнес-команды, что обычно приводит к созданию события, связанного с бизнес-сферой, — события домена .

Если вы хотите узнать больше о DDD и агрегатах, лучше всего начать с оригинальной книги Эрика Эванса . Есть также отличная серия об эффективном дизайне заполнителей, написанная Воном Верноном. Определенно стоит прочитать.

Работать с событиями предметной области вручную может быть обременительно. К счастью, Spring Framework позволяет нам легко публиковать и обрабатывать события предметной области при работе с совокупными корнями с использованием репозиториев данных.

2. Зависимости Maven

Spring Data представила @DomainEvents в выпуске Ingalls. Он доступен для любого репозитория.

Образцы кода, предоставленные для этой статьи, используют Spring Data JPA. Самый простой способ интегрировать события домена Spring в наш проект — использовать 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. Сервисный уровень

Мы можем просто публиковать события после вызова метода сохранения репозитория внутри метода службы .

Если метод службы является частью транзакции, и мы обрабатываем события внутри прослушивателя, аннотированного @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());
});
}
}

Вот тест, который доказывает, что события действительно публикуются службой DomainOperation :

@DisplayName("given existing aggregate,"
+ " when do domain operation on service,"
+ " then domain event is published")
@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 инициализирует сущности из репозиториев.

Вот соответствующий тест, который показывает реальное поведение:

@DisplayName("given existing aggregate,"
+ " when do domain operation directly on aggregate,"
+ " then domain event is NOT published")
@Test
void aggregateEventsTest() {
Aggregate existingDomainEntity = new Aggregate(0, eventPublisher);
repository.save(existingDomainEntity);

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

// then
verifyNoInteractions(eventHandler);
}

Как мы видим, событие вообще не публикуется. Наличие зависимостей внутри агрегата может быть плохой идеей. В этом примере ApplicationEventPublisher не инициализируется Spring Data автоматически.

Агрегат создается путем вызова конструктора по умолчанию. Чтобы заставить его вести себя так, как мы ожидаем, нам нужно будет вручную воссоздать объекты (например, используя пользовательские фабрики или программирование аспектов).

Кроме того, нам следует избегать публикации событий сразу после завершения агрегатного метода. По крайней мере, если мы не уверены на 100%, что этот метод является частью транзакции. В противном случае у нас могут быть опубликованы «ложные» события, когда изменение еще не сохранено. Это может привести к несогласованности в системе.

Если мы хотим избежать этого, мы должны помнить, что всегда должны вызывать агрегатные методы внутри транзакции. К сожалению, таким образом мы сильно связываем наш дизайн с технологией персистентности. Мы должны помнить, что мы не всегда работаем с транзакционными системами.

Поэтому, как правило, лучше позволить нашему агрегату просто управлять набором событий предметной области и возвращать их, когда он собирается быть сохраненным .

В следующем разделе мы объясним, как сделать публикацию доменных событий более управляемой с помощью аннотаций @DomainEvents и @AfterDomainEvents .

4. Публикуйте события с помощью @DomainEvents

С момента выпуска поезда Spring Data Ingalls мы можем использовать аннотацию @DomainEvents для автоматической публикации доменных событий .

Метод, аннотированный @DomainEvents , автоматически вызывается Spring Data всякий раз, когда объект сохраняется с использованием правильного репозитория.

Затем события, возвращаемые этим методом, публикуются с помощью интерфейса 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("given aggregate with @DomainEvents,"
+ " when do domain operation and save,"
+ " then event is published")
@Test
void domainEvents() {

// given
Aggregate2 aggregate = new Aggregate2();

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

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

После публикации доменных событий вызывается метод с аннотацией @AfterDomainEventsPublication .

Целью этого метода обычно является очистка списка всех событий, чтобы они не публиковались снова в будущем:

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

Давайте добавим этот метод в класс Aggregate2 и посмотрим, как он работает:

@DisplayName("given aggregate with @AfterDomainEventPublication,"
+ " when do domain operation and save twice,"
+ " then an event is published only for the first time")
@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));
}

Мы ясно видим, что событие публикуется только в первый раз. Если бы мы убрали аннотацию @AfterDomainEventPublication из метода clearEvents , то это же событие было бы опубликовано во второй раз .

Однако то, что произойдет на самом деле, зависит от разработчика. Spring только гарантирует вызов этого метода — и ничего больше.

5. Используйте шаблон AbstractAggregateRoot

Можно еще больше упростить публикацию событий предметной области благодаря шаблонному классу AbstractAggregateRoot . Все, что нам нужно сделать, это вызвать метод register , когда мы хотим добавить событие нового домена в коллекцию событий:

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

Это аналог примера, показанного в предыдущем разделе.

Просто чтобы убедиться, что все работает как положено — вот тесты:

@DisplayName("given aggregate extending AbstractAggregateRoot,"
+ " when do domain operation and save twice,"
+ " then an event is published only for the first time")
@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("given aggregate extending AbstractAggregateRoot,"
+ " when do domain operation and save,"
+ " then an event is published")
@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 мы не обязательно вызываем метод сохранения, когда хотим сохранить изменения.

Если наш код является частью транзакции (например, с аннотацией @Transactional ) и вносит изменения в существующую сущность, то обычно мы просто разрешаем транзакцию фиксироваться без явного вызова метода сохранения в репозитории. Таким образом, даже если наша совокупность произвела новые события предметной области, они никогда не будут опубликованы.

Мы также должны помнить, что функция @DomainEvents работает только при использовании репозиториев Spring Data . Это может быть важным фактором дизайна.

6.2. Потерянные события

Если во время публикации событий возникнет исключение, слушатели просто не получат уведомления .

Даже если бы мы могли каким-то образом гарантировать уведомление прослушивателей событий, в настоящее время нет противодействия, чтобы сообщить издателям, что что-то пошло не так. Если прослушиватель событий будет прерван исключением, событие останется неиспользованным и больше никогда не будет опубликовано.

Этот недостаток дизайна известен команде разработчиков Spring. Один из ведущих разработчиков даже предложил возможное решение этой проблемы .

6.3. Местный контекст

События предметной области публикуются с помощью простого интерфейса ApplicationEventPublisher .

По умолчанию при использовании ApplicationEventPublisher события публикуются и потребляются в одном потоке . Все происходит в одном контейнере.

Обычно мы хотим отправлять события через какой-либо брокер сообщений, чтобы другие распределенные клиенты/системы получали уведомления. В таком случае нам нужно будет вручную пересылать события брокеру сообщений.

Также можно использовать Spring Integration или сторонние решения, такие как Apache Camel .

7. Заключение

В этой статье мы узнали, как управлять совокупными событиями домена с помощью аннотации @DomainEvents .

Этот подход может значительно упростить инфраструктуру событий, поэтому мы можем сосредоточиться только на логике предметной области . Нам просто нужно знать, что серебряной пули не существует, и то, как Spring обрабатывает события предметной области, не является исключением.

Полный исходный код всех примеров доступен на GitHub .