Рубрики
Без рубрики

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

Узнайте о событиях домена в Spring Data

Автор оригинала: Mike Wojtyna.

1. Обзор

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

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

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

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

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

Весенние данные введены @DomainEvents в Ingalls release train. Он доступен для любого хранилища.

Примеры кода, представленные в этой статье, используют Spring Data JPA. Самый простой способ интегрировать события домена Spring в наш проект-использовать Spring Boot Data JPA Starter :


    org.springframework.boot
    spring-boot-starter-data-jpa

3. Публикуйте События Вручную

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

Для нужд этой статьи мы будем использовать пустой класс маркеров для событий домена – DomainEvent .

Мы будем использовать стандартный интерфейс ApplicationEventPublisher .

Есть два хороших места, где мы можем публиковать события: уровень сервиса или непосредственно внутри агрегата .

3.1. Уровень обслуживания

Мы можем просто публиковать события после вызова метода репозитория 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("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. Совокупность

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

Таким образом, мы управляем созданием доменных событий внутри класса, что кажется более естественным для этого:

@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
    verifyZeroInteractions(eventHandler);
}

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

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

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

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

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

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

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

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

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

Затем события, возвращаемые этим методом, публикуются с помощью интерфейса ApplicationEventPublisher :

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

    @DomainEvents
    public Collection 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();
}

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

@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 template . Все, что нам нужно сделать, это вызвать метод register , когда мы хотим добавить новое событие домена в коллекцию событий:

@Entity
public class Aggregate3 extends AbstractAggregateRoot {
    // ...
    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 ) и вносит изменения в существующую сущность, то мы обычно просто позволяем транзакции фиксироваться без явного вызова метода save в репозитории. Таким образом, даже если наш агрегат создал новые доменные события, они никогда не будут опубликованы.

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

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

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

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

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

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

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

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

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

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

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

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

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

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