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

ContextFlow: listeners для side effects

Spring Core
18 уровень , 4 лекция
Открыта

1. Толстые use-case сервисы

К этому месту уже видны все основные правила дня: listener может быть обычным методом bean-а, событие уместно для реакций на факт, несколько listeners нужно делать предсказуемыми, а publishEvent() по умолчанию синхронный. Теперь просто соберём это в ContextFlow, чтобы use-case сервисы перестали быть складом всех побочных эффектов сразу.

Вот типичный перегруженный вариант сервиса отмены. Это не «ужас-ужас», а вполне естественный код, который появляется первым, если не провести границу ответственности:

import org.springframework.stereotype.Service;

@Service
public class OrderCancellationService {

    public void cancel(Order order) {
        // Core step: меняем состояние заказа и сохраняем
        order.cancel();
        orderStore.save(order);

        // Side effects: реакции на уже случившийся бизнес-факт (отмена)
        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) {
    // Core step: фиксируем факт создания заказа в хранилище
    orderStore.save(order);

    // Side effects будут снаружи: здесь мы только публикуем факт, что заказ создан
    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) {
    // Core step: меняем состояние заказа
    order.cancel();

    // Core step: сохраняем изменения, чтобы слушатели видели консистентное состояние
    orderStore.save(order);

    // Публикуем бизнес-факт "заказ отменён" для всех side effects
    publisher.publishEvent(
        new OrderCancelledEvent(order.getId(), order.getChannel())
    );
}

Здесь мы не теряем контроль над сценарием: по умолчанию listeners синхронны, поэтому исключение из критичного обработчика вернётся наружу через publishEvent(). Код стал чище, но успех и ошибка по‑прежнему остаются наблюдаемыми из cancel().

5. @EventListener: аудит, уведомления, статистика

Теперь самое приятное: переносим side effects туда, где им и место. Здесь легко “пересолить” и сделать один гигантский 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 сервис больше не знает о ней.

Для наблюдаемого demo-baseline зафиксируем и статистику третьей — тогда порядок в консоли будет предсказуемым без гадания.

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) // Для demo-цепочки держим статистику после аудита и уведомлений
    @EventListener
    public void onCancelled(OrderCancelledEvent event) {
        // Здесь event не используется — нам важен сам факт отмены
        statistics.incrementCancelled();
    }
}

В обычном коде статистике порядок нужен только если у вас действительно есть требование к месту этого side effect. Здесь мы фиксируем его просто чтобы собранный call flow читался без гадания.

6. Проверяем call flow и порядок

Теперь просто проверим, что собранный baseline ведёт себя так, как и должен после @Order и sync-by-default доставки: use-case сервис заканчивается только после listeners, а порядок реакций виден прямо в консоли.

Представим, что у нас есть простой сценарный runner, который отменяет заказ:

import org.springframework.stereotype.Component;

@Component
public class ScenarioRunner {

    private final OrderCancellationService cancellation;

    public ScenarioRunner(OrderCancellationService cancellation) {
        // В раннере нам нужен только один use-case сервис для демонстрации потока вызовов
        this.cancellation = cancellation;
    }

    public void runCancel(Order order) {
        System.out.println("before cancel"); // точка "до сценария"
        cancellation.cancel(order);          // внутри publishEvent() вызовутся listeners
        System.out.println("after cancel");  // точка "после сценария"
    }
}

Если listeners временно печатают диагностические строки, вы увидите примерно такой порядок:

before cancel
AUDIT cancelled orderId=o-1
NOTIFY cancelled orderId=o-1 channel=EMAIL
stats.cancelled=1
after cancel

Здесь важно не само println, а порядок строк. after cancel появляется последней, значит publishEvent() дождался listeners, а зафиксированный @Order дал понятную последовательность auditnotificationstatistics.

На схеме это тот же поток, только уже на реальном refactor-е 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(). Для нашего baseline это и хорошо: сценарий остаётся 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 — это не замена нормальному сценарию. Он нужен, чтобы несколько независимых side effects вызывались в понятной последовательности. Если вы начинаете строить через @Order цепочку «сначала шаг 1, потом шаг 2, потом шаг 3» — вероятно, вы уже вынесли core step из use-case сервиса и потеряли читаемость сценария.

Ошибка №5: сделать listeners ленивыми “для оптимизации”.
Ленивые обработчики в таком проекте редко дают реальную пользу, но легко дают странные эффекты: обработчик может оказаться не готов в момент, когда вы публикуете событие, или вы получите неожиданности на старте. Для teaching default лучше держать listeners обычными singleton-beans без @Lazy.

1
Задача
Spring Core, 18 уровень, 4 лекция
Недоступна
Отмена заказа с тремя отдельными listeners
Отмена заказа с тремя отдельными listeners
1
Задача
Spring Core, 18 уровень, 4 лекция
Недоступна
Создание заказа с condition у notification-listeners
Создание заказа с condition у notification-listeners
1
Опрос
События Spring, 18 уровень, 4 лекция
Недоступен
События Spring
Обработка событий в Spring
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ