Spring Data - сила доменных событий

Доменное событие (Domain Event)  —  одна из идей Domain Driven Design. Как только вы познакомитесь с этой техникой, то больше не захотите обходиться без нее. Итак, в этой статье я покажу пример разработки приложения. Мы пройдем процесс шаг за шагом по мере поступления новых требований. Это должно дать четкое представление о ценности доменных событий.

Наш стек  —  это Java 11 + Spring Boot + Hibernate.

Допустим, мы создаем сервис по продаже книг. Авторы могут выставлять книги на продажу, а как клиенты  —  их покупать.

Определим основные бизнес-объекты. Саму книгу, Book.

@Entity
@Table
public class Book {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private String name;

private String description;

private OffsetDateTime dateCreated;

private OffsetDateTime lastDateUpdated;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "author_id")
private Author author;

private int price;

@OneToMany(fetch = LAZY, mappedBy = "book", cascade = ALL)
private List<BookSale> bookSales = new ArrayList<>();

// геттеры, сеттеры
}

И класс продаж книг BookSale.

@Entity
@Table
public class BookSale {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private int priceSold;

private OffsetDateTime dateSold;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "book_id")
private Book book;

// геттеры, сеттеры
}

Экземпляр Book содержит название, автора, дату создания, последнюю дату обновления, цену и список всех продаж.

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

Бизнес-требования

Минимальная модель предметной области обоснована. Пришло время реализовать бизнес-требования.

1. Каждая продажа должна быть зарегистрирована

В этом вся идея системы.

Вот первая попытка.

@Service
public class BookSaleServiceImpl implements BookSaleService {
private final BookRepository bookRepository;
private final BookSaleRepository bookSaleRepository;

@Override
@Transactional
public void sellBook(Long bookId) {
final var book =
bookRepository.findById(bookId)
.orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
BookSale bookSale = new BookSale();
bookSale.setBook(book);
bookSale.setDateSold(OffsetDateTime.now());
bookSale.setPriceSold(book.getPrice());
bookSaleRepository.save(bookSale);
}
}

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

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

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

2. Автор должен получать уведомление о каждой сотне продаж своей книги

Нам нужно сообщить автору, что его книги продаются.

Как реализовать эту функцию? Что ж, наивный подход заключается в том, чтобы поместить функциональность в метод sellBook.

@Service
public class BookSaleServiceImpl implements BookSaleService {
private final BookRepository bookRepository;
private final BookSaleRepository bookSaleRepository;
private final EmailService emailService;

@Override
@Transactional
public void sellBook(Long bookId) {
final var book =
bookRepository.findById(bookId)
.orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
BookSale bookSale = new BookSale();
bookSale.setBook(book);
bookSale.setDateSold(OffsetDateTime.now());
bookSale.setPriceSold(book.getPrice());
bookSaleRepository.save(bookSale);

int totalSoldBooks = book.getBookSales().size();
if (totalSoldBooks % 100 == 0) {
Author author = book.getAuthor();
emailService.send(author.getEmail(), "Another 100 books of yours have been sold!");
}
}
}

Первое, что становится ясным,  —  транзакция все еще обрабатывается, когда вызывается метод EmailService.send. Во-первых, это снижение производительности. Во-вторых, существует вероятность того, что транзакция в конце концов будет отменена. В этом случае никаких электронных писем отправлять не надо.

Это можно исправить, применив программные транзакции.

@Service
public class BookSaleServiceImpl implements BookSaleService {
private final BookRepository bookRepository;
private final BookSaleRepository bookSaleRepository;
private final EmailService emailService;
private final TransactionTemplate transactionTemplate;

@Override
public void sellBook(Long bookId) {
final var savedBook = transactionTemplate.execute(status -> {
final var book =
bookRepository.findById(bookId)
.orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
BookSale bookSale = new BookSale();
bookSale.setBook(book);
bookSale.setDateSold(OffsetDateTime.now());
bookSale.setPriceSold(book.getPrice());
bookSaleRepository.save(bookSale);
return book;
});

int totalSoldBooks = savedBook.getBookSales().size();
if (totalSoldBooks % 100 == 0) {
Author author = savedBook.getAuthor();
emailService.send(author.getEmail(), "Another 100 books of your have been sold!");
}
}
}

Но одна проблема остается нерешенной. Такой подход нарушает принцип единственной ответственности (single-responsibility principle, SRP) и принцип открытости-закрытости (open-closed principle, OCP). Лучший вариант для нас  —  шаблон декоратора.

@Service
public class EmailNotifierBookSaleService implements BookSaleService {
@ActualBookSaleServiceQualifier
private final BookSaleService origin;
private final BookRepository bookRepository;
private final EmailService emailService;

@Override
public void sellBook(Long bookId) {
origin.sellBook(bookId);
final var savedBook = bookRepository.findById(bookId).orElseThrow();
int totalSoldBooks = savedBook.getBookSales().size();
if (totalSoldBooks % 100 == 0) {
Author author = savedBook.getAuthor();
emailService.send(author.getEmail(), "Another 100 books of your have been sold!");
}
}
}

EmailNotifierBookSaleService вводит интерфейс BookSaleService. В производственной среде это будет реализация BookSaleServiceImpl (аннотация @Qualifier указывает на нее). Но в тестовой среде мы сможем задействовать заглушку или макет.

Так и вправду намного лучше. Функциональность разделена между двумя сервисами. И каждый из них можно протестировать индивидуально.

3. Каждое обновление книги должно быть заархивировано

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

@Entity
@Table
public class BookArchive {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private String name;

private String description;

private int soldBooks;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
private Book book;

private OffsetDateTime dateArchiveCreated;

private OffsetDateTime lastDateVersionUpdated;

// геттеры, сеттеры
}

Как мы отслеживаем продажи книг? Мы могли бы добавить функциональность непосредственно в BookSaleServiceImpl, но мы уже указывали, что это плохой способ. Итак, еще один декоратор.

@Service
public class ArchiveBookSaleService implements BookSaleService {
@ActualBookSaleServiceQualifier
private final BookSaleService origin;
private final BookRepository bookRepository;
private final BookArchiveRepository bookArchiveRepository;

@Override
@Transactional
public void sellBook(Long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow();

BookArchive bookArchive = new BookArchive();
bookArchive.setBook(book);
bookArchive.setName(book.getName());
bookArchive.setDescription(book.getDescription());
bookArchive.setDateArchiveCreated(OffsetDateTime.now());
bookArchive.setLastDateVersionUpdated(requireNonNullElse(book.getLastDateUpdated(), book.getDateCreated()));
bookArchive.setSoldBooks(book.getBookSales().size());

bookArchiveRepository.save(bookArchive);

origin.sellBook(bookId);
}
}

Следует отметить некоторые важные детали.

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

Метод BookRepository.findById(id) вызывается два раза во время выполнения. Но поскольку существует транзакция, при втором вызове Hibernate возвращает кэшированный экземпляр из контекста сохранения. Таким образом, нет никаких дополнительных обходов базы данных.

Второй момент  —  это @ActualBookSaleServiceQualifier. EmailNotifierBookSaleService не запускает никаких транзакций. Это означает, что источник должен иметь тип BookSaleServiceImpl. Поэтому, чтобы не вводить BookSaleServiceImpl дважды, необходимо отредактировать EmailNotifierBookSaleService.

Итак, вот текущая схема процесса.

Для несложных систем такого подхода может быть достаточно. Но приложение для продажи книг может стать огромным корпоративным решением. Мы только начали разработку, но уже получили два декоратора. Кроме того, порядок упаковки также имеет значение. Вот почему нам пришлось изменить квалификаторы.

Кажется, что система чрезмерно усложняется, но это еще не конец.

4. Администраторы должны иметь возможность обновлять название и описание книги. Авторов необходимо уведомлять о каждом обновлении по электронной почте

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

  1. Обновление информации о книге.
  2. Архивирование книг.
  3. Уведомление по электронной почте.

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

В чем проблема? Некоторые из ранее реализованных функций придется повторять. Например, архивирование книг. Независимо от того, что именно произошло с книгой, должна создаваться новая архивная запись. Потому что именно это нужно аналитикам. Электронные письма  —  аналогично. Разница в частоте их отправки.

Итак, какое же решение предпочесть? Именно тут на помощь приходят доменные события. Но сначала стоит провести рефакторинг.

Скажем “нет” анемичной модели домена

Какие запросы у нас есть на данный момент? Только два. Запрос на продажу книги и на обновление информации. Немного перепишем сущность Book.

@Entity
@Table
public class Book {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private String name;

private String description;

private OffsetDateTime dateCreated;

private OffsetDateTime lastDateUpdated;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "author_id")
private Author author;

private int price;

@OneToMany(fetch = LAZY, mappedBy = "book", cascade = ALL)
private List<BookSale> bookSales = new ArrayList<>();

public void sell() {
final var bookSale = new BookSale();
bookSale.setBook(this)
bookSale.setDateSold(OffsetDateTime.now())
bookSale.setPriceSold(price);
bookSale.add(bookSale);
}

public void changeInfo(String name, String description) {
this.name = name;
this.description = description;
lastDateUpdated = OffsetDateTime.now();
}
}

Обратите внимание на методы sell и changeInfo. Первый регистрирует продажу новой книги, а второй обновляет название и описание книги.

Кажется, что до сих пор ничего не изменилось. Мы просто объединили функциональность, которая может быть выполнена с помощью вызова сеттеров. Теперь реорганизуем BookSaleServiceImpl.

@Service
public class BookSaleServiceImpl implements BookSaleService {
private final BookRepository bookRepository;

@Transactional
@Override
public void sellBook(Long bookId) {
final var book = bookRepository.findById(bookId).orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
book.sell();
bookRepository.save(book);
}
}

Код больше не выглядит как список команд. Бизнес-кейс теперь прозрачен. Кроме того, Book.sell возможно переиспользовать в различных службах приложений. Но бизнес-правило остается прежним.

Сервис, который обновляет информацию о книге, будет выглядеть знакомо:

@Service
public class BookUpdateServiceImpl implements BookUpdateService {
private final BookRepository bookRepository;

@Transactional
@Override
public void updateBookInfo(Long bookId, String name, String description) {
final var book = bookRepository.findById(bookId).orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
book.changeInfo(name, description);
bookRepository.save(book);
}
}

Введение доменных событий

Теперь перейдем к задаче архивирования книг. Что, если бы каждое обновление книги публиковало событие, которое запускает архивирование книг? У Spring есть компонент ApplicationEventPublisher, который позволяет публиковать события и подписываться на них с помощью @EventListener.

@Service
public class BookSaleServiceImpl implements BookSaleService {
private final BookRepository bookRepository;
private final ApplicationEventPublisher eventPublisher;

@Transactional
@Override
public void sellBook(Long bookId) {
final var book = bookRepository.findById(bookId).orElseThrow(() -> new NoSuchElementException(
"Book is not found"
));
// здесь происходит событие публикации
eventPublisher.publishEvent(new BookUpdated(book));
book.sell();
bookRepository.save(book);
}
}

@Component
public class BookUpdatedListener {
@EventListener
public void archiveBook(BookUpdated bookUpdated) {
// архивирование
}
}

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

Мы могли бы предоставить ApplicationEventPublisher в качестве делегата для методов обновления.

@Entity
@Table
public class Book {
...

public void sell(Supplier<? extends ApplicationEventPublisher> publisher) {
publisher.get().publishEvent(new BookUpdated(this));
final var bookSale = new BookSale();
bookSale.setBook(this)
bookSale.setDateSold(OffsetDateTime.now())
bookSale.setPriceSold(price);
bookSales.add(bookSale);
}

public void changeInfo(String name, String description, Supplier<? extends ApplicationEventPublisher> publisher) {
publisher.get().publishEvent(new BookUpdated(this));
this.name = name;
this.description = description;
lastDateUpdated = OffsetDateTime.now();
}

Так-то лучше. Но в любом случае, мы должны внедрить этот экземпляр ApplicationEventPublisher в каждую службу, которая каким-то образом взаимодействует с Book.

Есть ли решение получше? Конечно. Встречайте @DomainEvents.

@Entity
@Table
public class Book {
...

@Transient
private final List<Object> domainEvents = new ArrayList<>();

@DomainEvents
public Collection<Object> domainEvents() {
return Collections.unmodifiableList(this.domainEvents);
}

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

private void registerEvent(Object event) {
this.domainEvents.add(event);
}

public void sell() {
final var bookUpdated = new BookUpdated(this);
final var bookSale = new BookSale();
bookSale.setBook(this)
bookSale.setDateSold(OffsetDateTime.now())
bookSale.setPriceSold(price);
bookSales.add(bookSale);
registerEvent(bookUpdated);
}

public void changeInfo(String name, String description) {
final var bookUpdated = new BookUpdated(this);
this.name = name;
this.description = description;
lastDateUpdated = OffsetDateTime.now();
registerEvent(bookUpdated);
}
}

Каждый раз, когда клиент вызывает метод sell или changeInfo, событие BookUpdated добавляется в список DomainEvents. Как вы можете догадаться, прямой публикации нет. Итак, как события добираются до слушателей событий? Когда мы вызываем метод Repository.save, Spring собирает события и ищет аннотацию @DomainEvents. Затем выполняется очистка (@AfterDomainEventPublication).

Можно сделать и проще. Spring предоставляет класс AbstractAggregateRoot, который уже содержит необходимую функциональность. Итак, вот менее подробный вариант.

@Entity
@Table
public class Book extends AbstractAggregateRoot<Book> {
...

public void sell() {
final var bookUpdated = new BookUpdated(this);
final var bookSale = new BookSale();
bookSale.setBook(this)
bookSale.setDateSold(OffsetDateTime.now())
bookSale.setPriceSold(price);
bookSales.add(bookSale);
registerEvent(bookUpdated);
}

public void changeInfo(String name, String description) {
final var bookUpdated = new BookUpdated(this);
this.name = name;
this.description = description;
lastDateUpdated = OffsetDateTime.now();
registerEvent(bookUpdated);
}
}

Мы забыли о событиях электронной почты. Возникает соблазн объявить BookSaleEmailEvent или BookChangeInfoEmailEvent. Но это не будет доменно-ориентированным. Видите ли, отправка электронного письма  —  это всего лишь деталь реализации. Возможны десятки других вариантов. Ведение журнала, отправка сообщения в Kafka, запуск задания и т.д. Важно сосредоточиться на бизнес-примерах использования, а не на функциональном поведении.

Итак, правильный способ  —  объявить события BookSold и BookChangedInfo.

@Entity
@Table
public class Book extends AbstractAggregateRoot<Book> {
...

public void sell() {
final var bookUpdated = new BookUpdated(this);
final var bookSale = new BookSale();
bookSale.setBook(this)
bookSale.setDateSold(OffsetDateTime.now())
bookSale.setPriceSold(price);
bookSales.add(bookSale);
registerEvent(bookUpdated);
registerEvent(new BookSold(this));
}

public void changeInfo(String name, String description) {
final var bookUpdated = new BookUpdated(this);
this.name = name;
this.description = description;
lastDateUpdated = OffsetDateTime.now();
registerEvent(bookUpdated);
registerEvent(new BookChangedInfo(this));
}
}

Захват событий

Аннотация @EventListener  —  это простой и удобный способ отслеживания событий в Spring. Но есть один нюанс. Смысл не в том, чтобы просто фиксировать события. Нужно, чтобы слушатели вызывались в определенные моменты жизненного цикла транзакции.

Например, архивирование должно быть выполнено непосредственно перед фиксацией транзакции. Если что-то пойдет не так с основным запросом или самой архивацией, вся транзакция должна быть откатана.

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

Аннотация @EventListener недостаточно мощна, чтобы удовлетворить наши потребности. Но не беспокойтесь, на помощь спешит @TransactionalEventListener!

Разница в том, что аннотация предоставляет атрибут phase. Он объявляет точку жизненного цикла транзакции, когда должен быть вызван слушатель. Есть четыре возможных значения.

  1. BEFORE_COMMIT;
  2. AFTER_COMMIT  —  значение по умолчанию;
  3. AFTER_ROLLBACK;
  4. AFTER_COMPLETION.

Первые три варианта не требуют пояснений. AFTER_COMPLETION  —  это комбинация AFTER_ROLLBACK и AFTER_COMMIT.

Например, именно так может быть реализовано архивирование книг.

@Component
public class BookUpdatedListener {
private final BookArchiveRepository bookArchiveRepository;

@TransactionalEventListener(phase = BEFORE_COMMIT)
public void archiveBook(BookUpdated bookUpdated) {
BookArchive bookArchive = BookArchive.createNew(bookUpdated);
bookArchiveRepository.save(bookArchive);
}
}

BookArchive.CreateNew просто инкапсулировал логику создания нового экземпляра BookArchive, которая была описана ранее.

Проще простого! Захват BookChangedInfo и BookSold будет похожим:

@Component
public class BookChangedInfoListener {
private final EmailService emailService;

@TransactionalEventListener(phase = AFTER_COMMIT)
public void notifyAuthorByEmail(BookChangedInfo bookChangedInfo) {
String email = bookChangedInfo.getAuthorEmail();
emailService.send(email, "Your book's info has been changed");
}
}

@Component
public class BookSoldListener {
private final EmailService emailService;

@TransactionalEventListener(phase = AFTER_COMMIT)
public void notifyAuthorIfNeeded(BookSold bookSold) {
int totalSoldBooks = bookSold.getTotalSoldBooksCount();
if (totalSoldBooks % 100 == 0) {
String email = bookSold.getAuthorEmail();
emailService.send(email, "Another 100 books of your have been sold!");
}
}
}

Следует уточнить одну важную деталь о @TransactionalEventListener. Иногда нужно вызвать команды в новой транзакции на этапе AFTER_COMMIT. Если это так, убедитесь, что также указали @Transactional(propagation = REQUIRES_NEW). Параметр REQUIRES_NEW имеет решающее значение. Потому что есть вероятность, что ресурсы предыдущих транзакций еще не очищены. Итак, надо убедиться, что Spring начнет новую.

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

Первая попытка:

Окончательный вариант архитектуры:

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

Также вам, вероятно, не понадобятся Spring Data и Hibernate, если вы применяете шаблон сценариев транзакции. Поскольку все бизнес-правила привязаны к службам, переход в спящий режим принесет накладные расходы и не так много преимуществ. Вместо этого можно испробовать JDBI, JOOQ или даже обычный JDBC.

Окончательная архитектура переворачивает все с ног на голову. Сущности домена инкапсулируют бизнес-логику, а службы действуют как тонкие оболочки (расширенная доменная модель). Независимо от того, кто взаимодействует с объектом Book, бизнес-правила остаются неизменными. Вся дополнительная функциональность определяется доменными событиями. Это позволяет бесконечно расширять систему. Доменные события могут запускать различные бизнес-операции: помещение сообщения в очередь, выполнение действий аудита, уведомление пользователей, применение шаблона CQRS и т.д.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Semyon Kirekov: Spring Data — Power of Domain Events

Предыдущая статья7 вопросов по фронтенд-разработке
Следующая статьяНовый шаг к будущему без языковых границ