1. Много слушателей: порядок вызова
Как только side effects уехали из use-case сервиса в listeners, проблема сместилась. Теперь мало просто решить, что вообще выносить в событие: на одно и то же OrderCreatedEvent может откликаться несколько обработчиков, и без явных правил этот кусок быстро становится непредсказуемым.
Когда проект маленький, легко поверить, что “ну слушателей два, как-нибудь”. Но как только у вас появляется аудит, уведомления и статистика (а это почти любой реальный бизнес-сценарий, даже в учебном ContextFlow), одно событие начинает разлетаться в несколько реакций. И если вы не задаёте правила, у команды появляется любимая игра: “угадай, кто отработает первым”.
Проблема не в том, что Spring “плохой” и не умеет порядок. Проблема в том, что без явных правил порядок не обязан быть очевидным. Он может зависеть от того, как контейнер регистрировал бины, как вы меняли пакеты, как вы переименовали классы, и даже от того, как вы рефакторили конфигурацию. Иногда “вроде работает”, но это ровно тот случай, когда стабильность — случайность, а случайность — плохой фундамент.
Представим наш привычный поток в ContextFlow. Сервис опубликовал OrderCreatedEvent, а дальше мы хотим три реакции: записать аудит, обновить статистику, отправить уведомление. Схематично это выглядит так:
flowchart TD
A["OrderPlacementService.publishEvent(OrderCreatedEvent)"] --> B[AuditOrderEventsListener]
A --> C[StatisticsOrderEventsListener]
A --> D[NotificationOrderEventsListener]
Если реакции независимы, вам в целом всё равно, кто из них первый. Но как только появляется хоть один “тонкий” момент — например, вы хотите, чтобы аудит записался до отправки уведомления (чтобы в логах было видно, что мы вообще начали обработку), порядок становится инструментом предсказуемости.
2. @Order: порядок вызова listeners
@Order — это маленькая аннотация с большим психологическим эффектом. Она позволяет сказать Spring: “если вы вызываете несколько обработчиков одного события, пожалуйста, сделайте это в таком порядке”. Важно именно слово “относительный”: @Order(1) не означает “первый во вселенной”, он означает “раньше, чем @Order(2)”.
В Spring правило простое и запоминается почти как “в школе на физре”: меньшее число бежит первым. То есть @Order(1) выполняется раньше, чем @Order(10). Если @Order нет — порядок не должен считаться стабильным.
Вот минимальный пример для ContextFlow: аудит хотим выполнять самым первым, чтобы он “фиксировал факт реакции” ещё до остальных эффектов.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // регистрируем listener как Spring-bean
public class AuditOrderEventsListener {
@Order(1) // чем меньше число — тем раньше будет вызван listener
@EventListener // подписываемся на событие OrderCreatedEvent
public void onCreated(OrderCreatedEvent event) {
// аудит: фиксируем факт обработки до любых внешних эффектов
System.out.println("AUDIT: created " + event.orderId()); // AUDIT: created O-101
}
}
Для статистики дадим следующий приоритет. Пусть она будет второй: аудит уже записался, теперь можно обновить счетчики.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // listener со статистикой
public class StatisticsOrderEventsListener {
@Order(2) // будет вызван после @Order(1)
@EventListener // слушаем то же событие
public void onCreated(OrderCreatedEvent event) {
// здесь могли бы обновляться метрики/счетчики
System.out.println("STATS: +1 created"); // STATS: +1 created
}
}
И уведомления — третьими. Логика не “единственно правильная”, но часто удобная: аудит и статистика — внутренняя кухня, уведомление — внешний шум. Пусть внешний шум идёт после внутренней фиксации.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // listener для "внешнего эффекта" (уведомления)
public class NotificationOrderEventsListener {
@Order(3) // отправляем уведомления после аудита и статистики
@EventListener
public void onCreated(OrderCreatedEvent event) {
// обычно здесь будет интеграция: email/sms/очередь и т.п.
System.out.println("NOTIFY: " + event.channel()); // NOTIFY: EMAIL
}
}
Чтобы это не выглядело как “магические числа”, полезно держать в голове небольшую табличку смыслов. Она не про закон, а про удобство чтения:
| @Order | Что это значит в голове | Типичный смысл в событиях |
|---|---|---|
| 1 | “сначала зафиксировать” | аудит, критичная диагностика |
| 2 | “обновить внутренние агрегаты” | статистика, счетчики, кэш |
| 3 | “сделать внешние эффекты” | уведомления, отправки, вывод |
Теперь важный методический момент. @Order — не механизм построения бизнес-процесса. Если вы начинаете думать “а давайте сделаем 12 шагов сценария через 12 listeners и выстроим их @Order”, то поздравляю: вы изобрели “скрытый workflow-движок”, который неудобно читать, трудно тестировать и почти невозможно объяснить новичку (включая вас через три месяца).
3. condition в @EventListener: фильтрация событий
Иногда проблема не в порядке, а в том, что слушателей слишком много, и часть из них должна срабатывать только при определённых обстоятельствах. Самый простой пример из ContextFlow — канал уведомления. Если событие содержит channel = NotificationChannel.EMAIL, мы хотим отправить e-mail, а если NotificationChannel.SMS — SMS. И хочется, чтобы это было видно прямо из объявления listener-а, а не пряталось в if внутри метода.
Для этого у @EventListener есть параметр condition. Это SpEL-выражение (Spring Expression Language), которое вычисляется перед вызовом метода. Если условие ложно, метод просто не вызывается. По смыслу это похоже на “декларативный if”, только в аннотации.
У нас channel — это NotificationChannel, поэтому в условии удобнее сравнивать name() enum-а. Само событие от этого обратно в строковый мешок не превращается.
Пример: отдельный listener, который реагирует на создание заказа только если канал — EMAIL.
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // отдельный обработчик только для email-канала
public class EmailOrderEventsListener {
@EventListener(condition = "#event.channel.name() == 'EMAIL'") // SpEL-фильтр до вызова метода
public void onCreated(OrderCreatedEvent event) {
// сюда попадем только если channel == EMAIL
System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
}
}
Обратите внимание на “переменную” #event. В condition Spring даёт доступ к объекту события, и мы можем читать его поля, как будто пишем обычный код. Если ваш метод принимает параметр OrderCreatedEvent event, то выражение #event.channel выглядит почти как Java — и это хороший признак: условие должно быть коротким и читаемым.
Можно сделать условие и на отрицание. Например, вы решили, что “консольный” канал — это dev-штука, и в статистику такие события не считаем (потому что иначе dev-запуски портят цифры).
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // статистика только по "боевым" событиям
public class ProductionLikeStatisticsListener {
@EventListener(condition = "#event.channel.name() != 'CONSOLE'") // отбрасываем dev/локальный канал
public void onCreated(OrderCreatedEvent event) {
// важно: этот код не выполнится для channel == CONSOLE
System.out.println("STATS: counted " + event.orderId()); // STATS: counted O-101
}
}
А ещё условие может быть не только “равно/не равно”. Например, иногда удобно фильтровать demo/test события по формату id. Это не лучший дизайн для продакшена, но в учебном проекте может помочь сделать поведение наглядным.
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // аудит только для demo-заказов
public class DemoOnlyAuditListener {
@EventListener(condition = "#event.orderId.startsWith('DEMO-')") // фильтр по префиксу id
public void onCreated(OrderCreatedEvent event) {
// попадем сюда только если orderId начинается с "DEMO-"
System.out.println("DEMO AUDIT " + event.orderId()); // DEMO AUDIT DEMO-1
}
}
Здесь важное ограничение: если вы чувствуете, что условие в condition становится длиннее пары простых проверок, это почти всегда сигнал, что вы пытаетесь засунуть бизнес-логику “в строку”. Лучше вернуться к нормальному Java-коду: либо сделать разные типы событий, либо вынести решение в сервис, либо перегруппировать listeners.
4. Практика: @Order + condition
Аккуратная дисциплина вместо “event soup”
Когда вы ставите @Order и condition вместе, очень легко получить две крайности. Первая — “у нас нет правил, всё случайно”. Вторая — “у нас 28 listeners, и каждый с condition на 4 строки, а порядок — как расписание поездов”. Нам нужна золотая середина: ровно столько механики, чтобы поток был предсказуемым и читаемым.
Представим практический и “живой” вариант для ContextFlow. Аудит должен происходить всегда и первым. Статистика — всегда и второй. Уведомления — третьи, но причём уведомления бывают разные: e-mail и SMS.
Сразу оговорюсь: следующий split на Email/Sms listeners — это демонстрационная ветка, чтобы condition было видно максимально явно. В основном baseline ContextFlow этого дня уведомления по-прежнему удобно держать в одном NotificationOrderEventsListener, который уже внутри делегирует в NotificationDispatchService.
Вот как это может выглядеть с комбинированием:
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class SmsOrderEventsListener {
@Order(3)
@EventListener(condition = "#event.channel.name() == 'SMS'")
public void onCreated(OrderCreatedEvent event) {
System.out.println("SMS: " + event.orderId()); // SMS: O-101
}
}
И симметричный обработчик для e-mail (тот же порядок, то же событие, но другое условие):
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class EmailOrderEventsListener {
@Order(3)
@EventListener(condition = "#event.channel.name() == 'EMAIL'")
public void onCreated(OrderCreatedEvent event) {
System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
}
}
Тут есть тонкость, которую полезно проговорить. Если оба listener-а имеют одинаковый @Order(3), то между ними порядок может быть не фиксирован. Но так как условия взаимоисключающие (канал не может быть одновременно EMAIL и SMS), нам порядок между ними и не нужен. Главное — что они идут после аудита и статистики.
Получается довольно читаемая картина: сначала фиксируем, потом считаем, потом уведомляем. И при этом “ветка уведомления” выбирается естественно по условию, не превращая основной use case сервис в “диспетчера всего на свете”.
Мини-инкремент в ContextFlow
Очень хочется, чтобы @Order воспринимался как “ну да, работает, просто поверь”. Но новичку (и честно говоря, не только новичку) полезно один раз увидеть это в выводе консольного запуска. Сделаем маленький наблюдаемый пример: сервис публикует событие, а обработчики печатают строки.
Пусть у нас есть такой вызов в сценарии (упрощённо, без доменной детали):
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class DemoOrderPublisher {
private final ApplicationEventPublisher publisher;
public DemoOrderPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void publishCreated(String orderId, NotificationChannel channel) {
publisher.publishEvent(new OrderCreatedEvent(orderId, channel));
}
}
И где-то в ScenarioRunner (или временно в demo-сценарии) вы вызываете:
demoOrderPublisher.publishCreated("O-101", NotificationChannel.EMAIL);
Если у вас стоят listeners из предыдущих примеров с @Order(1), @Order(2) и @Order(3), то вывод будет идти в этом порядке. Например, в максимально простом варианте:
AUDIT: created O-101
STATS: +1 created
EMAIL: O-101
Это важное ощущение: вы не “надеетесь на порядок”, вы его декларируете. И когда кто-то через неделю добавит новый listener, ему придётся либо тоже явно обозначить порядок, либо принять, что его обработчик не имеет требований к позиции в цепочке.
5. Типичные ошибки при работе с @Order и condition
Ошибка №1: полагаться на “случайный порядок”, потому что “и так работает на моём компьютере”.
Это особенно коварно, когда проект ещё маленький: кажется, что аудит всегда печатается первым, потому что “так получилось”. Но после рефакторинга пакетов, добавления нового bean-а или перестройки конфигурации порядок может измениться. Если порядок действительно важен — его нужно объявить через @Order.
Ошибка №2: перепутать направление порядка (думать, что 10 раньше 1).
Эта ошибка встречается чаще, чем хочется признать. В Spring меньшая цифра — выше приоритет. Если у вас @Order(10) на аудите и @Order(1) на уведомлении, то уведомление будет раньше аудита, и вы будете долго искать “почему логи выглядят странно”, хотя ответ спрятан в одной цифре.
Ошибка №3: пытаться построить основной бизнес-процесс через @Order.
Когда сценарий “создать заказ” разъезжается по десятку listener-ов, связь между шагами становится неявной. Да, оно может работать, но читать это сложно: основной use case сервис превращается в “публикатора события”, а логика разбросана по обработчикам. @Order должен помогать упорядочить несколько независимых side effects, а не заменять собой нормальный дизайн сценария.
Ошибка №4: превращать condition в полноценную программу на SpEL.
Короткое условие вроде #event.channel.name() == 'EMAIL' читается нормально. А вот выражение на полэкрана, которое вычисляет что-то хитрое, — это почти всегда “плохой запах”. В такой ситуации лучше перенести логику в Java-код: либо сделать отдельный listener и отдельный тип события, либо вынести решение в сервис, а condition оставить как простой фильтр.
Ошибка №5: забыть, что condition выполняется до вызова метода, и “случайный null” может сломать выражение.
Если вы пишете #event.channel.name().startsWith('E'), а channel внезапно оказался null, то выражение может упасть ещё до вашего кода. Поэтому либо держите данные события корректными и непустыми, либо пишите условия так, чтобы они были устойчивыми (например, сначала проверяйте на null), но не превращайте это в спагетти.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ