1. Обробник події як бін
Ви вже вмієте публікувати події через ApplicationEventPublisher. Тепер потрібен другий кінець цього дроту: хто приймає подію і як зробити реакції окремими бінами, а не новим комбайном усередині одного класу. У ContextFlow поки що тримаймося лінії ApplicationEvent → типобезпечний ApplicationListener<T>, бо тут зв’язок між фактом і обробником видно максимально чесно.
Коли ми вперше виносимо реакції в події, дуже хочеться зробити «щось одне, але велике»: один обробник, який «сам розбереться», що сталося, а потім викличе аудит, сповіщення й усе інше. На практиці це швидко перетворюється на новий моноліт — тільки тепер не в сервісі сценарію, а в «суперслухачі». У цій лекції ми свідомо підемо іншим шляхом: робитимемо обробники маленькими, типобезпечними та вузько сфокусованими, щоб додавання нової реакції виглядало як додавання нового класу, а не як правка старого «центру всесвіту».
Уявіть, що publishEvent() — це повідомлення в корпоративному чаті: «Замовлення створено». Сервіс сценарію просто надсилає повідомлення до спільного каналу, а далі кожен підписник читає його й робить своє: бухгалтерія пише аудит, маркетинг надсилає сповіщення, аналітика оновлює статистику. Важливо, що відправник не зобов’язаний знати, скільки підписників існує і хто вони.
2. ApplicationListener<T> без магії
Інтерфейс ApplicationListener<T> — це найпряміший спосіб сказати Spring: «Я хочу отримувати події типу T». Тут майже немає прихованих правил і анотаційних спецефектів: є інтерфейс, є один метод, є один параметр. Для навчання це ідеальний варіант: він пояснює механіку подій, а не змушує запам’ятовувати, як правильно ставити анотацію.
Виглядає він концептуально так (спрощено):
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
/**
* Слухач подій Spring.
* Важливо: параметр E задає тип події, на яку підписано обробник.
*/
public interface ApplicationListener<E extends ApplicationEvent> {
/**
* Викликається Spring, коли опубліковано подію відповідного типу.
*/
void onApplicationEvent(E event);
}
Головна цінність тут — тип параметра E. Якщо ви пишете ApplicationListener<OrderCreatedEvent>, то ваш обробник:
1) гарантовано отримає саме OrderCreatedEvent,
2) не робитиме instanceof,
3) не робитиме downcast,
4) не зможе «випадково» почати обробляти не ті події — принаймні, мовчки.
Щоб відчути різницю, корисно один раз побачити, як не треба, — і чому це незручно.
Приклад «не дуже»: слухаємо все й розбираємося через if/else
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Реєструємо слухач як bean, інакше його не буде викликано
public class BigOrderEventsListener implements ApplicationListener<ApplicationEvent> {
@Override
public void onApplicationEvent(ApplicationEvent event) {
// Погано: ми підписалися на ВСІ події застосунку, включно із системними
if (event.getClass().getSimpleName().equals("OrderCreatedEvent")) {
// Погано: ручна маршрутизація за типами всередині одного класу
System.out.println("створено"); // створено
}
}
}
Тут одразу дві проблеми. По-перше, обробник ловить взагалі всі події застосунку, включно з тими, про які ви не думали, а Spring їх публікує чимало. По-друге, логіка визначення типу події перетворюється на ручний аналіз вхідних даних, наче у нас не Java, а пошта без теми листа. Навіть якщо ви зробите instanceof, це все одно буде один клас із розгалуженням і зростанням складності.
Приклад «як треба»: слухаємо конкретний факт типом
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Bean, який буде автоматично знайдено під час component scan
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Тут event уже потрібного типу: ніякого instanceof і приведення типів
System.out.println("замовлення створено: " + event.getOrderId()); // замовлення створено: ord-1
}
}
Тепер тип події є частиною контракту класу. Це читається приблизно так: «AuditOrderCreatedListener слухає OrderCreatedEvent». І так, це саме той рівень самодокументованості, який зазвичай і потрібен.
3. Співставлення слухачів і подій у Spring
Хотілося б вірити, що Spring вгадує ваші думки. Але в нього все прозаїчніше: він дивиться на зареєстровані біни, знаходить серед них слухачів (ApplicationListener<?>) і для кожного перевіряє: «чи підходить цей слухач до типу події, яку ми зараз публікуємо». Якщо підходить — викликає onApplicationEvent(...). Якщо не підходить — не чіпає.
Цю картину зручно тримати в голові як невелику схему. Вона не ідеальна з погляду внутрішньої реалізації фреймворку, але чудово підходить для вашої ментальної моделі:
flowchart TD
A["OrderPlacementService
publisher.publishEvent(...)"] --> B["ApplicationEventPublisher"]
B --> C["Внутрішня розсилка події
(multicaster)"]
C --> D["AuditOrderCreatedListener
ApplicationListener<OrderCreatedEvent>"]
C --> E["NotificationOrderCreatedListener
ApplicationListener<OrderCreatedEvent>"]
C --> F["AuditOrderCancelledListener
ApplicationListener<OrderCancelledEvent>"]
Зверніть увагу: сервіс сценарію знає тільки про ApplicationEventPublisher. Він не знає ні про аудит, ні про сповіщення, ні про те, скільки підписників існує. Саме цього м’якого розв’язання ми й добиваємося.
Ще один важливий практичний нюанс, який часто ловить новачків: слухач — це звичайний bean. Отже, щоб він реально працював, його треба зареєструвати в контейнері — через @Component, компонентне сканування або через @Bean — і розмістити в зоні конфігурації, тобто в пакетах, які скануються чи імпортуються. Якщо ви написали клас і забули зробити його beanʼом, вітаю: ви створили гарний Java-файл, який ніколи не запуститься. Це теж корисна навичка — уміти відрізняти код від коду, який реально підключено до застосунку.
4. Аудит для OrderCreatedEvent
Зараз у нас уже є публікація події із сервісу сценарію, яку ми зробили в попередній лекції. Умовно вона виглядає так:
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service // Сервіс сценарію: фіксує факт і публікує подію
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
// Publisher дає нам «вихід» у подієву модель
this.publisher = publisher;
}
public void publishCreated(String orderId, String customerId) {
// Публікуємо доменну подію (факт), щоб реакції жили окремо
publisher.publishEvent(new OrderCreatedEvent(this, orderId, customerId));
}
}
Тепер додамо обробник, який реагує на створення замовлення і пише аудит. Важливо: слухач не повинен сам уміти писати аудит у файл, формувати audit record і так далі. У нас це вже є в архітектурі: для цього існує AuditService, який уже вміє працювати з AuditWriter і профілями.
Тому слухач — це тонкий перехідник: «подія → виклик сервісу».
import com.example.contextflow.application.service.AuditService;
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Важливо: без реєстрації в контейнері обробник не буде викликано
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
private final AuditService auditService;
public AuditOrderCreatedListener(AuditService auditService) {
// Залежності явно через конструктор — легко тестувати і розуміти
this.auditService = auditService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Слухач не робить аудит сам — він делегує профільному сервісу
auditService.recordCreated(event.getOrderId());
}
}
Тут відбувається одразу кілька правильних речей, і кожна з них робить ваше життя простішим.
По-перше, обробник типобезпечний: метод приймає OrderCreatedEvent, а не щось абстрактне. По-друге, залежності видно через конструктор. По-третє, слухач не зберігає стан: він просто реагує. І нарешті, найважливіше: AuditOrderCreatedListener взагалі не знає, куди саме пишеться аудит — у консоль, у файл, у який формат. Це змінюється профілями й конфігурацією, а слухач залишається тим самим.
Щоб це було зовсім наочно, можна подивитися на AuditService у спрощеному вигляді — саме він залишається власником бізнес-аудиту:
import org.springframework.stereotype.Service;
@Service // Сервіс реакції: тут живе логіка аудиту
public class AuditService {
public void recordCreated(String orderId) {
// Тут може бути запис через AuditWriter, інтеграції, форматування тощо
System.out.println("АУДИТ: створено " + orderId); // АУДИТ: створено ord-1
}
}
У реальному проєкті замість println у вас буде запис через AuditWriter, але логіка «сервіс уміє, слухач викликає» залишиться тією самою.
5. Два слухачі на OrderCreatedEvent
Ось тут починається найприємніше: коли до одного факту можна додати ще одну реакцію, взагалі не чіпаючи сервіс сценарію. У старій прямій моделі ви б повернулися в OrderPlacementService і додали notificationService.sendCreated(...). Ми ж просто додамо новий клас — і все.
З погляду архітектури це нагадує просту думку: «Ми не редагуємо історію, ми додаємо підписника». І це справді зменшує зв’язаність.
import com.example.contextflow.application.service.NotificationDispatchService;
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Ще один незалежний підписник на той самий факт
public class NotificationOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
private final NotificationDispatchService notificationService;
public NotificationOrderCreatedListener(NotificationDispatchService notificationService) {
// Сервіс сповіщень — окрема відповідальність, не частина сценарію створення замовлення
this.notificationService = notificationService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Реакція максимально тонка: подія -> виклик профільного сервісу
notificationService.sendCreated(event.getOrderId());
}
}
Психологічно це дуже важливий момент: подія — це не «обов’язкова магія», а просто спосіб зробити так, щоб ваш use case не перетворився на комбайн із побічних дій.
Тепер для OrderCreatedEvent у нас є щонайменше два незалежні обробники:
- AuditOrderCreatedListener — пише бізнес-аудит;
- NotificationOrderCreatedListener — надсилає сповіщення.
І сервіс сценарію при цьому залишається маленьким і зрозумілим.
Щоб краще побачити ефект, порівняймо залежність сервісу до подій і після подій у вигляді простої схеми:
flowchart TD
subgraph Before["До подій"]
OPS["OrderPlacementService"] --> AS["AuditService"]
OPS --> NS["NotificationDispatchService"]
end
flowchart TD
subgraph After["Після подій"]
OPS2["OrderPlacementService"] --> PUB["ApplicationEventPublisher"]
PUB --> L1["AuditOrderCreatedListener"]
PUB --> L2["NotificationOrderCreatedListener"]
L1 --> AS2["AuditService"]
L2 --> NS2["NotificationDispatchService"]
end
Сервіс сценарію перестав бути «центральним диспетчером усього». Він робить основне: фіксує бізнес-факт і оголошує його. А реакції переїжджають у свої точки входу.
Єдина дисципліна, яку важливо тримати від самого початку: не робити так, щоб один listener залежав від іншого, наприклад «сповіщення можна надіслати тільки після аудиту». Інакше ви знову склеїте систему, тільки хитрішим способом. Якщо вам справді потрібні строгий порядок або умовність, це окрема тема, і ми до неї ще повернемося. Але сьогодні нас цікавить чиста модель «кілька незалежних реакцій».
6. Реакції на OrderCancelledEvent
Коли у нас з’явився один факт (OrderCreatedEvent), майже автоматично з’являється другий: OrderCancelledEvent. І тут важливо не спокуситися зробити один загальний OrderChangedEvent або один загальний слухач, який реагує «на все». Чим конкретніший факт, тим простіші обробники і тим менше розгалуження за контекстом.
Припустімо, у події скасування є orderId і reason. Тоді слухачі виходять такими ж тонкими, як і у випадку створення.
import com.example.contextflow.application.service.AuditService;
import com.example.contextflow.domain.events.OrderCancelledEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Незалежна реакція на скасування: аудит
public class AuditOrderCancelledListener implements ApplicationListener<OrderCancelledEvent> {
private final AuditService auditService;
public AuditOrderCancelledListener(AuditService auditService) {
// Впровадження залежностей: слухач сам не створює сервіси
this.auditService = auditService;
}
@Override
public void onApplicationEvent(OrderCancelledEvent event) {
// Беремо дані з події та делегуємо: не «допитуємо» сервіс сценарію
auditService.recordCancelled(event.getOrderId(), event.getReason());
}
}
І другий слухач — сповіщення:
import com.example.contextflow.application.service.NotificationDispatchService;
import com.example.contextflow.domain.events.OrderCancelledEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Незалежна реакція на скасування: сповіщення
public class NotificationOrderCancelledListener implements ApplicationListener<OrderCancelledEvent> {
private final NotificationDispatchService notificationService;
public NotificationOrderCancelledListener(NotificationDispatchService notificationService) {
// Ще одна незалежна залежність — без зв’язку з аудитом
this.notificationService = notificationService;
}
@Override
public void onApplicationEvent(OrderCancelledEvent event) {
// Подія містить мінімально потрібні поля для реакції
notificationService.sendCancelled(event.getOrderId(), event.getReason());
}
}
Тут легко побачити приємний архітектурний ефект: ви можете додавати будь-які реакції на скасування замовлення — наприклад, «оновити статистику» або «сформувати службове повідомлення» — так само, як підписку на розсилку, не чіпаючи сервіс скасування замовлення.
Саме в цей момент подієва модель перестає бути абстрактною теорією і перетворюється на конкретний інженерний інструмент: додавання функціональності відбувається через додавання нового обробника, а не через переписування основного сценарію.
7. Маленькі слухачі без моноліту
Дуже легко перенести моноліт із сервісу сценарію в слухач. Типовий шлях деградації виглядає так: спочатку слухач викликає один сервіс, потім два, потім там з’являється логіка маршрутизації, потім if/else і додаткові запити до сховища, і за тиждень ви отримуєте OrderEventsMegaListener, який потрібно знову рефакторити.
І щойно слухачі стали окремими бінами, спливає наступне практичне запитання: publishEvent() просто роздає факт «кудись убік» чи реально проходить цих підписників у тому самому виклику? Від відповіді залежать затримка сценарію і поведінка при помилках, тож це запитання краще не залишати за кадром.
Щоб не перенести моноліт у нову точку, корисно заздалегідь домовитися про роль кожного шару.
Ось проста таблиця ролей, яка добре працює саме для ContextFlow в межах курсу:
| Елемент | За що відповідає | За що не відповідає |
|---|---|---|
| OrderPlacementService / OrderCancellationService | Основний сценарій: змінити стан (створити/скасувати), зберегти, опублікувати факт | Не повинен знати, які є реакції і скільки їх |
| Подія (OrderCreatedEvent, OrderCancelledEvent) | Носій факту і мінімально достатніх даних для реакцій | Не має бути «живим» об’єктом, який хтось змінює після публікації |
| Listener (ApplicationListener<T>) | Реакція на конкретний факт: прийняти подію і делегувати роботу | Не повинен ставати другим бізнес-сервісом і «сховищем логіки» |
| Сервіс реакції (AuditService, NotificationDispatchService) | Виконати корисну роботу в межах своєї відповідальності | Не повинен залежати від сервісу сценарію, щоб «спитати подробиці» |
Якщо переносити це на практику, виходять два прості правила.
Перше правило: слухач має бути настільки коротким, щоб його можна було прочитати з першого погляду, без прокручування й без емоційного вигорання. Якщо ваш слухач став довшим, ніж OrderPlacementService, ви десь помилилися на рівні філософії.
Друге правило: слухач має брати все, що йому потрібно, з події. Якщо ви ловите себе на думці: «А зараз я полізу в OrderStore, щоб дізнатися ще 15 полів замовлення» — зупиніться й подумайте. Іноді це справді потрібно, але дуже часто це означає, що подію сформульовано занадто бідно, або що обробник намагається зробити забагато.
8. Типові помилки під час роботи з ApplicationListener<T>
Помилка № 1: слухач написано, але він не став beanʼом.
Найчастіший сценарій: ви створили клас, реалізували ApplicationListener<OrderCreatedEvent>, запустили застосунок — а він не викликається. Причина банальна: немає @Component або реєстрації через @Bean, чи клас лежить поза зоною @ComponentScan або модульної конфігурації. У Spring існує лише те, що зареєстровано в контейнері.
Помилка № 2: один величезний слухач «на все», усередині якого if/else за типами подій.
Здається, що так «менше класів». На практиці це просто новий моноліт: він росте, обростає залежностями, його складніше тестувати й безпечно змінювати. Типобезпечність ApplicationListener<T> якраз і придумана для того, щоб ви ділили реакції за фактами й не писали ручну маршрутизацію.
Помилка № 3: слухач витягує дані не з події, а «допитує» сервіс сценарію.
Коли обробник починає залежати від OrderPlacementService або OrderCancellationService, ви фактично повертаєте зв’язаність назад, тільки прихованіше. Слухач має читати дані з події та делегувати роботу «своєму» сервісу, а не просити publisher пояснити, що взагалі сталося.
Помилка № 4: слухач перетворюється на новий бізнес-сервіс і містить важку логіку.
Дуже спокусливо: «раз подія прилетіла, давайте тут усе й зробимо». Але тоді втрачається сенс розділення. Слухач — це точка входу, а не місце, де ви будуєте великий сценарій. Якщо реакція складна, краще винести її в окремий сервіс і викликати його зі слухача.
Помилка № 5: очікування, що події — це «черга» і все виконується десь у фоні.
Це небезпечна ілюзія: усередині одного застосунку події за замовчуванням доставляються як звичайні виклики обробників, тобто без зовнішнього брокера і без автоматичної асинхронності. Тому обробники мають бути зрозумілими й не перевантаженими. Поки що достатньо тримати одну думку: публікація події за замовчуванням залишається частиною звичайного виклику, а не відлітає в окремий всесвіт.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ