JavaRush /Курсы /Spring Core /Где полезны события в сценариях

Где полезны события в сценариях

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

1. Сервис сценария: дирижёр и комбайн

Если посмотреть на ContextFlow со стороны архитектуры, то наши OrderPlacementService и OrderCancellationService — это не “сервисы вообще”, а сервисы сценариев. Они дирижируют процессом: собирают данные, меняют состояние, вызывают нужные зависимости. И это нормально. Проблема начинается, когда дирижёр внезапно решает сам играть на всех инструментах оркестра — и ещё продавать билеты у входа.

В реальном приложении сервис сценария почти всегда стартует “чисто”: он делает одну-две вещи, выглядит аккуратно и даже внушает оптимизм. Но потом появляется аудит (и он “обязательно нужен”), затем уведомления (“а давайте сразу SMS и email”), затем статистика (“нам нужен отчёт на дашборд”), затем ещё один побочный эффект (“ну это быстро, просто вызови методик”). И вот уже сервис сценария знает слишком много и зависит от половины приложения.

Давайте посмотрим на типичную картину “до событий” — когда мы честно вызываем всё напрямую.

Представим, что сценарий создания заказа выглядит так (упрощённо, без деталей домена):

public void placeOrder(CreateOrderCommand cmd) {
    // 1) Создаём доменную модель заказа из входной команды
    Order order = orderFactory.create(cmd);

    // 2) Сохраняем заказ — это часть ядра сценария (без этого факта "заказ создан" нет)
    orderStore.save(order);

    // 3) Дальше идут реакции на уже произошедший факт (не ядро сценария)
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
}

С точки зрения результата бизнес-сценария мы сделали “всё правильно”: заказ сохранили, аудит записали, уведомление отправили. Но архитектурно здесь уже спрятана проблема: OrderPlacementService знает, что после создания заказа обязательно нужно вызвать аудит и уведомления, а значит он напрямую связан с конкретными сервисами и их контрактами. Любое новое “после создания заказа ещё надо…” заставит править именно этот сервис.

2. Основной сценарий и реакции

Чтобы понять, где событие уместно, нам сначала нужно договориться о терминах. Иначе мы будем спорить в стиле “а аудит — это важное или побочное?”, а потом всё равно сделаем как в таске, но с чувством вины. В этой лекции нас интересует простая и очень практичная граница: что является ядром сценария, а что является реакцией на уже свершившийся факт.

Под “основным сценарием” мы будем понимать шаги, без которых бизнес-факт не считается состоявшимся. Для создания заказа это обычно выглядит так: мы собрали заказ, присвоили ему id, рассчитали сумму, сохранили. Можно спорить о порядке и деталях, но смысл один: если заказ не сохранён и не перешёл в ожидаемое состояние, то говорить “заказ создан” рано.

Под “побочным действием” мы будем понимать реакцию на факт, который уже произошёл. Например, “заказ создан” — факт, а “запиши аудит”, “уведомь клиента”, “увеличь счётчик статистики” — реакции. И здесь важный нюанс: побочное действие не обязательно “неважное”. Аудит может быть критически важным для бизнеса, но он всё равно может быть реакцией на факт создания заказа, а не частью механики “создать заказ как объект”.

Чтобы было проще держать это в голове, полезно один раз увидеть сравнение в таблице.

Вопрос Основной сценарий Побочное действие (реакция)
Про что это? “Сделать так, чтобы факт случился” “Отреагировать на факт”
Кто должен об этом знать? Сервис сценария (дирижёр) Любые отдельные “реакторы”: аудит, уведомления, статистика
Что будет, если этого не сделать? Факт не состоялся Факт состоялся, но система не полностью отреагировала
Что обычно происходит при расширении требований? Редко меняется, потому что ядро стабильнее Растёт постоянно (“ещё одно уведомление”, “ещё один отчёт”)

В ContextFlow естественные факты, вокруг которых всё крутится, — это “заказ создан” и “заказ отменён”. Именно такие факты — лучшие кандидаты на события. Не “мы начали создавать заказ”, не “мы поставили статус NEW”, не “мы рассчитали сумму” (хотя в сложных доменах и такое бывает), а именно понятные вещи, которые читаются как завершённые бизнес-утверждения.

3. Прямые вызовы повышают связность

Теперь вернёмся к нашему “комбайну” и сформулируем, что именно в нём начинает болеть. Важно: это не “эстетика” и не “чистая архитектура ради лайков”. Боль здесь практическая: такие сервисы тяжело менять, тяжело тестировать и тяжело расширять без случайных побочных эффектов. А ещё они растят зависимость на зависимость, как дерево зависимостей растит новые ветки — и однажды вы не сможете проследить, где там ствол.

Посмотрим на более реалистичный фрагмент: сервис сценария знает про хранилище заказов, про ценообразование, про аудит, про уведомления, и (в какой-то момент) про статистику.

public void placeOrder(CreateOrderCommand cmd) {
    // Ядро сценария: собрать заказ, посчитать сумму, сохранить
    Order order = orderFactory.create(cmd);
    Money total = pricingService.calculateTotal(order);

    // Обновляем модель заказа рассчитанной суммой
    order = order.withTotal(total);

    // Сохранение — часть определения факта "заказ создан"
    orderStore.save(order);

    // Реакции на факт: они не меняют сам результат "заказ создан", а дополняют систему
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
    statisticsService.onOrderCreated(order.getId());
}

На уровне “работает” — отлично. Но на уровне “архитектура” у нас появляются три типичных последствия.

Первое последствие — эффект снежного кома. Любая новая реакция (“а давайте ещё писать в файл”, “а давайте ещё в отчёт”, “а давайте ещё в локализованное сообщение”) приводит к новой зависимости и новому вызову в этом методе. И в какой-то момент метод превращается в сериал: 12 сезонов, 300 серий, и вы не помните, кто кому брат.

Второе последствие — жёсткая связанность: сервис сценария зависит от конкретных реакций. Даже если аудит и уведомления оформлены красивыми сервисами, сам факт “кто реагирует” зашит в сценарии. Сервис сценария становится местом, где “сходятся все провода”.

Третье последствие — сложность тестирования. Чтобы протестировать “создание заказа сохраняет заказ”, вам нужно либо поднимать кучу зависимостей, либо мокать аудит, уведомления и статистику, хотя тест вообще не про это. В итоге тесты становятся хрупкими: поменяли реакцию — сломались тесты создания заказа.

4. Прямой вызов: честный и простой

В этот момент у новичков часто рождается соблазн: “О, тогда давайте всё делать через события!” И это отличный способ заменить одну проблему другой, просто более модной. Прямой вызов — не зло, а нормальный инженерный инструмент. Он честный, прозрачный и хорошо читается, когда зависимость действительно участвует в основном результате сценария.

Прямой вызов особенно уместен, когда сервис сценария не может завершить работу без результата зависимости. Например, без OrderStore заказ не будет сохранён. Без генерации id заказ не станет валидным объектом. Без расчёта цены вы не сможете сформировать итоговую сумму. Это всё элементы ядра сценария — их не хочется прятать за событиями, потому что тогда сам сценарий станет “неявным”.

В ContextFlow ядро сценария создания заказа может выглядеть примерно так:

public Order placeOrder(CreateOrderCommand cmd) {
    // Создание доменного объекта — часть сценария
    Order order = orderFactory.create(cmd);

    // Сохранение — часть определения факта "заказ создан"
    orderStore.save(order);

    // Возвращаем результат сценария вызывающему коду
    return order;
}

Здесь можно спорить, где считать цену и когда назначать id, но ключевой момент простой: orderStore.save(order) — это не реакция, это суть сценария. Если “сохранение заказа” вы вынесете в реакцию на событие, то сценарий превратится в “мы как бы создали заказ, но реально он ещё не создан, пока кто-то там что-то не сделал”. И это уже архитектурный туман, который выглядит красиво только в презентации.

В итоге полезное правило звучит скучно, но работает: если шаг является частью определения факта (“заказ создан”), то пусть он остаётся прямым вызовом. Если шаг является реакцией на факт (“после создания заказа сделай ещё вот это”), то это кандидат на развязку.

5. Событие: один факт и реакции

Теперь переходим к месту, ради которого вообще затеваются события. Они полезны там, где один факт должен запустить несколько реакций, а сервис сценария не должен знать ни список этих реакций, ни их порядок, ни то, сколько их вообще существует. Сервис сценария должен сделать своё дело и объявить: “Факт произошёл”. А дальше пусть система реагирует.

На человеческом языке это похоже на объявление в офисе: “Коллеги, заказ создан”. Кто-то услышал и пошёл писать аудит. Кто-то услышал и пошёл отправлять уведомление. Кто-то услышал и обновил статистику. При этом человек, который создал заказ, не бегает по кабинетам с криком “Петя, запиши аудит! Маша, отправь SMS! Вася, посчитай статистику!”. Он сделал действие и сообщил о факте.

Если мы остаёмся в мире прямых вызовов, то “объявление” обычно выглядит как цепочка вызовов:

// Ядро сценария
orderStore.save(order);

// Реакции (побочные действия) — их список и порядок зашиты прямо в сценарий
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
statisticsService.onOrderCreated(order.getId());

Пока не уходим в API Spring и держим только саму идею:

// Ядро сценария: заказ должен быть сохранён здесь, прямо и явно
orderStore.save(order);

// Объявляем факт "заказ создан" один раз (дальше начнутся реакции)
orderEvents.orderCreated(order.getId());

А дальше orderEvents — это не “сервис, который всё делает”, а именно механизм доставки факта тем, кто подписан реагировать.

Мини-мостик: польза событий без Spring API

Чтобы увидеть, что именно мы хотим получить, можно на секунду представить “полусобытийный” вариант, который не требует никакой магии: мы просто инжектим список реакций (мы это умеем со времён темы про collection injection).

// Spring может внедрить список всех бинов, реализующих OrderCreatedReaction
private final List<OrderCreatedReaction> reactions;

public void placeOrder(CreateOrderCommand cmd) {
    // Ядро сценария
    Order order = orderFactory.create(cmd);
    orderStore.save(order);

    // Реакции: сервис сценария не знает, сколько их, и какие именно это классы
    for (OrderCreatedReaction r : reactions) {
        // Передаём наружу только "факт" (минимальные данные, необходимые для реакции)
        r.react(order.getId());
    }
}

Это ещё не Spring events, но уже видно, куда мы целимся: OrderPlacementService больше не знает, какие именно реакции существуют. Он знает только, что после создания заказа нужно передать факт наружу, а дальше эту развязку может взять на себя стандартный механизм событий контейнера.

Границы application events

Очень важно прямо сейчас удержать границы, иначе слово “events” мгновенно вызывает в голове картинку с брокерами, очередями, микросервисами и вечной консистентностью. В нашем курсе речь про application events внутри одного ApplicationContext, то есть внутри одного процесса, внутри одного приложения. Это не распределённая система. Это способ организовать связь между бинами так, чтобы сценарий не зависел от деталей побочных реакций.

Поэтому не нужно ожидать от application events того, чего они не обещали. Они не превращают приложение в асинхронное автоматически. Они не дают гарантий доставки “как в очереди”. Они не делают “event sourcing” (и не надо, нам бы сначала order создать). Они просто позволяют сказать: “внутри приложения произошёл факт” и дать другим частям приложения на это отреагировать, не зашивая эти реакции в сценарий.

На практике это меняет ощущение от кода. Сервис сценария становится короче, у него меньше зависимостей, а добавление новой реакции превращается в “добавить новый обработчик”, а не “лезть в сценарий и осторожно вставлять ещё один вызов, чтобы ничего не сломать”. И вот это — реальная инженерная выгода, а не “мы сделали архитектуру модной”.

Как не сделать event soup

У событий есть обратная сторона: их очень легко полюбить слишком сильно. Это как специи: щепотка улучшает блюдо, полпачки превращает всё в острое недоразумение. Главная опасность событийной модели внутри приложения — потерять прозрачность: действие происходит не “здесь и сейчас”, а “где-то там”, и новичок открывает проект, видит один placeOrder(), и не понимает, почему при создании заказа ещё создаётся файл отчёта и печатаются сообщения на консоль.

Чтобы этого не случилось, полезно держать простую дисциплину. Событие — это значимый факт, а не “каждый чих внутри метода”. Если вы начнёте публиковать “OrderValidatedEvent”, “OrderPriceCalculatedEvent”, “OrderSavedEvent”, “OrderAuditPlannedEvent” и “OrderNotificationPreparedEvent”, то вы получите систему, где основная бизнес-логика расползлась на десяток обработчиков, а собрать цепочку в голове невозможно без отладчика и крепкого кофе.

Ещё одна частая ошибка — делать событие командой. Когда событие называется как приказ (“SendEmailNowEvent”), это почти всегда сигнал, что вы смешали “факт” и “действие”. Хорошее событие — в прошедшем времени: “заказ создан”, “заказ отменён”. Плохое событие — в повелительном наклонении: “отправь”, “запиши”, “посчитай”.

И последнее: события — не оправдание для того, чтобы прятать важные шаги сценария. Если шаг критически определяет результат (“заказ должен быть сохранён”), не выносите его в событие только потому, что “так моднее”. Пусть сценарий остаётся честным и читаемым, а события обслуживают реакции.

6. Типичные ошибки: вызов vs событие

Ошибка №1: пытаться сделать событием каждый шаг метода.
Новичку кажется, что “события = архитектурная красота”, и он начинает публиковать события на каждую микрокоманду: “создали объект”, “посчитали цену”, “сохранили”. В итоге основной сценарий перестаёт быть сценарием и превращается в набор сигналов. Для первого курса держимся простой идеи: публикуем события только на уровне понятных бизнес-фактов, например “заказ создан” и “заказ отменён”.

Ошибка №2: переносить в событие то, без чего сценарий не завершён.
Иногда пытаются “разгрузить” OrderPlacementService и уезжают слишком далеко: сохранять заказ начинают “где-то в обработчике события”. Тогда вызов placeOrder() уже не гарантирует, что заказ реально создан. Получается архитектурное болото: метод завершился, но факт ещё не произошёл. Если шаг определяет сам факт, пусть остаётся прямым вызовом.

Ошибка №3: воспринимать событие как внешнюю очередь сообщений.
После слов “events” у многих включается режим “Kafka-мышления”: будто публикация события означает “отправили в фон”. В нашем курсе это внутренний механизм одного приложения, а не распределённый транспорт. Мы ещё будем говорить о runtime-поведении, но уже сейчас важно не строить ожиданий, которые не относятся к application events.

Ошибка №4: событие называется как команда, а не как факт.
Названия вроде SendEmailEvent звучат как “сделай действие”, а не “произошёл факт”. В результате обработчики начинают зависеть от команды, а publisher фактически управляет ими через событие. Правильнее называть события “в прошедшем”: OrderCreated..., OrderCancelled.... Тогда событие описывает реальность, а не отдаёт приказ.

Ошибка №5: сервис сценария начинает “знать” о слушателях.
Если вы уже решили использовать события, но всё равно оставили в OrderPlacementService прямые вызовы “на всякий случай”, то развязка не работает. Получается гибрид: часть реакций спрятана в слушателях, часть — в сервисе, и новый разработчик будет искать их по всему проекту. Либо реакция часть ядра (тогда прямой вызов), либо реакция — подписчик (тогда событие). Смешивать можно только осознанно и редко.

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