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 ми публікуємо події тільки на справді значущі факти: замовлення створено, замовлення скасовано. Усе інше поки що залишаємо прямим кодом усередині ядра сценарію.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ