Spring умеет передавать «сигналы» между частями одного приложения: один класс сообщает «произошло вот это», а другие на это реагируют — каждый своим делом. Разберём с нуля, зачем это нужно, как опубликовать событие и как его поймать.
Обычный слушатель срабатывает в момент публикации — внутри транзакции, до фиксации. Слушатель фазы AFTER_COMMIT ждёт итога: при откате его просто не вызовут, а вот синхронное письмо уже ушло.
Зачем вообще нужны события
Представьте метод, который создаёт заказ. Сразу после сохранения нужно сделать ещё несколько вещей: отправить покупателю письмо, обновить кэш, записать строчку в журнал действий. Самый простой способ — позвать всё прямо в методе:
void createOrder(...) {
orderRepository.save(order);
emailService.sendConfirmation(order); // отправить письмо
cache.evict(order.customerId()); // обновить кэш
auditLog.record(order); // записать в журнал
}
Проблема: метод «создать заказ» теперь знает про почту, кэш и журнал. Захотим добавить четвёртое действие — снова правим этот же метод. Класс разрастается и завязывается на всё подряд.
Идея событий простая: метод просто объявляет «заказ создан» и на этом заканчивает свою работу. А кто и как на это отреагирует — его уже не касается. Письмо, кэш, журнал живут в отдельных классах и подписываются на это сообщение сами.
void createOrder(...) {
orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(order.id(), order.customerId())); // объявили — и всё
}
Так зависимости расцепляются: отправитель события ничего не знает о тех, кто его слушает. Это работает внутри одного приложения — это не способ общаться между разными сервисами (для этого есть очереди сообщений и сетевые вызовы).
Из чего состоит механизм
Тут всего три участника:
- Событие — обычный объект с данными о том, что случилось. Чаще всего
record. - Издатель — тот, кто говорит «случилось вот это». В Spring это
ApplicationEventPublisher. - Слушатель — метод, помеченный
@EventListener, который реагирует на событие.
Само событие — это просто класс без всякой магии:
public record OrderCreatedEvent(UUID orderId, UUID customerId) {}
Раньше события должны были наследоваться от специального класса ApplicationEvent. Сейчас это не нужно — публиковать можно любой объект. Спокойно используйте обычный record.
Как опубликовать событие
Издателя ApplicationEventPublisher не надо создавать руками — Spring сам передаст его через конструктор, как любую другую зависимость:
@Service
class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
void createOrder(...) {
orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(order.id(), order.customerId()));
}
}
Вызвали publishEvent — и забыли. Дальше дело за слушателями.
Как поймать событие через @EventListener
Слушатель — это метод в любом бине, помеченный @EventListener. Spring смотрит на тип параметра метода и сам понимает, какое событие тот ловит:
@Component
class OrderCreatedListener {
private final EmailService email;
OrderCreatedListener(EmailService email) {
this.email = email;
}
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
email.sendConfirmation(event.customerId(), event.orderId());
}
}
На одно событие может быть сколько угодно слушателей в разных классах — Spring позовёт их все. Если важен порядок вызова, добавьте к слушателю @Order (меньше число — раньше очередь).
Слушателя можно ограничить условием, не разводя if в начале метода. Атрибут condition принимает выражение на SpEL, где #event это само событие; слушатель вызовется, только если выражение истинно:
@EventListener(condition = "#event.total > 1000")
public void onLargeOrder(OrderCreatedEvent event) { ... }
Выражение проверяется до вызова, поэтому это ещё и способ не тратить время на разбор события, которое вам не нужно. Условие читается хуже обычного if, зато видно прямо в объявлении слушателя, и у SpEL нет проверки на этапе компиляции: опечатка в имени поля даст ошибку при первом же событии.
Вторая возможность, о которой редко знают: слушатель может вернуть значение, и Spring опубликует его как новое событие. @EventListener OrderNotified onOrderCreated(OrderCreatedEvent e) породит OrderNotified, на которое подпишется кто-то ещё; null и void цепочку не продолжают, а коллекция публикуется поэлементно. Приём короткий, но именно из-за него однажды обнаруживается цепочка из четырёх событий, которую никто не проектировал, поэтому пользуются им осознанно и редко.
Синхронные слушатели: всё в одном потоке
По умолчанию слушатель работает синхронно: издатель вызывает publishEvent, и прямо в этот момент, в том же потоке, по очереди отрабатывают все слушатели. Издатель ждёт, пока они закончат, и только потом продолжает.
По умолчанию слушатели — это обычные вызовы методов: тот же поток, та же транзакция, та же очередь. Медленный слушатель замедляет создание заказа, а его исключение откатывает всё вместе.
Из этого следуют три вещи, которые легко упустить:
- Если слушатель долго работает — издатель всё это время стоит и ждёт.
- Если слушатель бросит исключение — оно «прорвётся» обратно к издателю, как при обычном вызове метода.
- Если всё это внутри транзакции и слушатель пишет в базу — запись попадёт в ту же транзакцию.
Синхронные слушатели — это разумное поведение по умолчанию. К асинхронности переходят осознанно.
Асинхронные слушатели: @Async
Иногда реакция не должна тормозить основную работу. Отправка письма может занять секунду — глупо заставлять покупателя ждать ответа всё это время. Тогда слушателя делают асинхронным: он отрабатывает в отдельном потоке, а издатель не ждёт.
Сначала надо разрешить асинхронность в приложении:
@Configuration
@EnableAsync
class AsyncConfig { }
А потом добавить слушателю @Async:
@Component
class OrderCreatedListener {
@Async
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
email.sendConfirmation(...); // выполняется в другом потоке
}
}
Теперь издатель опубликовал событие и сразу пошёл дальше, а письмо уходит параллельно.
Одна публикация события и две судьбы слушателя: обычный остаётся внутри транзакции издателя, а @Async уходит в свой поток до её итога.
Но за это приходится платить:
- Исключение из слушателя «теряется». Издатель уже ушёл вперёд и про сбой не узнает. Чтобы такие ошибки не пропадали молча, их ловят отдельным обработчиком (
AsyncUncaughtExceptionHandler). - Слушатель работает вне исходной транзакции. Он в другом потоке, со своей транзакцией — атомарность с основной работой теряется.
Асинхронные слушатели хороши для необязательных побочных действий: письма, журналы, уведомления. Для важных вещей чаще берут @TransactionalEventListener (ниже).
@TransactionalEventListener: дождаться итога транзакции
Вот частая ловушка. Метод работает в транзакции, внутри публикует событие, а синхронный слушатель сразу отправляет письмо «заказ создан». Но что, если после этого транзакция откатится и заказ на самом деле в базу не попадёт? Письмо уже ушло — покупателю сообщили про заказ, которого нет.
Хочется, чтобы слушатель срабатывал только если транзакция действительно завершилась успешно. Для этого есть @TransactionalEventListener — он привязывает слушателя к определённой фазе транзакции:
На практике используют две фазы. AFTER_COMMIT, по умолчанию, срабатывает после успешной фиксации: письмо, уведомление, запись в очередь, всё, что нельзя делать, пока заказ не сохранён. BEFORE_COMMIT срабатывает перед фиксацией и ещё внутри транзакции: проверка, которая должна откатить заказ, если не прошла. Ещё две, AFTER_ROLLBACK и AFTER_COMPLETION, нужны редко: первая после отката, вторая после любого завершения.
@Component
class OrderCreatedListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(OrderCreatedEvent event) {
email.sendConfirmation(...); // только если транзакция зафиксировалась
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onRollback(OrderCreatedEvent event) {
log.warn("Создание заказа откатилось: {}", event.orderId());
}
}
Главное достоинство: при откате транзакции слушатель просто не вызовется. «Заказ не сохранился — значит, и письмо отправлять не надо» получается само собой.
И тут же — оборотная сторона, на которой обжигаются. Такой слушатель ждёт итога транзакции. А если в момент публикации транзакции не было вовсе — событие отправили из метода без @Transactional, из фонового задания, из теста — ждать нечего, и слушателя не позовут. Совсем. Ошибки не будет, в логах в лучшем случае найдётся строчка уровня debug. Со стороны выглядит так, будто письмо просто не отправилось. Если событие надо обработать в любом случае, у аннотации есть fallbackExecution = true: без транзакции слушатель отработает сразу, как обычный @EventListener.
@Order у таких слушателей тоже работает, но только внутри одной фазы — порядок самих фаз задаёт транзакция, и переставить их нельзя.
Что происходит с исключением, зависит от фазы, и разница здесь принципиальная. В BEFORE_COMMIT слушатель работает ещё внутри транзакции: его исключение отменит фиксацию, и заказ не сохранится — именно поэтому в эту фазу ставят проверки, которые должны иметь право всё отменить. В AFTER_COMMIT отменять уже нечего: транзакция зафиксирована, заказ в базе, и исключение слушателя только вылетит наружу (в обычном синхронном вызове — к вызывающему коду, но откатить фиксацию оно не может). Плюс к этому слушатели фазы AFTER_COMMIT идут цепочкой в одном потоке, поэтому исключение в первом отменяет вызов остальных: упавшая отправка письма может незаметно отменить запись в журнал.
Ту же механику можно собрать руками на чистой Java: обычный слушатель вызывается прямо в момент публикации, а слушатель фазы AFTER_COMMIT откладывается до фиксации. Запустите и сравните два прогона:
живой пример
import java.util.ArrayList;
import java.util.List;
public class EventPhasesDemo {
record OrderCreated(String orderId) {}
static void publish(OrderCreated event, List<Runnable> afterCommit) {
sendEmail(event); // @EventListener — прямо сейчас
afterCommit.add(() -> writeAudit(event)); // @TransactionalEventListener
}
static void sendEmail(OrderCreated event) {
System.out.println(" [синхронно] письмо про " + event.orderId());
}
static void writeAudit(OrderCreated event) {
System.out.println(" [AFTER_COMMIT] журнал: " + event.orderId());
}
static void createOrder(String orderId, boolean broken) {
List<Runnable> afterCommit = new ArrayList<>(); // очередь AFTER_COMMIT этой транзакции
System.out.println("BEGIN " + orderId);
publish(new OrderCreated(orderId), afterCommit);
if (broken) {
System.out.println("ROLLBACK"); // очередь пропадает вместе с транзакцией
return;
}
System.out.println("COMMIT");
afterCommit.forEach(Runnable::run);
}
public static void main(String[] args) {
createOrder("A-1", false);
createOrder("A-2", true);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В первом прогоне отработали оба слушателя. Во втором — только синхронный: письмо про заказ A-2 ушло, хотя заказа в базе нет.
Какой слушатель выбрать
Короткое правило на каждый день:
- Работа должна быть частью той же транзакции (например, ещё одна запись в базу рядом с основной) — обычный
@EventListener, синхронно. - Работа должна выполниться только после успешной фиксации и в базу не пишет (письмо, уведомление) —
@TransactionalEventListenerс фазойAFTER_COMMIT. - Работа необязательная и не должна тормозить основной поток (письма, журналы) —
@Asyncплюс@EventListener, помня про потерю исключений.
Где события — не лучший выбор
События Spring живут внутри одного запущенного приложения, в его памяти. Если сервер выключится сразу после фиксации транзакции, но до того, как слушатель успел отработать, событие просто пропадёт — никакой гарантии доставки тут нет.
Поэтому для пересылки событий между разными сервисами (через очередь сообщений) их не используют напрямую: там нужна гарантия, что сообщение не потеряется. Для такого применяют отдельный приём, Outbox: сообщение и бизнес-данные сохраняют в базу одной транзакцией, а отдельный процесс потом досылает его в очередь и помечает отправленным. Устройство, гарантии и подводные камни разобраны в статье про распределённые паттерны. События Spring остаются для побочных действий внутри сервиса: письма, кэш, журнал.
И последняя цена, которую платят за расцепление: у событий нет проверяемого контракта. Издатель не знает своих слушателей, компилятор не знает, что кто-то должен на событие реагировать, и если слушатель объявлен с другим типом (переименовали событие, скопировали класс в другой пакет, опечатались), ошибки не будет — событие просто никто не поймает, и молча не произойдёт ничего. Обычный вызов метода такую ошибку не пропустил бы. Отсюда два правила: событиями расцепляют побочные действия, а не основной сценарий (то, без чего операция не считается выполненной, зовут методом напрямую), и на важные слушатели пишут тесты — @RecordApplicationEvents в @SpringBootTest позволяет проверить, что событие опубликовано, а тест самого слушателя убеждается, что он реагирует.
Глубже: если в фазе AFTER_COMMIT надо писать в базурасширенное
Тонкость. Фаза AFTER_COMMIT срабатывает после фиксации — исходная транзакция уже закрыта. Если такой слушатель попробует записать что-то в базу, запись просто не сохранится — и это самое неприятное, потому что никакой ошибки не будет. Сессия и соединение к потоку ещё привязаны, поэтому ошибки не случится — но фиксировать запись уже некому: транзакция закрыта секунду назад. С JPA она даже не доедет до базы: сессия примет изменение, а сбрасывать его в базу (flush) уже никто не будет — искать этот запрос в логе SQL бесполезно. Изменения молча пропадут. Чтобы запись прошла, слушателю нужна новая транзакция — её просят аннотацией:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onCommit(OrderCreatedEvent event) {
auditLog.save(new AuditEntry(event)); // в отдельной новой транзакции
}
Без REQUIRES_NEW запись из такого слушателя в базу не попадёт.
Глубже: встроенные события Springрасширенное
События — это не только ваши классы. Spring сам рассылает служебные сообщения о жизни приложения, и на них тоже можно подписаться тем же @EventListener. Например, ApplicationReadyEvent приходит, когда приложение полностью поднялось и готово принимать запросы — удобное место для стартовой инициализации:
@Component
class StartupListener {
@EventListener
public void onReady(ApplicationReadyEvent event) {
log.info("Приложение запущено и готово к работе");
}
}
Событий таких несколько, и каждое отвечает на свой вопрос «когда». ApplicationStartedEvent приходит после сборки контекста, но до раннеров; ApplicationReadyEvent последним, когда приложение готово принимать запросы, и именно он нужен для «сделать один раз при старте». ContextRefreshedEvent срабатывает при каждом обновлении контекста (в тестах их бывает несколько, поэтому для одноразовой работы он хуже), ContextClosedEvent — перед остановкой, туда вешают «успеть перед выключением». А ApplicationFailedEvent приходит, когда старт не удался, и это единственное место, где можно отправить уведомление о неподнявшемся приложении изнутри него самого. Ранние события (до создания контекста) обычным слушателем не поймать: их подписывают через SpringApplication.addListeners.
Коротко
- События нужны, чтобы расцепить код: издатель объявляет «случилось X» и не знает, кто на это отреагирует.
- Три участника: событие (обычный объект, чаще
record; наследоватьApplicationEventне нужно), издатель (ApplicationEventPublisher), слушатель (@EventListener). - По умолчанию
@EventListenerработает синхронно, в том же потоке и той же транзакции; издатель ждёт слушателей, а их исключения возвращаются к нему. @Asyncплюс@EventListener— слушатель в отдельном потоке, издатель не ждёт; но исключения теряются и работа идёт вне исходной транзакции.@TransactionalEventListenerпривязывает слушателя к фазе транзакции (BEFORE_COMMIT,AFTER_COMMITпо умолчанию,AFTER_ROLLBACK,AFTER_COMPLETION); в фазеAFTER_COMMITисходная транзакция уже закрыта, и для записи в базу нужна новая (REQUIRES_NEW).- На служебные события Spring подписываются тем же
@EventListener; но живут события в памяти одного приложения и не дают гарантии доставки — для надёжной пересылки между сервисами не годятся. conditionна SpEL отсекает лишние события до вызова; возвращённое слушателем значение публикуется как новое событие, и так рождаются незапланированные цепочки.- Исключение в
BEFORE_COMMITотменяет фиксацию, вAFTER_COMMITотменить уже нечего, но оно обрывает остальных слушателей той же фазы. - Служебные события:
ApplicationStartedEvent,ApplicationReadyEvent(готов принимать запросы),ContextRefreshedEvent(бывает несколько раз),ContextClosedEvent,ApplicationFailedEvent(старт не удался). - У событий нет проверяемого контракта: слушатель с другим типом молча не сработает, поэтому ими расцепляют побочные действия, а надёжную доставку наружу делает Outbox.
Что почитать дальше
- DI/IoC, жизненный цикл бина и scopes — как Spring создаёт бины и передаёт зависимости вроде
ApplicationEventPublisher. @Transactionalглубоко — фазы транзакции, к которым привязывается@TransactionalEventListener.- Spring AOP — на нём держится
@Async, и у него есть своя ловушка с вызовом метода внутри того же класса. - Scheduled и Async — в каком пуле потоков работает асинхронный слушатель и как этот пул настраивают.