← Назад к списку тем

04. Event Listener'ы

ApplicationEventPublisher и @EventListener, механика EventListenerMethodProcessor и ApplicationEventMulticaster, @Async- и @TransactionalEventListener, порядок и условная фильтрация событий, когда события — правильный инструмент, а когда антипаттерн.

Зачем это нужно: проблема сильной связанности сервисов

Типичный сценарий роста бизнес-логики: оформление заказа изначально делает одно — сохраняет заказ. Через полгода к этому добавляется резервирование склада, отправка письма-подтверждения, начисление бонусных баллов, событие для аналитики, уведомление в 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 публикует факт «заказ оформлен» и не знает, кто и сколько подписчиков на него отреагирует.

⚠️ Это не «серебряная пуля» против связанности вообще. События снимают связанность на уровне кода (публикующий сервис не импортирует слушателей), но не снимают связанность на уровне данных — слушатель всё равно должен понимать структуру события. И, как будет видно в разделе про механику, наивная публикация события внутри транзакции создаёт свою собственную скрытую связанность — сбой в слушателе может откатить транзакцию издателя. Раздел «Когда применять» ниже — не факультативный.

Базовое использование: ApplicationEventPublisher + @EventListener

С версии 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());
    }
}
🔑 Legacy-вариант ещё встречается в старом коде. До 4.2 событие обязано было наследовать ApplicationEvent, а слушатель — реализовывать ApplicationListener<E> с методом onApplicationEvent(E event). Оба стиля работают и сегодня и полностью совместимы друг с другом внутри одного приложения; ApplicationEvent просто добавляет готовые getTimestamp()/getSource(), а @EventListener — гибкость (SpEL-условия, несколько типов событий на одном методе, не нужен интерфейс).

Механика внутри: EventListenerMethodProcessor и ApplicationEventMulticaster

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 — их туда превращает отдельный механизм:

  1. При старте контекста регистрируется EventListenerMethodProcessorSmartInitializingSingleton, который отрабатывает после создания всех синглтонов;
  2. Он проходит по методам каждого бина в поисках @EventListener (и его специализаций, включая @TransactionalEventListener — см. ниже);
  3. Каждый найденный метод оборачивается в ApplicationListenerMethodAdapter, который реализует ApplicationListener<ApplicationEvent> и внутри рефлективно вызывает исходный метод через бин, полученный из контекста (то есть через прокси, если он есть);
  4. Адаптер регистрируется в том же ApplicationEventMulticaster, что и «ручные» реализации ApplicationListener.
🔑 Самовызов здесь не проблема — в отличие от @Async/@Transactional/@Retryable. Метод с @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}) допустимо, если у метода нет параметров (тип события тогда недоступен внутри) либо параметр объявлен общим супертипом обоих событий — удобно для сквозной логики вроде инвалидации кэша при любом изменении заказа.

Асинхронные слушатели: @Async на @EventListener

Раз мультикастер по умолчанию вызывает слушателей синхронно в потоке издателя, тяжёлый или медленный слушатель (внешний HTTP-вызов, отправка письма) удерживает этот поток на всё время своей работы — в том числе поток, обслуживающий HTTP-запрос пользователя. Решение — обычный @Async поверх @EventListener (после @EnableAsync, см. тему 02 про экзекьюторы):

@Component
class AnalyticsListener {

    @Async
    @EventListener
    void onOrderPlaced(OrderPlacedEvent event) {
        // уходит в applicationTaskExecutor, не блокирует поток publishEvent()
        analyticsClient.track("order_placed", event.order().getId());
    }
}
🚨 void + @Async — исключения слушателя пропадают бесследно. У асинхронного метода нет возвращаемого Future, через который можно было бы получить исключение вызывающей стороной — поэтому если analyticsClient.track() бросит исключение, оно просто уходит в дефолтный SimpleAsyncUncaughtExceptionHandler, который логирует его на уровне WARN и всё. Без явного мониторинга по логам такой сбой можно не заметить месяцами. Регистрируйте собственный AsyncUncaughtExceptionHandler через AsyncConfigurer, который хотя бы инкрементирует метрику ошибок — как и с @Scheduled (см. тему 02), молчаливое логирование — не мониторинг.
⚠️ Контекст запроса не переживает переход в другой поток. RequestContextHolder, MDC-поля логирования (traceId, requestId) — всё это привязано к потоку и не копируется автоматически в поток, куда @Async перекладывает выполнение. Слушатель, ожидающий увидеть тот же MDC, что был у вызывающего HTTP-запроса, увидит пустой контекст. Чтобы прокинуть MDC — нужен TaskDecorator на экзекьюторе, который явно копирует контекст в новый поток перед выполнением задачи.

@TransactionalEventListener: привязка к фазам транзакции

У обычного @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.

Продвинутые возможности: собственный мультикастер и generic-события

Общий 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;
  • нужно точечно расширить поведение модуля без правки его кода — новый слушатель подключается, ничего не трогая в издателе (полезно на границах модулей монолита, в духе Spring Modulith).

Избегать стоит, когда:

  • реакция обязана быть частью той же атомарной транзакции, что и издатель, и без неё вся операция должна считаться неуспешной — тогда это часть основной бизнес-логики, а не побочный эффект, зовите напрямую, а не через событие;
  • порядок выполнения нескольких слушателей критичен для корректности и не выражен явно через @Order — неявный порядок хрупок и рассыпается при добавлении нового слушателя;
  • событие должно пережить рестарт сервиса, дойти до другого сервиса/инстанса или быть повторно обработано после сбоя — задача не для in-process событий, а для персистентного брокера сообщений с паттерном transactional outbox;
  • цепочка «событие → слушатель публикует новое событие → следующий слушатель» разрастается настолько, что бизнес-поток невозможно проследить по коду одним чтением сверху вниз («event soup») — иногда явный последовательный вызов читается и отлаживается проще, чем красиво развязанная, но неотслеживаемая цепочка событий.

Как тестировать

Начиная со 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);
}
⚠️ @TransactionalEventListener(AFTER_COMMIT) в тесте с @Transactional попросту не сработает. Тестовые методы, аннотированные @Transactional, по умолчанию откатываются после теста — а значит коммита, на который подписан AFTER_COMMIT-слушатель, никогда не происходит, и слушатель не вызывается. Чтобы это протестировать, нужно либо явно закоммитить в тесте (TestTransaction.flagForCommit(); TestTransaction.end(); из spring-test), либо пометить тестовый метод @Commit, либо вообще не оборачивать тест в транзакцию и полагаться на реальный коммит в самом тестируемом коде.