1. Перевантажені use-case сервіси
Після попередніх кроків уже видно всі головні правила: listener може бути звичайним методом біна, подія доречна для реакцій на факт, кілька listeners варто робити передбачуваними, а publishEvent() за замовчуванням працює синхронно. Тепер зберімо все це в ContextFlow, щоб use-case сервіси перестали бути сховищем усіх побічних ефектів одразу.
Ось типовий перевантажений варіант сервісу скасування. Це не «жах-жах», а цілком природний код, який зʼявляється першим, якщо не провести межу відповідальності:
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
public void cancel(Order order) {
// Основний крок: змінюємо стан замовлення та зберігаємо його
order.cancel();
orderStore.save(order);
// Побічні ефекти: реакції на бізнес-факт, який уже стався (скасування)
auditService.recordCancelled(order.getId());
notificationService.sendCancelled(order.getId(), order.getChannel());
statisticsService.incrementCancelled();
}
}
На перший погляд усе нормально, але смислове навантаження змішане: перші дві строки — це core step, тобто ми справді скасували замовлення, а далі починаються реакції. Поки їх три — ще терпимо. Коли їх стане сім, ви почнете ненавидіти цей метод, хоча він ні в чому не винен.
2. Core step і listeners
Межа тут дуже проста. create і cancel самі по собі залишаються в use-case сервісах: там змінюється стан замовлення і зберігається результат. А audit, notification і statistics — це реакції на вже наявний факт, тому їх виносимо в listeners.
Для скасування цей факт зручно виразити звичайною подією з payload:
import com.example.contextflow.domain.model.NotificationChannel;
// Подія: "замовлення скасовано". Жодної логіки — лише дані, які потрібні обробникам.
public record OrderCancelledEvent(
String orderId, // ідентифікатор замовлення
NotificationChannel channel // канал, у який потрібно надсилати сповіщення
) { }
Для створення замовлення логіка та сама: окремий OrderCreatedEvent з таким самим мінімальним контрактом.
3. OrderPlacementService: core step + подія
Почнімо зі створення замовлення, бо саме там найчастіше й зʼявляється «комбайн»: зберегти, розрахувати ціну, відправити, записати… Ідея рефакторингу проста: усе, що стосується самого факту створення, залишається тут. Усе, що є реакцією, їде в listeners. Ми не робимо «магії» — ми просто переносимо код у доречніше місце, як переїзд у нову квартиру, тільки без коробок і з меншим обсягом лайки.
Ключовий момент: подію публікуємо після того, як core step завершено. Якщо слухачі розраховують, що замовлення вже існує в store, спочатку зберігаємо його, а потім публікуємо подію.
Ось як виглядає «серце» методу після рефакторингу:
import org.springframework.context.ApplicationEventPublisher;
public void place(Order order) {
// Основний крок: фіксуємо факт створення замовлення у сховищі
orderStore.save(order);
// Побічні ефекти будуть зовні: тут ми лише публікуємо факт, що замовлення створено
publisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getChannel())
);
}
Зверніть увагу на психологічний ефект: метод знову читається як один сценарій. Ми «зробили факт» і «оголосили світові, що факт стався». Усе. Жодних «а ще… а ще…».
Щоб цей фрагмент працював, сервісу потрібен ApplicationEventPublisher. Ми впроваджуємо його так само, як і будь-яку іншу залежність — через конструктор:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Видавець подій Spring: через нього ми "повідомляємо" про факти в домені
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
// Впроваджуємо через конструктор, щоб залежність була обов'язковою і явною
this.publisher = publisher;
}
}
Тут не показані інші залежності (OrderStore, pricing тощо) — вони залишаються, просто тепер use-case сервіс не зобов’язаний знати про аудит і сповіщення.
4. OrderCancellationService: core step + подія
Після створення замовлення скасування — це другий сценарій, у якому побічні ефекти традиційно розростаються. І тут важливо не заплутатися: скасування — це не «сповістити» і не «записати аудит». Скасування — це змінити статус замовлення і зберегти зміни. Усе інше — реакції, навіть якщо вони обов’язкові за правилами бізнесу. Обов’язковість не робить реакцію core step, вона лише робить її важливою.
Зробімо метод скасування таким самим струнким: змінюємо стан → зберігаємо → публікуємо OrderCancelledEvent.
import org.springframework.context.ApplicationEventPublisher;
public void cancel(Order order) {
// Основний крок: змінюємо стан замовлення
order.cancel();
// Основний крок: зберігаємо зміни, щоб слухачі бачили узгоджений стан
orderStore.save(order);
// Публікуємо бізнес-факт "замовлення скасовано" для всіх побічних ефектів
publisher.publishEvent(
new OrderCancelledEvent(order.getId(), order.getChannel())
);
}
Тут ми не втрачаємо контроль над сценарієм: за замовчуванням listeners синхронні, тому виняток із критичного обробника повернеться назовні через publishEvent(). Код став чистішим, але успіх і помилка, як і раніше, залишаються спостережуваними з cancel().
5. @EventListener: аудит, сповіщення, статистика
Тепер найприємніше: переносимо побічні ефекти туди, де їм і місце. Тут легко «пересолити» й зробити один гігантський listener «на все». Ми так робити не будемо. У нас буде три обробники, і кожен виконує рівно одну зрозумілу дію. Це зберігає читабельність і допомагає з налагодженням: якщо зламалися сповіщення, ви відкриваєте listener сповіщень, а не шукаєте серед тисячі рядків, де там цей sendCancelled…
Почнімо з аудиту скасування. Винесемо його в AuditOrderEventsListener і зробімо порядок явним: аудит виконується першим.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
private final AuditService audit;
public AuditOrderEventsListener(AuditService audit) {
// Сервіс аудиту — окрема залежність, listener лише викликає його
this.audit = audit;
}
@Order(1) // Явно фіксуємо порядок: аудит має виконатися раніше за інші реакції
@EventListener
public void onCancelled(OrderCancelledEvent event) {
// Реакція на подію: записуємо факт скасування в аудит
audit.recordCancelled(event.orderId());
}
}
Тепер сповіщення. Нам вигідно викликати не конкретного відправника, а наш уже наявний сервіс-оркестратор, наприклад NotificationDispatchService (який сам вибере канал або реалізацію так, як ви робили це на 8-му дні через @Primary, @Qualifier або Map<String, T>).
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class NotificationOrderEventsListener {
private final NotificationDispatchService notifications;
public NotificationOrderEventsListener(NotificationDispatchService notifications) {
// Оркестратор сповіщень сам вирішує, як саме надсилати (EMAIL/SMS/...)
this.notifications = notifications;
}
@Order(2) // Сповіщення йдуть після аудиту (якщо аудит упав — сповіщення не надсилаємо)
@EventListener
public void onCancelled(OrderCancelledEvent event) {
// Реакція: надсилаємо сповіщення про скасування в потрібний канал
notifications.sendCancelled(event.orderId(), event.channel());
}
}
І нарешті статистика. У навчальному проєкті статистика може бути дуже простою: лічильники створених і скасованих замовлень у пам’яті. Головне — показати, що реакцію винесено назовні, а use-case сервіс більше про неї не знає.
Для наочного демо-базового варіанту зафіксуймо й статистику третьою — тоді порядок у консолі буде передбачуваним без ворожіння.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class StatisticsOrderEventsListener {
private final StatisticsService statistics;
public StatisticsOrderEventsListener(StatisticsService statistics) {
// Сервіс статистики — окрема відповідальність
this.statistics = statistics;
}
@Order(3) // Для демо-ланцюжка тримаємо статистику після аудиту й сповіщень
@EventListener
public void onCancelled(OrderCancelledEvent event) {
// Тут event не використовується — нам важливий сам факт скасування
statistics.incrementCancelled();
}
}
У звичайному коді статистиці порядок потрібен лише тоді, коли у вас справді є вимога до місця цього побічного ефекту. Тут ми фіксуємо його просто для того, щоб зібраний потік викликів читався без ворожіння.
6. Перевіряємо потік викликів і порядок
Тепер просто перевірмо, що зібраний базовий сценарій поводиться саме так, як і має після @Order та синхронної доставки за замовчуванням: use-case сервіс завершується лише після listeners, а порядок реакцій видно прямо в консолі.
Уявімо, що в нас є простий сценарний runner, який скасовує замовлення:
import org.springframework.stereotype.Component;
@Component
public class ScenarioRunner {
private final OrderCancellationService cancellation;
public ScenarioRunner(OrderCancellationService cancellation) {
// У runner'і нам потрібен лише один use-case сервіс для демонстрації потоку викликів
this.cancellation = cancellation;
}
public void runCancel(Order order) {
System.out.println("перед скасуванням"); // точка "до сценарію"
cancellation.cancel(order); // всередині publishEvent() викличуться listeners
System.out.println("після скасування"); // точка "після сценарію"
}
}
Якщо listeners тимчасово друкують діагностичні рядки, ви побачите приблизно такий порядок:
перед скасуванням
АУДИТ скасовано orderId=o-1
СПОВІЩЕННЯ надіслано orderId=o-1 channel=EMAIL
статистика.скасовано=1
після скасування
Тут важливий не сам println, а порядок рядків. після скасування з’являється останнім, отже publishEvent() дочекався listeners, а зафіксований @Order дав зрозумілу послідовність audit → notification → statistics.
На схемі це той самий потік, тільки вже на реальному рефакторингу ContextFlow:
flowchart TD
A["OrderCancellationService.cancel()"] --> B["order.cancel(); orderStore.save(order)"]
B --> C["publisher.publishEvent(OrderCancelledEvent)"]
C --> D1["AuditOrderEventsListener (@Order 1)"]
C --> D2["NotificationOrderEventsListener (@Order 2)"]
C --> D3["StatisticsOrderEventsListener (@Order 3)"]
D1 --> E["повернення до publishEvent"]
D2 --> E
D3 --> E
E --> F["повернення до cancel()"]
Помилки в listeners
І з помилками картина така сама: якщо один із listeners викине виняток, він повернеться назад у cancel() через publishEvent(). Для нашого базового сценарію це навіть добре: потік залишається fail-fast, і нам не потрібно гадати, завершився він чи ні.
7. Типові помилки під час роботи з listeners
Коли ви вперше робите такий рефакторинг, проєкт зазвичай починає виглядати чистішим буквально за 10 хвилин — і саме тут криється пастка: хочеться й далі виносити в events усе підряд, поки use-case сервіс не перетвориться на «порожнього публікатора». Краще зупинитися й перевірити себе на кілька типових граблів.
Помилка №1: залишити прямі виклики й одночасно додати listeners (подвійне виконання).
Це найчастіший перехідний баг: ви додали publisher.publishEvent(...), але забули видалити auditService.record... і notificationService.send... із сервісу. У результаті аудит і сповіщення виконуються двічі, і ви кілька годин підозрюєте Spring у чаклунстві, хоча винен лише один зайвий виклик.
Помилка №2: зробити одного універсального listener-монстра на аудит, сповіщення і статистику.
Технічно це працює, але з архітектурного погляду ви просто перенесли комбайн із сервісу в listener. Такий клас швидко обростає умовами і стає складнішим у супроводі, ніж вихідний метод. У навчальному проєкті краще тримати правило: один listener — одна зрозуміла відповідальність.
Помилка №3: публікувати подію занадто рано, ще до завершення core step.
Якщо ви опублікували OrderCancelledEvent, а вже потім зберігаєте замовлення, listener-и можуть прочитати старий стан або взагалі не знайти замовлення в store. Порядок «спочатку змінили стан, потім опублікували факт» — це не естетика, а спосіб зберегти передбачуваність.
Помилка №4: намагатися через @Order побудувати бізнес-процес.
@Order — це не заміна нормальному сценарію. Він потрібен, щоб кілька незалежних побічних ефектів викликалися у зрозумілій послідовності. Якщо ви починаєте будувати через @Order ланцюжок «спочатку крок 1, потім крок 2, потім крок 3», імовірно, ви вже винесли core step із use-case сервісу і втратили читабельність сценарію.
Помилка №5: зробити listeners лінивими “для оптимізації”.
Ліниві обробники в такому проєкті рідко дають реальну користь, зате легко створюють дивні ефекти: обробник може виявитися неготовим у момент, коли ви публікуєте подію, або ви отримаєте несподіванки на старті. Для навчального варіанту краще тримати listeners звичайними singleton-бінами без @Lazy.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ