Типичный сценарий роста бизнес-логики: оформление заказа изначально делает одно — сохраняет заказ. Через полгода к этому добавляется резервирование склада, отправка письма-подтверждения, начисление бонусных баллов, событие для аналитики, уведомление в Slack о крупном заказе. Наивный путь — звать всё это напрямую из OrderService.placeOrder():
@Service
class OrderService {
@Transactional
Order placeOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
// OrderService теперь ЗНАЕТ про склад, почту, лояльность, аналитику —
// и должен собрать все эти зависимости конструктором
inventoryService.reserve(order);
emailClient.sendConfirmation(order);
loyaltyService.accruePoints(order);
analyticsClient.track("order_placed", order.getId());
return order;
}
}
Проблема не только в размере конструктора. Любая из побочных реакций может упасть — и если emailClient.sendConfirmation() бросит исключение из-за недоступности SMTP-сервера, весь метод откатит транзакцию и клиент не получит своего сохранённого заказа, хотя сам заказ к отправке письма никакого отношения не имеет. Добавление новой реакции на «заказ оформлен» требует правки OrderService — сервиса, который логически про заказы, а не про склад и почту. Событийная модель разрывает эту связь: OrderService публикует факт «заказ оформлен» и не знает, кто и сколько подписчиков на него отреагирует.
С версии Spring 4.2 событием может быть произвольный POJO — не обязательно наследовать ApplicationEvent. Достаточно объявить класс (удобно — record) и опубликовать его через ApplicationEventPublisher, который Spring внедряет как обычную зависимость (эту роль на практике исполняет сам ApplicationContext):
public record OrderPlacedEvent(Order order, Instant occurredAt) {
}
@Service
class OrderService {
private final ApplicationEventPublisher eventPublisher;
private final OrderRepository orderRepository;
@Transactional
Order placeOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
eventPublisher.publishEvent(new OrderPlacedEvent(order, Instant.now()));
return order;
}
}
Подписчик — обычный компонент с методом, размеченным @EventListener; тип единственного параметра метода определяет, на какие события он подписан:
@Component
class InventoryReservationListener {
@EventListener
void onOrderPlaced(OrderPlacedEvent event) {
inventoryService.reserve(event.order());
}
}
ApplicationEvent, а слушатель — реализовывать ApplicationListener<E> с методом onApplicationEvent(E event). Оба стиля работают и сегодня и полностью совместимы друг с другом внутри одного приложения; ApplicationEvent просто добавляет готовые getTimestamp()/getSource(), а @EventListener — гибкость (SpEL-условия, несколько типов событий на одном методе, не нужен интерфейс).
ApplicationEventPublisher в рантайме — это сам ApplicationContext (интерфейс ApplicationContext наследует ApplicationEventPublisher). Его метод publishEvent() не рассылает события напрямую — он делегирует единственному бину-диспетчеру, ApplicationEventMulticaster (по умолчанию — SimpleApplicationEventMulticaster):
// Упрощённо: AbstractApplicationContext.publishEvent(event)
void publishEvent(Object event) {
ApplicationEvent applicationEvent = event instanceof ApplicationEvent ae
? ae
: new PayloadApplicationEvent<>(this, event); // оборачиваем "голый" POJO
applicationEventMulticaster.multicastEvent(applicationEvent);
}
// Упрощённо: SimpleApplicationEventMulticaster.multicastEvent(event)
void multicastEvent(ApplicationEvent event) {
for (ApplicationListener<?> listener : getApplicationListeners(event)) {
Executor executor = getTaskExecutor();
if (executor != null) {
executor.execute(() -> invokeListener(listener, event));
} else {
invokeListener(listener, event); // СИНХРОННО, в потоке publishEvent()
}
}
}
Ключевой факт — getApplicationListeners(event) отбирает подходящих слушателей по generic-типу события через ResolvableType (что позволяет и универсальным событиям вроде PayloadApplicationEvent<OrderPlacedEvent> находить нужных подписчиков), а по умолчанию у мультикастера нет Executor — то есть весь список слушателей выполняется синхронно, один за другим, в том же потоке, который вызвал publishEvent(). Метод не возвращает управление публикующему коду, пока не отработает последний слушатель.
Методы, размеченные @EventListener, сами по себе не являются бинами ApplicationListener — их туда превращает отдельный механизм:
EventListenerMethodProcessor — SmartInitializingSingleton, который отрабатывает после создания всех синглтонов;@EventListener (и его специализаций, включая @TransactionalEventListener — см. ниже);ApplicationListenerMethodAdapter, который реализует ApplicationListener<ApplicationEvent> и внутри рефлективно вызывает исходный метод через бин, полученный из контекста (то есть через прокси, если он есть);ApplicationEventMulticaster, что и «ручные» реализации ApplicationListener.@EventListener вызывается не через this.method() внутри того же класса, а исключительно через ссылку на бин, зарегистрированную в ApplicationListenerMethodAdapter при старте контекста — то есть всегда через прокси, если он нужен. Поэтому комбинации вроде @Async @EventListener или @Transactional @EventListener на одном методе работают ожидаемо, без классической ловушки self-invocation.
Если на одно событие подписано несколько слушателей, порядок их вызова по умолчанию не гарантирован (фактически — порядок регистрации бинов, на который нельзя полагаться). Управлять им явно позволяет @Order:
@Component
class InventoryReservationListener {
@Order(1) // склад резервируем раньше, чем шлём письмо
@EventListener
void onOrderPlaced(OrderPlacedEvent event) {
inventoryService.reserve(event.order());
}
}
Второй практичный инструмент — атрибут condition с выражением SpEL, вычисляемым до вызова тела метода: событие доходит до слушателя, но обработка отфильтровывается декларативно, без ветвления внутри метода. Внутри выражения событие доступно как #event либо по имени параметра метода:
@Component
class FraudReviewListener {
@EventListener(condition = "#event.order().totalAmount().compareTo(T(java.math.BigDecimal).valueOf(100000)) > 0")
void onLargeOrder(OrderPlacedEvent event) {
fraudCheckService.flagForManualReview(event.order());
}
}
@EventListener({OrderPlacedEvent.class, OrderCancelledEvent.class}) допустимо, если у метода нет параметров (тип события тогда недоступен внутри) либо параметр объявлен общим супертипом обоих событий — удобно для сквозной логики вроде инвалидации кэша при любом изменении заказа.
Раз мультикастер по умолчанию вызывает слушателей синхронно в потоке издателя, тяжёлый или медленный слушатель (внешний HTTP-вызов, отправка письма) удерживает этот поток на всё время своей работы — в том числе поток, обслуживающий HTTP-запрос пользователя. Решение — обычный @Async поверх @EventListener (после @EnableAsync, см. тему 02 про экзекьюторы):
@Component
class AnalyticsListener {
@Async
@EventListener
void onOrderPlaced(OrderPlacedEvent event) {
// уходит в applicationTaskExecutor, не блокирует поток publishEvent()
analyticsClient.track("order_placed", event.order().getId());
}
}
Future, через который можно было бы получить исключение вызывающей стороной — поэтому если analyticsClient.track() бросит исключение, оно просто уходит в дефолтный SimpleAsyncUncaughtExceptionHandler, который логирует его на уровне WARN и всё. Без явного мониторинга по логам такой сбой можно не заметить месяцами. Регистрируйте собственный AsyncUncaughtExceptionHandler через AsyncConfigurer, который хотя бы инкрементирует метрику ошибок — как и с @Scheduled (см. тему 02), молчаливое логирование — не мониторинг.
RequestContextHolder, MDC-поля логирования (traceId, requestId) — всё это привязано к потоку и не копируется автоматически в поток, куда @Async перекладывает выполнение. Слушатель, ожидающий увидеть тот же MDC, что был у вызывающего HTTP-запроса, увидит пустой контекст. Чтобы прокинуть MDC — нужен TaskDecorator на экзекьюторе, который явно копирует контекст в новый поток перед выполнением задачи.
У обычного @EventListener есть скрытая опасность: если он выполняется синхронно внутри той же транзакции, что и издатель (а это дефолт), исключение в слушателе распространяется вверх и может откатить транзакцию, которая к слушателю отношения не имеет — например, отмена уже сохранённого заказа из-за упавшей отправки письма. @TransactionalEventListener решает это, привязывая вызов к конкретной фазе транзакции издателя, а не к моменту публикации:
@Component
class OrderConfirmationEmailListener {
// Выполнится ПОСЛЕ успешного коммита транзакции OrderService.placeOrder() —
// падение здесь уже никак не влияет на сохранённый заказ
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onOrderPlaced(OrderPlacedEvent event) {
emailClient.sendConfirmation(event.order());
}
}
| Фаза | Когда срабатывает |
|---|---|
AFTER_COMMIT (дефолт) | После успешного коммита транзакции издателя — самый частый выбор для побочных эффектов (письма, аналитика, интеграции) |
BEFORE_COMMIT | Непосредственно перед коммитом, ещё в той же транзакции — для проверок, которые должны иметь право откатить издателя |
AFTER_ROLLBACK | После отката — например, чтобы освободить резерв склада, занятый до отката |
AFTER_COMPLETION | После коммита ИЛИ отката — если реакция не зависит от исхода (закрыть ресурсы, снять блокировку) |
@TransactionalEventListener по умолчанию (fallbackExecution = false) вообще не выполнится, если в момент publishEvent() не было активной транзакции — ни исключения, ни лога, слушатель просто не будет вызван. Это легко словить: вызов сервисного метода без @Transactional, вызов из тестового кода без транзакции, либо публикация из @Async-метода (у которого своя, отдельная граница транзакции или её отсутствие). Если слушатель обязан сработать и без активной транзакции — ставьте fallbackExecution = true, тогда при отсутствии транзакции он выполнится немедленно и синхронно, как обычный @EventListener.
Общий Executor для всех слушателей сразу, без разметки каждого @Async по отдельности — можно переопределить сам бин мультикастера под именем applicationEventMulticaster:
@Configuration
class EventingConfig {
@Bean("applicationEventMulticaster") // имя строго зарезервировано фреймворком
ApplicationEventMulticaster applicationEventMulticaster() {
SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster();
multicaster.setTaskExecutor(Executors.newVirtualThreadPerTaskExecutor());
return multicaster;
}
}
Это распространяет асинхронность на все события и всех слушателей разом — удобно как глобальная политика, но менее гибко, чем точечный @Async на конкретных слушателях (и точно так же теряет порядок выполнения между слушателями, если он был важен).
Generic-события. Тип события может быть параметризован, и ResolvableType внутри мультикастера умеет находить точного подписчика на конкретный параметр:
public record EntityChangedEvent<T>(T entity, ChangeType type) {
}
@Component
class OrderCacheInvalidationListener {
// Сработает только на EntityChangedEvent<Order>, а не на
// EntityChangedEvent<Customer> и т.п. — резолвится по generic-параметру
@EventListener
void onOrderChanged(EntityChangedEvent<Order> event) {
cacheManager.evict("orders", event.entity().getId());
}
}
@EventListener без @Async и без @TransactionalEventListener вызывается синхронно в том же потоке и (для событий, опубликованных из @Transactional-метода) в той же транзакции, что и издатель. Необработанное исключение в таком слушателе распространяется вверх по стеку вызовов ровно так же, как если бы код слушателя был написан прямо внутри placeOrder() — и стандартный TransactionInterceptor откатит транзакцию издателя. Если побочный эффект не должен влиять на судьбу основной транзакции — используйте @TransactionalEventListener(phase = AFTER_COMMIT) или @Async, а не полагайтесь на «оно же в отдельном методе, значит изолировано».
| Критерий | ApplicationListener<E> | @EventListener | @TransactionalEventListener | Внешний брокер (Kafka и т.п.) |
|---|---|---|---|---|
| Способ объявления | Реализация интерфейса, один тип события на класс | Аннотация на методе, гибкий (SpEL-условие, несколько типов) | Аннотация-специализация @EventListener, привязка к фазе транзакции | Продюсер/консьюмер, сериализация сообщений |
| Синхронность по умолчанию | Синхронно, в потоке издателя | Синхронно, в потоке издателя (или @Async) | После коммита, тоже синхронно относительно завершения транзакции (или @Async) | Асинхронно всегда — отдельный процесс-консьюмер |
| Учитывает транзакцию издателя | Нет | Нет (риск отката — см. выше) | Да, по конструкции | Нет — коммит в БД и отправка в брокер требуют отдельного паттерна (outbox) |
| Переживает падение процесса | Нет — только in-memory, в рамках одного JVM | Нет | Нет | Да — сообщение персистентно в брокере |
| Работает между разными сервисами/инстансами | Нет | Нет | Нет | Да — основной сценарий использования |
| Когда выбирать | Редко: нужен явный тип без generic-параметра, легаси-код | Основной выбор для внутрипроцессной развязки | Побочный эффект не должен зависеть от исхода/влиять на исход транзакции | Событие должно пережить рестарт сервиса или уйти в другой сервис/деплой-юнит |
Применять события стоит, когда:
@TransactionalEventListener(AFTER_COMMIT) или @Async;Избегать стоит, когда:
@Order — неявный порядок хрупок и рассыпается при добавлении нового слушателя;Начиная со Spring Framework 5.3.3 есть штатная поддержка записи опубликованных событий в тесте — аннотация @RecordApplicationEvents вместе с автоматически доступным бином ApplicationEvents:
@SpringBootTest
@RecordApplicationEvents
class OrderServiceTest {
@Autowired OrderService orderService;
@Autowired ApplicationEvents events;
@Test
void placingOrderPublishesOrderPlacedEvent() {
orderService.placeOrder(someRequest());
assertThat(events.stream(OrderPlacedEvent.class))
.hasSize(1)
.first()
.extracting(OrderPlacedEvent::order)
.extracting(Order::getStatus)
.isEqualTo(OrderStatus.PLACED);
}
}
Отдельный слушатель без поднятия контекста тестируется как обычный класс — просто вызовом метода с моками зависимостей, без Spring вообще:
@Test
void reservesInventoryOnOrderPlaced() {
InventoryReservationListener listener =
new InventoryReservationListener(inventoryService);
listener.onOrderPlaced(new OrderPlacedEvent(order, Instant.now()));
verify(inventoryService).reserve(order);
}
@Transactional, по умолчанию откатываются после теста — а значит коммита, на который подписан AFTER_COMMIT-слушатель, никогда не происходит, и слушатель не вызывается. Чтобы это протестировать, нужно либо явно закоммитить в тесте (TestTransaction.flagForCommit(); TestTransaction.end(); из spring-test), либо пометить тестовый метод @Commit, либо вообще не оборачивать тест в транзакцию и полагаться на реальный коммит в самом тестируемом коде.