JavaRush /Курсы /Spring Core /События через ApplicationE...

События через ApplicationEventPublisher

Spring Core
17 уровень , 2 лекция
Открыта

1. Точка publishEvent()

Если вы впервые добавляете события в приложение, рука часто тянется вставить publishEvent() «где-нибудь пораньше», чтобы уж точно все обработчики успели отреагировать. И вот тут начинается классическая трагикомедия: вы публикуете OrderCreatedEvent, а потом выясняется, что заказ не сохранился, цена пересчиталась неправильно или вообще бросилось исключение. Событие уже разлетелось — а бизнес-факта по сути нет. Это примерно как написать в общий чат «я уже в отпуске», а потом вспомнить, что заявку вы не отправили.

Чтобы не ловить такие «отпускные» баги в проде (и в учебном проекте тоже), держим простое правило: публикуем событие только после того, как основной шаг сценария действительно завершён.

В терминах ContextFlow это звучит так:

  • «Заказ создан» — значит, заказ уже собран и сохранён в OrderStore и у него есть идентификатор, на который могут ссылаться реакции.
  • «Заказ отменён» — значит, статус изменён на CANCELLED, причина отмены зафиксирована (в доменной модели) и итоговое состояние сохранено в OrderStore.

Удобно представить сценарий как короткую линейку шагов, где событие — это аккуратный маркер между ядром и реакциями:

flowchart TD
    %% publishEvent ставим после фиксации состояния, но до выхода из метода сценария
    A["Сервис сценария: place/cancel"] --> B["Ядро: меняем состояние + сохраняем"]
    B --> C["publishEvent(Бизнес-факт)"]
    C --> D["Реакции: аудит, уведомление, статистика (слушатели)"]

Обратите внимание: мы не обсуждаем слушателей подробно — это следующая лекция. Сейчас нам важна точка publishEvent(): она стоит после изменения состояния и до выхода из метода сценария, чтобы событие было честным «фактом», а не обещанием.

2. ApplicationEventPublisher вместо ApplicationContext

Когда вы видите слово “publisher”, легко решить: «Ну раз события — часть Spring, значит сейчас я внедрю ApplicationContext и оттуда всё опубликую». Технически это работает, но методически (и архитектурно) это почти всегда шаг в сторону антипаттерна service locator. Сегодня нам важно сделать правильно: сервис сценария должен зависеть от узкого контракта, который ему нужен, а не «от всего контейнера сразу».

И ровно для этого Spring даёт интерфейс ApplicationEventPublisher. Он говорит одну простую вещь: «я умею опубликовать событие». Не «я умею выдать тебе любой бин», не «я умею управлять жизненным циклом», а ровно то, что нужно в этот момент.

Посмотрите на мини-сравнение — оно хорошо фиксирует, почему в сервис сценария мы тянем именно publisher:

Что внедряем Что это означает на практике Почему это (не) хорошо
ApplicationEventPublisher «Я умею публиковать события» Узко, честно, не провоцирует getBean()
ApplicationContext «Я знаю про контейнер всё» Слишком широко, соблазняет превращать сервис в “поисковик бинов”
OrderEventsGateway (свой интерфейс) «Я публикую доменные факты через свой порт» Часто хорошо в проде, но в учебном курсе добавляет лишний слой абстракции

В нашем учебном проекте мы выбираем первый вариант: внедряем ApplicationEventPublisher через конструктор (по привычному constructor injection), чтобы зависимость была видимой и минимальной.

Небольшой важный нюанс, который можно принять как факт: ApplicationContext сам умеет быть publisher-ом, поэтому Spring способен подставить ApplicationEventPublisher как зависимость без дополнительной настройки. Это “встроенная возможность” контекста, а не что-то, что мы обязаны руками зарегистрировать через @Bean.

3. Публикация OrderCreatedEvent

Теперь сделаем то, ради чего всё затевалось: добавим публикацию события в сценарий создания заказа. Важно начать с правильной «географии ответственности». Сервис сценария (OrderPlacementService) как раз и является тем местом, которое знает, что создание заказа реально произошло: он инициирует действие, сохраняет результат и возвращает управление наружу. Значит, он же и должен объявить факт событием.

Ни доменная сущность Order, ни OrderStore, ни какой-то “utility”-класс не являются хорошей точкой публикации. Если засунуть публикацию внутрь Order, получится доменная модель, зависящая от Spring (а домен у нас должен жить в Java-мире, а не в org.springframework.*). Если засунуть публикацию в OrderStore, получится «хранилище с побочными эффектами», где событие публикуется не по смыслу сценария, а “по факту сохранения” — и это быстро становится путаницей.

Ниже — минимальный фрагмент сервиса, который публикует событие после сохранения:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    // Внедряем узкий контракт: сервису нужно только публиковать события, а не весь контекст
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void place(Order order) {
        // Здесь специально показана только механика publishEvent — без сохранения (см. следующий пример)
        // В событие кладём минимально достаточные данные для реакций: id заказа и id клиента
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
    }
}

Да, здесь пока нет orderStore.save(order) — я специально показал «голую механику» публикации, чтобы вы не утонули в бизнес-деталях. В реальном коде ContextFlow событие должно появиться после сохранения.

Сделаем пример ближе к правде, но всё ещё компактным. Пусть сервис сохраняет заказ, а затем публикует OrderCreatedEvent:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    // Хранилище фиксирует результат сценария (в учебном проекте это in-memory, но смысл тот же)
    private final OrderStore orderStore;

    // Publisher — точка объявления бизнес-факта наружу
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void place(Order order) {
        // 1) Сначала фиксируем состояние: заказ действительно создан и сохранён
        orderStore.save(order);

        // 2) Потом объявляем факт: "заказ создан" (а не "мы планируем его создать")
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
    }
}

Обратите внимание на две вещи.

Первая — ApplicationEventPublisher внедрён как обычная зависимость. Никакого ApplicationContext, никакого “дайте мне весь контейнер, я сам разберусь”. Это важный “мышечный рефлекс”: вы просите у Spring ровно то, что вам нужно.

Вторая — OrderCreatedEvent создаётся прямо на месте публикации, потому что именно здесь у нас под рукой нужные данные. И это нормально. Когда начинаешь, хочется вынести «создание события» куда-то в отдельный класс OrderEventFactory, но в учебном проекте это часто рождает больше шума, чем пользы. Мы вынесем создание в отдельный метод только если увидим, что код действительно начинает распухать.

source: значение и выбор this

У ApplicationEvent есть обязательный source. В большинстве прикладных сценариев вы не используете source в бизнес-логике вообще, но он полезен для диагностики и чтения логов/дебага: можно понять, кто именно опубликовал событие. Поэтому this (то есть сам OrderPlacementService) — абсолютно нормальный выбор.

Если вы чувствуете внутреннюю боль «я не хочу светить сервис как source», вы можете передать что-то более абстрактное (например, строку "order-placement"), но для учебной прозрачности “this” — самый простой вариант: вы точно знаете, откуда это событие вылетело.

4. OrderCancelledEvent в OrderCancellationService

Отмена заказа — второй основной сценарий проекта. И он интереснее в плане «точки публикации», потому что отмена — это не просто “что-то произошло”, а изменение статуса и часто наличие причины. Для новичка это идеальный пример, чтобы почувствовать: событие — это результат сценария, а не “внутренний шаг”.

Сервис отмены обычно делает две вещи: меняет состояние заказа (через доменную модель) и сохраняет итог (через OrderStore). И только после этого он публикует событие “заказ отменён”.

Минимальная форма:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderCancellationService {
    private final OrderStore orderStore;
    private final ApplicationEventPublisher publisher;

    public OrderCancellationService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void cancel(Order order, String reason) {
        // 1) Сначала меняем доменное состояние
        order.cancel(reason);

        // 2) Затем фиксируем итог (чтобы событие отражало уже сохранённый факт)
        orderStore.save(order);

        // 3) И только потом публикуем событие с минимально нужным payload (id + причина)
        publisher.publishEvent(new OrderCancelledEvent(this, order.getId(), reason));
    }
}

Здесь важно, что publishEvent() идёт после order.cancel(reason) и после сохранения. Для нашего in-memory OrderStore “save” выглядит почти игрушечно, но в mental model курса это играет роль «фиксации результата сценария». Если бы у нас была БД, мы бы думали ещё и о транзакциях, но это уже территория следующих курсов (и мы туда не лезем, чтобы не устроить «экскурсию по соседним дисциплинам»).

Ещё один нюанс: событие должно нести минимально достаточные данные, чтобы обработчики не гадали, что случилось. Для отмены причина — важная часть факта. Она может пойти в аудит и в уведомление. Поэтому класть reason внутрь события — логично.

5. Читаемость сценария: один факт — один publishEvent()

Когда вы почувствовали вкус к событиям, появляется соблазн публиковать их на каждый микрошаг. «Сохранили заказ — событие. Посчитали цену — событие. Выбрали канал уведомления — событие. Перешли в следующий метод — событие». Это выглядит “гибко”, но очень быстро превращается в “event soup”, в котором невозможно понять, что реально является бизнес-фактом.

Чтобы удержать систему в адеквате, нам полезно держать простую дисциплину: в одном методе сценария публикуется одно событие на один завершённый факт. В place()OrderCreatedEvent. В cancel()OrderCancelledEvent. И всё.

Можно даже записать это как микрошаблон сценарного метода:

Часть метода Что здесь происходит Публикуем событие?
Подготовка проверка/сбор данных нет
Ядро сценария изменение состояния + сохранение нет (ещё рано)
Объявление факта publishEvent(...) да
Завершение return / выход нет

Если вы видите в одном методе два publishEvent() подряд — это не обязательно ошибка, но почти всегда повод остановиться и спросить себя: “А я сейчас публикую два разных факта, или я просто дроблю один факт на кусочки?”

Маленький трюк для читаемости: отдельный приватный метод публикации

Если строка publisher.publishEvent(new ...) начинает мешать чтению (например, payload сложный), можно вынести это в приватный метод. Это не делает магию — просто улучшает “визуальный поток” сценария:

public void place(Order order) {
    // 1) Фиксируем состояние
    orderStore.save(order);

    // 2) Публикацию выносим в отдельный метод, чтобы place() читался линейно
    publishCreated(order);
}

private void publishCreated(Order order) {
    // publishEvent — маркер завершённого факта
    publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}

Такой приём особенно полезен, когда метод сценария уже содержит несколько шагов ядра (например, pricing, генерация id, сохранение) и вы хотите, чтобы publishEvent() читался как отдельный «маркер» завершённого факта.

6. Рефакторинг: реакции через события

Сейчас мы делаем важный архитектурный поворот. До событий типичный сервис создания заказа выглядел как «оркестр, который играет на всех инструментах одновременно»: он и сохраняет, и пишет аудит, и отправляет уведомления. Да, так проще стартовать, но это плохо масштабируется: каждая новая реакция добавляет сервису новую зависимость и новую строку кода.

Пример “как было” (сильно упрощённо, но узнаваемо):

public void place(Order order) {
    // Сценарий знает обо всём сразу: и про хранилище, и про аудит, и про уведомления
    orderStore.save(order);

    // Реакции "встроены" в сценарий — сервис разрастается зависимостями
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
}

После введения событий мы начинаем двигаться к виду «сервис фиксирует факт и объявляет его», а реакции живут отдельно:

public void place(Order order) {
    // 1) Фиксируем результат сценария
    orderStore.save(order);

    // 2) Объявляем факт: дальше реакции выполнятся в отдельных listener-ах
    publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}

Обратите внимание: на этом шаге сервис сценария становится короче и честнее. Он перестаёт знать, какие реакции вообще существуют. Сегодня это аудит и уведомления, завтра статистика, послезавтра “обновление простого отчёта” — и сервис сценария от этого не меняется.

Да, после такого рефакторинга у новичка возникает естественный вопрос: «А где теперь аудит и уведомления, они что, исчезли?» Не исчезли. Они переедут в listeners — то есть в отдельные beans, которые подпишутся на OrderCreatedEvent и сделают свою работу. Это будет в следующей лекции, где мы начнём писать обработчики через ApplicationListener<T>.

7. Типичные ошибки публикации

Ошибка №1: публикация события до завершения основного шага.
Самая частая проблема — поставить publishEvent() раньше, чем заказ реально сохранён или статус реально поменялся. Снаружи это выглядит “всё работает”, пока не прилетает исключение в середине метода. Тогда часть реакций могла успеть выполниться, а бизнес-факт не состоялся. Поэтому лучше держать железное правило: событие публикуется после того, как ядро сценария завершено.

Ошибка №2: внедрение ApplicationContext вместо ApplicationEventPublisher.
Новичку кажется удобным “взять контекст, а там разберёмся”, но это почти всегда начало service locator-мышления. Через неделю в сервисе появится context.getBean(...), через две — выбор реализаций вручную, через три — вы начнёте лечить архитектуру «поиском бинов». В сервис сценария внедряем узкий publisher и радуемся, что код остаётся приличным.

Ошибка №3: публикация события из доменной модели (Order).
Иногда хочется сделать красиво: “заказ сам знает, что он отменён, пусть он сам и публикует событие”. Проблема в том, что Order тогда должен знать про Spring и иметь доступ к publisher-у. В результате доменная модель становится framework-зависимой, хуже тестируется и хуже переносится. В нашем курсе домен остаётся plain Java, а публикация — задача application-сервисов.

Ошибка №4: событие тащит «всё подряд» (включая изменяемый Order).
Если в событие положить целый объект заказа, и этот объект где-то потом меняется, слушатели могут увидеть «уже другой заказ» или состояние, которое меняется в процессе. Для первого курса лучше держать событие как маленький неизменяемый снимок факта: идентификаторы и нужные поля, без лишней тяжести.

Ошибка №5: несколько publishEvent() в одном сценарии “на всякий случай”.
Такое обычно начинается с благих намерений: “пусть будет и OrderSavedEvent, и OrderCreatedEvent, и OrderPricedEvent”. Но в итоге читатель кода теряет понимание, где реальный бизнес-факт, а где просто технический шаг. В учебном ContextFlow мы публикуем события только на действительно значимые факты: заказ создан, заказ отменён. Всё остальное пока оставляем прямым кодом внутри ядра сценария.

1
Задача
Spring Core, 17 уровень, 2 лекция
Недоступна
Публикация события после сохранения сертификата
Публикация события после сохранения сертификата
1
Задача
Spring Core, 17 уровень, 2 лекция
Недоступна
RecordingEventPublisher как наблюдаемая точка публикации
RecordingEventPublisher как наблюдаемая точка публикации
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ