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 дал понятную последовательность audit → notification → statistics.
На схеме это тот же поток, только уже на реальном 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ