JavaRush /Курси /Spring Core /Події через ApplicationEve...

Події через 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) Оголошуємо факт: далі реакції виконаються в окремих слухачах
    publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}

Зверніть увагу: на цьому кроці сервіс сценарію стає коротшим і чеснішим. Він перестає знати, які реакції взагалі існують. Сьогодні це аудит і сповіщення, завтра — статистика, післязавтра оновлення простого звіту, і сервіс сценарію від цього не змінюється.

Так, після такого рефакторингу в новачка виникає природне питання: «А де тепер аудит і сповіщення, вони що, зникли?» Не зникли. Вони переїдуть у listeners — тобто в окремі біни, які підпишуться на 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 ми публікуємо події тільки на справді значущі факти: замовлення створено, замовлення скасовано. Усе інше поки що залишаємо прямим кодом усередині ядра сценарію.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ