JavaRush /Курси /Spring Core /ContextFlow: listene...

ContextFlow: listeners для побічних ефектів

Spring Core
Рівень 18 , Лекція 4
Відкрита

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 дав зрозумілу послідовність auditnotificationstatistics.

На схемі це той самий потік, тільки вже на реальному рефакторингу 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.

1
Опитування
Події Spring, рівень 18, лекція 4
Недоступний
Події Spring
Обробка подій у Spring
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ