1. Точка publishEvent()
Если вы впервые добавляете события в приложение, рука часто тянется вставить publishEvent() «где-нибудь пораньше», чтобы уж точно все обработчики успели отреагировать. И вот тут начинается классическая трагикомедия: вы публикуете OrderCreatedEvent, а потом выясняется, что заказ не сохранился, цена пересчиталась неправильно или вообще бросилось исключение. Событие уже разлетелось — а бизнес-факта по сути нет. Это примерно как написать в общий чат «я уже в отпуске», а потом вспомнить, что заявку вы не отправили.
Чтобы не ловить такие «отпускные» баги в проде (и в учебном проекте тоже), держим простое правило: публикуем событие только после того, как основной шаг сценария действительно завершён.
В терминах ContextFlow это звучит так:
- «Заказ создан» — значит, заказ уже собран и сохранён в OrderStore и у него есть идентификатор, на который могут ссылаться реакции.
- «Заказ отменён» — значит, статус изменён на CANCELLED, причина отмены зафиксирована (в доменной модели) и итоговое состояние сохранено в OrderStore.
Удобно представить сценарий как короткую линейку шагов, где событие — это аккуратный маркер между ядром и реакциями:
flowchart TD
%% publishEvent ставим после фиксации состояния, но до выхода из метода сценария
A["Сервис сценария: place/cancel"] --> B["Ядро: меняем состояние + сохраняем"]
B --> C["publishEvent(Бизнес-факт)"]
C --> D["Реакции: аудит, уведомление, статистика (слушатели)"]
Обратите внимание: мы не обсуждаем слушателей подробно — это следующая лекция. Сейчас нам важна точка publishEvent(): она стоит после изменения состояния и до выхода из метода сценария, чтобы событие было честным «фактом», а не обещанием.
2. ApplicationEventPublisher вместо ApplicationContext
Когда вы видите слово “publisher”, легко решить: «Ну раз события — часть Spring, значит сейчас я внедрю ApplicationContext и оттуда всё опубликую». Технически это работает, но методически (и архитектурно) это почти всегда шаг в сторону антипаттерна service locator. Сегодня нам важно сделать правильно: сервис сценария должен зависеть от узкого контракта, который ему нужен, а не «от всего контейнера сразу».
И ровно для этого Spring даёт интерфейс ApplicationEventPublisher. Он говорит одну простую вещь: «я умею опубликовать событие». Не «я умею выдать тебе любой бин», не «я умею управлять жизненным циклом», а ровно то, что нужно в этот момент.
Посмотрите на мини-сравнение — оно хорошо фиксирует, почему в сервис сценария мы тянем именно publisher:
| Что внедряем | Что это означает на практике | Почему это (не) хорошо |
|---|---|---|
| ApplicationEventPublisher | «Я умею публиковать события» | Узко, честно, не провоцирует getBean() |
| ApplicationContext | «Я знаю про контейнер всё» | Слишком широко, соблазняет превращать сервис в “поисковик бинов” |
| OrderEventsGateway (свой интерфейс) | «Я публикую доменные факты через свой порт» | Часто хорошо в проде, но в учебном курсе добавляет лишний слой абстракции |
В нашем учебном проекте мы выбираем первый вариант: внедряем ApplicationEventPublisher через конструктор (по привычному constructor injection), чтобы зависимость была видимой и минимальной.
Небольшой важный нюанс, который можно принять как факт: ApplicationContext сам умеет быть publisher-ом, поэтому Spring способен подставить ApplicationEventPublisher как зависимость без дополнительной настройки. Это “встроенная возможность” контекста, а не что-то, что мы обязаны руками зарегистрировать через @Bean.
3. Публикация OrderCreatedEvent
Теперь сделаем то, ради чего всё затевалось: добавим публикацию события в сценарий создания заказа. Важно начать с правильной «географии ответственности». Сервис сценария (OrderPlacementService) как раз и является тем местом, которое знает, что создание заказа реально произошло: он инициирует действие, сохраняет результат и возвращает управление наружу. Значит, он же и должен объявить факт событием.
Ни доменная сущность Order, ни OrderStore, ни какой-то “utility”-класс не являются хорошей точкой публикации. Если засунуть публикацию внутрь Order, получится доменная модель, зависящая от Spring (а домен у нас должен жить в Java-мире, а не в org.springframework.*). Если засунуть публикацию в OrderStore, получится «хранилище с побочными эффектами», где событие публикуется не по смыслу сценария, а “по факту сохранения” — и это быстро становится путаницей.
Ниже — минимальный фрагмент сервиса, который публикует событие после сохранения:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Внедряем узкий контракт: сервису нужно только публиковать события, а не весь контекст
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(Order order) {
// Здесь специально показана только механика publishEvent — без сохранения (см. следующий пример)
// В событие кладём минимально достаточные данные для реакций: id заказа и id клиента
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}
}
Да, здесь пока нет orderStore.save(order) — я специально показал «голую механику» публикации, чтобы вы не утонули в бизнес-деталях. В реальном коде ContextFlow событие должно появиться после сохранения.
Сделаем пример ближе к правде, но всё ещё компактным. Пусть сервис сохраняет заказ, а затем публикует OrderCreatedEvent:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Хранилище фиксирует результат сценария (в учебном проекте это in-memory, но смысл тот же)
private final OrderStore orderStore;
// Publisher — точка объявления бизнес-факта наружу
private final ApplicationEventPublisher publisher;
public OrderPlacementService(OrderStore orderStore, ApplicationEventPublisher publisher) {
this.orderStore = orderStore;
this.publisher = publisher;
}
public void place(Order order) {
// 1) Сначала фиксируем состояние: заказ действительно создан и сохранён
orderStore.save(order);
// 2) Потом объявляем факт: "заказ создан" (а не "мы планируем его создать")
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}
}
Обратите внимание на две вещи.
Первая — ApplicationEventPublisher внедрён как обычная зависимость. Никакого ApplicationContext, никакого “дайте мне весь контейнер, я сам разберусь”. Это важный “мышечный рефлекс”: вы просите у Spring ровно то, что вам нужно.
Вторая — OrderCreatedEvent создаётся прямо на месте публикации, потому что именно здесь у нас под рукой нужные данные. И это нормально. Когда начинаешь, хочется вынести «создание события» куда-то в отдельный класс OrderEventFactory, но в учебном проекте это часто рождает больше шума, чем пользы. Мы вынесем создание в отдельный метод только если увидим, что код действительно начинает распухать.
source: значение и выбор this
У ApplicationEvent есть обязательный source. В большинстве прикладных сценариев вы не используете source в бизнес-логике вообще, но он полезен для диагностики и чтения логов/дебага: можно понять, кто именно опубликовал событие. Поэтому this (то есть сам OrderPlacementService) — абсолютно нормальный выбор.
Если вы чувствуете внутреннюю боль «я не хочу светить сервис как source», вы можете передать что-то более абстрактное (например, строку "order-placement"), но для учебной прозрачности “this” — самый простой вариант: вы точно знаете, откуда это событие вылетело.
4. OrderCancelledEvent в OrderCancellationService
Отмена заказа — второй основной сценарий проекта. И он интереснее в плане «точки публикации», потому что отмена — это не просто “что-то произошло”, а изменение статуса и часто наличие причины. Для новичка это идеальный пример, чтобы почувствовать: событие — это результат сценария, а не “внутренний шаг”.
Сервис отмены обычно делает две вещи: меняет состояние заказа (через доменную модель) и сохраняет итог (через OrderStore). И только после этого он публикует событие “заказ отменён”.
Минимальная форма:
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) {
// 1) Сначала меняем доменное состояние
order.cancel(reason);
// 2) Затем фиксируем итог (чтобы событие отражало уже сохранённый факт)
orderStore.save(order);
// 3) И только потом публикуем событие с минимально нужным payload (id + причина)
publisher.publishEvent(new OrderCancelledEvent(this, order.getId(), reason));
}
}
Здесь важно, что publishEvent() идёт после order.cancel(reason) и после сохранения. Для нашего in-memory OrderStore “save” выглядит почти игрушечно, но в mental model курса это играет роль «фиксации результата сценария». Если бы у нас была БД, мы бы думали ещё и о транзакциях, но это уже территория следующих курсов (и мы туда не лезем, чтобы не устроить «экскурсию по соседним дисциплинам»).
Ещё один нюанс: событие должно нести минимально достаточные данные, чтобы обработчики не гадали, что случилось. Для отмены причина — важная часть факта. Она может пойти в аудит и в уведомление. Поэтому класть reason внутрь события — логично.
5. Читаемость сценария: один факт — один publishEvent()
Когда вы почувствовали вкус к событиям, появляется соблазн публиковать их на каждый микрошаг. «Сохранили заказ — событие. Посчитали цену — событие. Выбрали канал уведомления — событие. Перешли в следующий метод — событие». Это выглядит “гибко”, но очень быстро превращается в “event soup”, в котором невозможно понять, что реально является бизнес-фактом.
Чтобы удержать систему в адеквате, нам полезно держать простую дисциплину: в одном методе сценария публикуется одно событие на один завершённый факт. В place() — OrderCreatedEvent. В cancel() — OrderCancelledEvent. И всё.
Можно даже записать это как микрошаблон сценарного метода:
| Часть метода | Что здесь происходит | Публикуем событие? |
|---|---|---|
| Подготовка | проверка/сбор данных | нет |
| Ядро сценария | изменение состояния + сохранение | нет (ещё рано) |
| Объявление факта | publishEvent(...) | да |
| Завершение | return / выход | нет |
Если вы видите в одном методе два publishEvent() подряд — это не обязательно ошибка, но почти всегда повод остановиться и спросить себя: “А я сейчас публикую два разных факта, или я просто дроблю один факт на кусочки?”
Маленький трюк для читаемости: отдельный приватный метод публикации
Если строка publisher.publishEvent(new ...) начинает мешать чтению (например, payload сложный), можно вынести это в приватный метод. Это не делает магию — просто улучшает “визуальный поток” сценария:
public void place(Order order) {
// 1) Фиксируем состояние
orderStore.save(order);
// 2) Публикацию выносим в отдельный метод, чтобы place() читался линейно
publishCreated(order);
}
private void publishCreated(Order order) {
// publishEvent — маркер завершённого факта
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}
Такой приём особенно полезен, когда метод сценария уже содержит несколько шагов ядра (например, pricing, генерация id, сохранение) и вы хотите, чтобы publishEvent() читался как отдельный «маркер» завершённого факта.
6. Рефакторинг: реакции через события
Сейчас мы делаем важный архитектурный поворот. До событий типичный сервис создания заказа выглядел как «оркестр, который играет на всех инструментах одновременно»: он и сохраняет, и пишет аудит, и отправляет уведомления. Да, так проще стартовать, но это плохо масштабируется: каждая новая реакция добавляет сервису новую зависимость и новую строку кода.
Пример “как было” (сильно упрощённо, но узнаваемо):
public void place(Order order) {
// Сценарий знает обо всём сразу: и про хранилище, и про аудит, и про уведомления
orderStore.save(order);
// Реакции "встроены" в сценарий — сервис разрастается зависимостями
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
}
После введения событий мы начинаем двигаться к виду «сервис фиксирует факт и объявляет его», а реакции живут отдельно:
public void place(Order order) {
// 1) Фиксируем результат сценария
orderStore.save(order);
// 2) Объявляем факт: дальше реакции выполнятся в отдельных listener-ах
publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
}
Обратите внимание: на этом шаге сервис сценария становится короче и честнее. Он перестаёт знать, какие реакции вообще существуют. Сегодня это аудит и уведомления, завтра статистика, послезавтра “обновление простого отчёта” — и сервис сценария от этого не меняется.
Да, после такого рефакторинга у новичка возникает естественный вопрос: «А где теперь аудит и уведомления, они что, исчезли?» Не исчезли. Они переедут в listeners — то есть в отдельные beans, которые подпишутся на OrderCreatedEvent и сделают свою работу. Это будет в следующей лекции, где мы начнём писать обработчики через ApplicationListener<T>.
7. Типичные ошибки публикации
Ошибка №1: публикация события до завершения основного шага.
Самая частая проблема — поставить publishEvent() раньше, чем заказ реально сохранён или статус реально поменялся. Снаружи это выглядит “всё работает”, пока не прилетает исключение в середине метода. Тогда часть реакций могла успеть выполниться, а бизнес-факт не состоялся. Поэтому лучше держать железное правило: событие публикуется после того, как ядро сценария завершено.
Ошибка №2: внедрение ApplicationContext вместо ApplicationEventPublisher.
Новичку кажется удобным “взять контекст, а там разберёмся”, но это почти всегда начало service locator-мышления. Через неделю в сервисе появится context.getBean(...), через две — выбор реализаций вручную, через три — вы начнёте лечить архитектуру «поиском бинов». В сервис сценария внедряем узкий publisher и радуемся, что код остаётся приличным.
Ошибка №3: публикация события из доменной модели (Order).
Иногда хочется сделать красиво: “заказ сам знает, что он отменён, пусть он сам и публикует событие”. Проблема в том, что Order тогда должен знать про Spring и иметь доступ к publisher-у. В результате доменная модель становится framework-зависимой, хуже тестируется и хуже переносится. В нашем курсе домен остаётся plain Java, а публикация — задача application-сервисов.
Ошибка №4: событие тащит «всё подряд» (включая изменяемый Order).
Если в событие положить целый объект заказа, и этот объект где-то потом меняется, слушатели могут увидеть «уже другой заказ» или состояние, которое меняется в процессе. Для первого курса лучше держать событие как маленький неизменяемый снимок факта: идентификаторы и нужные поля, без лишней тяжести.
Ошибка №5: несколько publishEvent() в одном сценарии “на всякий случай”.
Такое обычно начинается с благих намерений: “пусть будет и OrderSavedEvent, и OrderCreatedEvent, и OrderPricedEvent”. Но в итоге читатель кода теряет понимание, где реальный бизнес-факт, а где просто технический шаг. В учебном ContextFlow мы публикуем события только на действительно значимые факты: заказ создан, заказ отменён. Всё остальное пока оставляем прямым кодом внутри ядра сценария.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ