1. Синхронность событий и неожиданные баги
Когда люди впервые слышат “события”, мозг часто автоматически дорисовывает картинку из мира очередей: будто мы “отправили куда-то сообщение”, а оно “когда-нибудь потом” обработается само, где-то в фоне, и можно идти пить чай. В Spring application events (в рамках сегодняшней темы) всё гораздо прозаичнее и, честно говоря, полезнее для понимания: событие — это обычный вызов слушателей, который происходит прямо внутри вашего потока выполнения. Не отдельный сервис, не брокер, не параллельная вселенная.
Это означает очень практическую вещь: когда OrderPlacementService вызывает publisher.publishEvent(...), он ждёт, пока все подходящие ApplicationListener отработают. Если слушатель делает что-то тяжёлое (например, пишет в файл, делает сложную обработку, “ну я тут просто один маленький отчётик посчитаю”), то метод place(...) станет выполняться дольше. А если слушатель бросит исключение, то исключение может “прилететь обратно” в сценарий, и код после publishEvent(...) может вообще не выполниться.
Чтобы это закрепить, дальше мы сделаем то, что разработчики делают чаще всего, когда сомневаются: добавим минимальную трассировку и посмотрим на реальный порядок выполнения. Да, System.out.println — не система логирования мечты, но как учебный “фонарик” он работает прекрасно.
2. publishEvent() в обычном call flow
Самый честный способ понять синхронность — сделать маленький эксперимент в проекте. Мы добавим три сообщения в сервис сценария и по два сообщения в каждом listener’е: “старт” и “конец”. Если события синхронные, то вывод будет строго показывать, что код после publishEvent() выполняется только после того, как обработчики завершились.
Ниже — минимальный вариант OrderPlacementService, где мы намеренно добавили простые печати. Обратите внимание: это не рекомендация “так логировать всегда”, это просто демонстрация “как течёт выполнение”.
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final OrderStore orderStore;
// Публикатор событий: вызов publishEvent() будет выполняться синхронно в текущем потоке
private final ApplicationEventPublisher publisher;
public OrderPlacementService(OrderStore orderStore, ApplicationEventPublisher publisher) {
this.orderStore = orderStore;
this.publisher = publisher;
}
public void place(Order order) {
// Учебная трассировка: хотим увидеть реальный порядок выполнения
System.out.println("place(): 1) save"); // place(): 1) save
orderStore.save(order);
// Важно: publishEvent() НЕ "отправил и забыл", а "вызвал слушателей и дождался"
// Контракт события остаётся тем же: orderId + customerId
System.out.println("place(): 2) publish"); // place(): 2) publish
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
// Эта строка появится только ПОСЛЕ того, как отработают все listeners
System.out.println("place(): 3) after publish"); // place(): 3) after publish
}
}
Теперь добавим listener, который реагирует на OrderCreatedEvent, и тоже сделаем “start/end”. Важно, что listener — отдельный bean, и он не зависит от OrderPlacementService. Он знает только про событие и сервис, который выполняет реакцию.
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
// Реакция вынесена в отдельный сервис: listener остаётся "тонким"
private final AuditService auditService;
public AuditOrderCreatedListener(AuditService auditService) {
this.auditService = auditService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Учебная трассировка: показываем, что listener выполняется синхронно
System.out.println("listener(audit): start"); // listener(audit): start
auditService.recordCreated(event.getOrderId()); // Реальная работа делегирована сервису
System.out.println("listener(audit): end"); // listener(audit): end
}
}
И второй listener — например, уведомления:
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class NotificationOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
// Ещё одна независимая реакция на то же событие
private final NotificationDispatchService notificationService;
public NotificationOrderCreatedListener(NotificationDispatchService notificationService) {
this.notificationService = notificationService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Учебная трассировка: порядок между listeners не гарантирован, но синхронность — да
System.out.println("listener(notification): start"); // listener(notification): start
notificationService.sendCreated(event.getOrderId()); // Отправка уведомления как побочное действие
System.out.println("listener(notification): end"); // listener(notification): end
}
}
Теперь самое интересное: как будет выглядеть вывод. Если запустить сценарий создания заказа, типичная последовательность будет такая (порядок между listeners может отличаться, и на это нельзя опираться, но главный факт сохранится):
place(): 1) save
place(): 2) publish
listener(audit): start
listener(audit): end
listener(notification): start
listener(notification): end
place(): 3) after publish
Ключевая строчка здесь — последняя. place(): 3) after publish появляется после того, как слушатели отработали. То есть publishEvent() — это не “отправил и забыл”, а “вызвал обработчиков и дождался”.
Чтобы закрепить это не только на уровне “внутри метода”, можно посмотреть на уровень выше: в ScenarioRunner. Если он печатает “сценарий завершён” после вызова place(), то вы увидите, что “завершён” печатается тоже только после listeners.
import org.springframework.stereotype.Component;
@Component
public class ScenarioRunner {
private final OrderPlacementService orderPlacementService;
public ScenarioRunner(OrderPlacementService orderPlacementService) {
this.orderPlacementService = orderPlacementService;
}
public void run(Order order) {
// Вызов place() вернётся только после того, как завершится обработка событий
orderPlacementService.place(order);
// Эта строка будет напечатана в самом конце (после всех listeners)
System.out.println("scenario: done"); // scenario: done
}
}
И снова вывод (упрощённо) будет показывать, что scenario: done — в самом конце, потому что place() не вернулся, пока не закончились listeners.
3. Схема ContextFlow после развязки
Когда мы переносим реакции в listeners, проект начинает выглядеть структурно “взрослее”, но при этом проще для чтения. Сервис сценария становится коротким и честным: он делает то, что делает сценарий, и объявляет факт. Всё остальное — отдельные реакции.
Чтобы увидеть это как архитектурную картинку, полезно представить flow создания заказа так:
flowchart TD
Place["OrderPlacementService.place()"]
Store["OrderStore.save(order)"]
Publish["publisher.publishEvent(OrderCreatedEvent)"]
AuditL["AuditOrderCreatedListener"]
NotifL["NotificationOrderCreatedListener"]
AuditS["AuditService.recordCreated()"]
NotifS["NotificationDispatchService.sendCreated()"]
Place --> Store --> Publish
Publish --> AuditL --> AuditS
Publish --> NotifL --> NotifS
Здесь есть важная мысль: OrderPlacementService больше не держит ссылки на аудит и уведомления. Он зависит от OrderStore (потому что без сохранения “заказ создан” не считается правдой) и от ApplicationEventPublisher (потому что ему нужно объявить факт). А уже аудит и уведомления решают: что им делать с этим фактом.
На практике это даёт очень прикладной эффект: когда вы добавляете новую реакцию (например, простую статистику), вы не лезете в OrderPlacementService и не добавляете туда ещё одну зависимость и ещё одну строчку. Вы просто добавляете новый listener. Это похоже на ситуацию “в комнату вошёл человек и сказал: ‘заказ создан’”, а дальше кто-то записал в журнал, кто-то отправил письмо, кто-то обновил счётчик на табло. И никто не заставляет “входящего человека” знать, где у вас журнал и как отправлять письма.
Ещё один бонус — читаемость сценариев. У нас теперь появляется два места для чтения:
Сначала вы читаете use-case сервис и понимаете “что считается выполнением сценария”. Он короткий. Потом вы читаете listeners и понимаете “какие реакции на факт у нас есть”. Это уже другой слой ответственности.
Если бы мы оставили всё прямыми вызовами внутри одного метода, эти слои смешались бы в “суп из кода”. Иногда суп вкусный, но когда там плавают и вилка, и ложка, и половник — становится тревожно.
4. Исключения в listeners: влияние на сценарий
Синхронность означает ещё одну важную вещь: ошибки в обработчиках событий влияют на вызывающий код. Это не “плохое поведение Spring”, это честная модель: если у вас реакция на событие упала, то сценарий, который это событие публиковал, увидит проблему.
Сначала посмотрим “как может быть больно”. Допустим, аудиторский listener (или внутренняя логика AuditService) внезапно бросает исключение. Для демонстрации можно сделать так:
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class BrokenAuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Демонстрация: ошибка в listener по умолчанию "прилетит обратно" в publishEvent()
throw new IllegalStateException("Audit write failed"); // boom
}
}
В таком случае publisher.publishEvent(...) выбросит исключение, и строка place(): 3) after publish из нашего примера не выполнится, если вы исключение не обработаете. Это помогает поймать проблему быстро (fail-fast), но требует осознанного дизайна: нельзя выносить в listeners то, без чего сценарий считается “юридически не завершённым”.
Теперь — важный вопрос “и что с этим делать?”. В рамках сегодняшнего дня мы не уходим в сложные механики доставки событий и не меняем инфраструктуру Spring. Но мы можем зафиксировать два простых (и очень жизненных) подхода, которые уже помогают мыслить правильно.
Первый подход — если реакция действительно “побочная”, но вам не хочется, чтобы она ломала сценарий, вы можете поймать исключение внутри listener’а и аккуратно превратить его в техническое сообщение. Это не идеальная стратегия для всех случаев, но как учебная модель — отличная: вы контролируете влияние ошибки.
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
try {
// Побочная реакция: пытаемся записать аудит
auditService.recordCreated(event.getOrderId());
} catch (RuntimeException ex) {
// Изолируем сбой: сценарий не падает из-за аудита (если это ваша политика)
System.out.println("audit failed: " + ex.getMessage()); // audit failed: ...
}
}
Второй подход — вы можете ловить исключение на стороне сценария, если считаете, что сценарий должен продолжиться даже при проблемах в реакциях. Например, так:
public void place(Order order) {
orderStore.save(order);
try {
// Публикация события синхронная: исключение из listener может упасть сюда
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
} catch (RuntimeException ex) {
// Изоляция ошибки на уровне сценария (использовать осознанно)
System.out.println("place(): event handling failed: " + ex.getMessage());
}
}
Но тут важно не попасть в ловушку: если вы ловите исключения “на всякий случай” везде подряд, вы получаете “тихий режим”, в котором система ломается, но делает вид, что всё хорошо. Поэтому даже этот простой try/catch надо применять с пониманием: вы либо считаете, что реакция действительно побочная, либо считаете, что ошибка должна остановить сценарий.
И вот почему синхронность полезна для начинающих: она заставляет вас честно выбрать, что является частью гарантии сценария, а что — “желательно, но не всегда обязательно”. С асинхронностью этот вопрос тоже существует, просто он становится ещё менее очевидным. А мы сегодня хотим, чтобы всё было максимально наблюдаемо и объяснимо.
Ниже небольшая табличка, чтобы зафиксировать ощущения:
| Где упало исключение | Что происходит по умолчанию | Что вы видите в place() |
|---|---|---|
| В listener’е | Исключение пробрасывается вверх | place() падает на publishEvent() |
| В listener’е, но вы поймали внутри listener’а | Ошибка изолирована | place() продолжает выполнение |
| В place(), вы поймали вокруг publishEvent() | Ошибка изолирована на уровне сценария | Код после publishEvent() выполняется |
5. “Правильный” сценарий после развязки
После развязки событиями ContextFlow становится очень удобным полигоном для понимания архитектуры. Вы открываете OrderPlacementService и видите: вот зависимость на хранилище, вот публикация события. Всё. Это как “скелет” сценария, где нет лишнего мяса.
Условно “чистый” сервис сценария после развязки выглядит так:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
private final OrderStore orderStore;
// События публикуем здесь, а реакции (аудит/уведомления/статистика) живут отдельно
private final ApplicationEventPublisher publisher;
public OrderCancellationService(OrderStore orderStore, ApplicationEventPublisher publisher) {
this.orderStore = orderStore;
this.publisher = publisher;
}
public void cancel(Order order, String reason) {
// Основная часть сценария: меняем состояние доменной модели
order.cancel(reason);
// Основная гарантия сценария: сохраняем изменения
orderStore.save(order);
// Объявляем факт: дальше пойдут синхронные реакции listeners
publisher.publishEvent(new OrderCancelledEvent(this, order.getId(), reason));
}
}
Аудит отмены, уведомление об отмене, статистика отмен — всё это теперь живёт в отдельных слушателях. Это очень важно: у “скелета” сценария меньше причин меняться. Если вы завтра решите, что помимо аудит-записи вам нужна ещё одна реакция (например, написать ещё одну “служебную заметку”), вы не трогаете OrderCancellationService. И это не магия. Это простая инженерная выгода: меньше причин для изменения одного и того же класса.
Но есть и дисциплина, которую нельзя потерять. Поскольку события синхронные, вы должны держать listeners “тонкими” и предсказуемыми. Listener — не место, где вы строите огромный бизнес-процесс, который никто не понимает, потому что он запускается “где-то там” из публикации события. Listener — это, по сути, отдельный вход в кусочек реакции. Хороший listener обычно делает две вещи: берёт данные из события и делегирует работу сервису, который вы и так могли бы вызвать напрямую.
И здесь появляется очень простой “мысленный тест”: если вы читаете код place() и видите publishEvent(), вы должны понимать, что это не “черная дыра”, а “сейчас пойдут реакции, но метод ждёт их завершение”. То есть flow остаётся линейным, просто разнесённым по ответственностям.
Здесь полезно закрепить одно правило, которое снимает большую часть путаницы. Внутри одного Spring-приложения application event — это не “очередь” и не “задача в фоне”. Это скорее “вызов набора обработчиков по типу”, просто организованный контейнером.
Именно поэтому полезно один раз пройти путь через ApplicationEventPublisher и ApplicationListener<T> без сокращений. Более компактная запись обработчиков вроде @EventListener меняет синтаксис, но не меняет сам call flow: факт всё так же публикуется внутри одного ApplicationContext, а реакции по умолчанию выполняются синхронно.
Если вам хочется думать об этом бытовой метафорой, можно представить, что publishEvent() — это как звонок по внутреннему офисному телефону “всем, кому интересно: заказ создан”. Но звонок не заканчивается, пока все не договорили. Это не сообщение в мессенджер, которое прочитают “когда-нибудь”.
На практике это даёт простой критерий для проектирования ContextFlow. В listeners мы складываем побочные действия, которые логически должны происходить “после факта” и которые удобно развязать: аудит, уведомления, статистика. А то, что является частью основного результата сценария, мы оставляем прямыми вызовами: сохранить заказ, поменять статус, выполнить минимально необходимое изменение состояния.
Если держать это в голове, events перестают быть “магией” и превращаются в спокойный инструмент: вы просто разрезали один длинный метод на несколько независимых реакций, но при этом не потеряли линейность исполнения.
6. Типичные ошибки при работе с publishEvent()
Ошибка №1: ожидать асинхронности “по умолчанию” и проектировать listeners как будто они работают в фоне.
Это очень частая ловушка: в listener добавляют тяжёлую работу, а потом удивляются, что метод сценария “вдруг стал медленным”. На самом деле всё честно: publishEvent() ждёт слушателей, поэтому тяжёлая логика в listener делает сценарий тяжёлым. В рамках сегодняшней модели это даже полезно: оно заставляет держать реакции короткими и осмысленными.
Ошибка №2: переносить в listener шаг, без которого сценарий нельзя считать завершённым.
Иногда хочется вынести “во имя красоты” вообще всё, включая сохранение заказа или изменение статуса, и оставить в сценарии одну публикацию события. Это превращает сценарий в загадку: теперь непонятно, кто гарантирует состояние и где именно происходит главное изменение. События нужны, чтобы развязывать реакции на факт, а не чтобы прятать сам факт.
Ошибка №3: не думать про исключения и удивляться, что сценарий падает из-за “побочной реакции”.
Синхронные события означают, что исключение в listener может остановить выполнение метода сценария. Новичок видит stack trace и думает: “почему аудит мешает созданию заказа?”. Ответ простой: вы пока не решили, аудит критичен или нет. Если не критичен, ошибку нужно изолировать (хотя бы простым try/catch в listener). Если критичен, то падение — честный сигнал, что сценарий не должен “делать вид, что всё отлично”.
Ошибка №4: строить listeners так, что они зависят друг от друга и молча предполагают порядок выполнения.
Даже если в конкретном запуске “сначала аудит, потом уведомление”, нельзя превращать это в скрытый контракт. Слушатели должны быть независимыми: каждый реагирует на факт сам по себе. Как только listener начинает полагаться на то, что “кто-то до меня уже сделал X”, система становится хрупкой, а расследование багов превращается в археологию.
Ошибка №5: превращать publishEvent() в “черную дыру” без наблюдаемости.
Когда в проекте много реакций, новичку полезно хотя бы временно иметь простую трассировку (как мы сегодня сделали через System.out.println) или аккуратные технические сообщения, чтобы видеть, что реально происходит при публикации события. Если же всё спрятано, то “события” начинают казаться магией, и доверие к коду падает — а это опаснее любой технической ошибки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ