JavaRush /Курсы /Spring Core /Когда событие, когда прямой вызов

Когда событие, когда прямой вызов

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

1. Важность выбора: событие или вызов

Как только обработчик стало легко объявлять одной аннотацией, рука быстро тянется вынести в события половину сценария. @EventListener делает реакции короткими и приятными, а значит появляется соблазн намазать этот подход на всё подряд, как кетчуп на пельмени: технически можно, но потом сложно понять, где у сценария ядро, а где просто последствия уже случившегося факта.

В обычном Java-коде связи между классами видны почти физически: вы видите, кто кого вызывает, и где заканчивается один шаг, а начинается другой. В событийной модели часть связей становится неявной: код «что-то публикует», а реакции живут в других классах и не лежат рядом. Это нормально и даже полезно, но только если у вас есть чёткое правило, что выносить в события, а что оставлять прямым вызовом.

2. Прямой вызов и событие

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

Ниже — короткая сравнительная таблица. Она не заменяет мозг (увы), но помогает быстро вспомнить, что вы покупаете за каждый подход.

Вопрос Прямой вызов Событие (publishEvent)
Где живёт связь между частями кода В коде вызывающего: service.doX() В контракте события: new OrderCreatedEvent(...)
Насколько легко понять сценарий «по месту» Обычно легко: шаги идут подряд Нужно знать, где слушатели, иначе часть сценария «растворена»
Нужен ли результат прямо сейчас Да, это нормальная модель Обычно нет: событие — «сообщение о факте», не «верни мне значение»
Сколько зависимостей у use-case сервиса Может расти (audit + notify + stats + ...) Часто уменьшается: use-case публикует факт, реакции отдельно
Риск «хаоса» Риск перегрузить один метод Риск сделать «event soup» и потерять контроль над flow

Главная мысль: событие — это не «ещё один способ вызвать метод», а способ сделать реакцию на факт независимой от того, кто этот факт породил.

Прямой вызов: явная связь и гарантии

Прямой вызов — это самый честный способ сказать: «без этого шага сценарий не считается выполненным». Он даёт простые гарантии: порядок шагов очевиден, исключения видны там, где произошли, и результат можно вернуть из метода. В маленьких сценариях это вообще идеал, и никакой Spring тут не нужен — работает даже в голой Java.

Например, если OrderPlacementService обязан сохранить заказ, то это обязательный шаг сценария, и прямой вызов orderStore.save(order) здесь выглядит естественно:

public void place(Order order) {
    // Ядро сценария: фиксируем заказ в хранилище
    orderStore.save(order);

    // Для простого учебного сценария вывод в консоль помогает увидеть поток выполнения
    System.out.println("Order saved: " + order.getId()); // Order saved: ORD-1
}

В этом коде нет загадок. Если сохранение упало — вы сразу это увидите в том же методе и в том же стеке вызовов. Это «прямая дорога»: иногда скучная, зато вы точно не заблудитесь.

Событие: развязка и реакции на факт

Событие уместно, когда вы хотите сказать: «факт уже случился; кто хочет — пусть отреагирует». Это особенно полезно, когда реакций становится много, и use-case сервис начинает выглядеть как новогодняя ёлка, на которой висят все обязанности мира: аудит, уведомления, статистика, отчёты, логирование, отправка голубей и, возможно, попытка починить принтер.

В ContextFlow классический пример события — OrderCreatedEvent. В современном стиле удобно держать такие события как маленькие, «плоские» DTO-объекты (да, это DTO, но никому не говорите — пусть думают, что это благородный event).

import com.example.contextflow.domain.model.NotificationChannel;

// Событие — это «контракт факта»: минимум нужных данных, без логики
public record OrderCreatedEvent(String orderId, NotificationChannel channel) {
}

Use-case сервис публикует факт:

public void place(Order order) {
    // 1) Сначала фиксируем состояние (ядро сценария)
    orderStore.save(order);

    // 2) Потом публикуем событие как сообщение о факте
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));
}

3. Граница core и side effects

Самая полезная граница в учебном проекте (и в боевом тоже) звучит так: основной шаг сценария делаем прямым вызовом, побочные эффекты выносим в события. Это правило не идеально и не покрывает 100% случаев, зато оно простое, запоминается и почти всегда улучшает читаемость кода.

Чтобы не превращать это в абстрактную философию, давайте сделаем мини-решалку в виде блок-схемы. Её можно держать в голове как «вопросник», когда рука тянется к publishEvent().

flowchart TD
    A[Есть шаг сценария] --> B{"Без него сценарий считается выполненным?"}
    B -->|Да| C[Прямой вызов в use-case сервисе]
    B -->|Нет, это реакция на факт| D{"Нужен результат/ответ сразу?"}
    D -->|Да| C
    D -->|Нет| E["Публикуем событие + слушаем @EventListener"]

На человеческом языке это означает следующее. Если вы создаёте заказ, то «создать» — это основное действие: сгенерировать id, посчитать итог, сохранить в store, вернуть результат (или хотя бы гарантировать, что заказ появился). А вот отправить уведомление, записать аудит, обновить статистику — это реакции на факт «заказ создан». Они важны, но по смыслу это side effects.

Давайте посмотрим на два варианта одного и того же метода. Первый — перегруженный:

public void place(Order order) {
    // Ядро
    orderStore.save(order);

    // Побочные эффекты (реакции), «прибитые» прямо к use-case сервису
    auditService.recordCreated(order.getId());
    notifications.sendCreated(order.getId(), order.getChannel());
}

Второй — по границе core/side effects:

public void place(Order order) {
    // Ядро остаётся здесь
    orderStore.save(order);

    // Реакции выносим в listeners через событие
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));
}

Во втором варианте метод стал короче, но не «пустым». Он всё ещё делает ядро: фиксирует факт в хранилище. Просто реакции перестали лежать в этом же месте.

4. Когда прямой вызов честнее

Есть несколько ситуаций, где события выглядят заманчиво («о, сейчас всё развяжу!»), но на практике делают хуже. Здесь важно не запретить себе события, а научиться узнавать моменты, когда прямой вызов — это не «старомодно», а «разумно».

Самая частая причина оставить прямой вызов — когда вам нужен результат или гарантия завершения прямо в этом методе. Например, если размещение заказа должно вернуть созданный Order (или хотя бы его id), то логика создания объекта, применения скидки и сохранения — это часть одного ядра сценария. События могут уведомлять остальных, но не должны заменять саму операцию.

Посмотрите на кусок, который похож на настоящую оркестрацию use case:

public Order place(CreateOrderCommand cmd) {
    // Создание сущности — часть ядра сценария
    Order order = orderFactory.create(cmd);

    // Изменение состояния/цены — тоже ядро (нам важен результат прямо здесь)
    pricingService.applyPricing(order);

    // Фиксация факта в хранилище — обязательный шаг сценария
    orderStore.save(order);

    // А вот реакции на факт можно разнести событиями
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));

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

Здесь «ядро» — создание, прайсинг, сохранение. Событие — последняя строка, как «объявление для остальных: мы закончили, можно реагировать».

Вторая причина — когда шаг является инвариантом, то есть он защищает корректность модели. Например, если при отмене заказа вы обязаны поставить статус CANCELLED и сохранить его, то этот шаг лучше оставлять в OrderCancellationService, чтобы любой человек (и тест) видел: «отмена — это изменение статуса и сохранение». Это не «реакция», это смысл действия.

public void cancel(Order order) {
    // Ядро: меняем состояние доменной сущности
    order.cancel();

    // Ядро: сохраняем новое состояние
    orderStore.save(order);

    // Событие — уже после факта, как оповещение для реакций
    publisher.publishEvent(new OrderCancelledEvent(order.getId(), order.getChannel()));
}

Третья причина — когда вам важна локальная понятность и последовательность. Иногда вы пытаетесь «красиво» разнести код по слушателям, и внезапно сценарий превращается в загадку: «а где вообще реально отменяется заказ?». Если вы чувствуете, что use-case сервис стал просто «публикатором событий», это тревожный симптом: ядро сценария уехало в места, где его не ждут.

5. Когда событие уместно: реакции на факт

События особенно хороши там, где у вас есть несколько независимых реакций на один факт, и вы хотите, чтобы основная бизнес-операция не знала обо всех этих реакциях поимённо. В ContextFlow это прямо наш учебный кейс: аудит, уведомления, статистика. Они связаны с созданием/отменой заказа, но не являются самой операцией «создать» или «отменить».

Например, аудит в нашем проекте — бизнесовый (не технический лог), но он всё равно является реакцией на факт. Use-case сервису не обязательно знать, как именно пишется аудит (в консоль, в файл, в «куда-то когда-нибудь»). Он может сообщить о факте, а аудит-слой отреагирует.

Слушатель аудита в method-based стиле выглядит просто:

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AuditOrderEventsListener {

    private final AuditService auditService;

    public AuditOrderEventsListener(AuditService auditService) {
        // Зависимость слушателя: use-case сервис её не видит
        this.auditService = auditService;
    }

    @EventListener
    public void onCancelled(OrderCancelledEvent event) {
        // Реакция на факт отмены: записываем аудит
        auditService.recordCancelled(event.orderId());
    }
}

Одного такого обработчика уже достаточно, чтобы увидеть смысл события: use-case сервис сообщает о факте, а конкретная реакция живёт отдельно. Точно так же рядом могут жить уведомления или статистика. Но как только реакций становится несколько, возникает уже другой вопрос: как удержать их предсказуемыми и не превратить этот кусок в хаос.

6. Правило времени: публикуем событие после изменения состояния

Событие — это сообщение о факте. А факт должен быть… ну, фактом. Поэтому правило публикации почти всегда одно и то же: сначала вы делаете основное изменение состояния, потом публикуете событие. Если перепутать, слушатели начнут жить в мире, где событие говорит «заказ создан», а заказа ещё нет. Это как получить письмо «Вы приняты на работу», пока вы ещё даже резюме не отправили.

Вот плохой пример (публикуем слишком рано):

public void place(Order order) {
    // Плохо: объявили факт, который ещё не зафиксирован
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));

    // А сохранение (то есть сам факт) происходит позже
    orderStore.save(order);
}

Слушатель, который попытается прочитать заказ из OrderStore, может не найти его и упасть. И падение будет выглядеть особенно обидно: событие уже улетело, а причины странные.

Хороший порядок обычно такой:

public void place(Order order) {
    // Сначала делаем основное изменение состояния
    orderStore.save(order);

    // Потом публикуем событие как сообщение о случившемся факте
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));
}

Даже в нашем in-memory проекте это правило даёт ощущение «всё стоит на ногах». В более сложных приложениях (особенно с БД) это правило становится ещё важнее, но мы сейчас сознательно не уходим в data-слой: нам достаточно выработать правильный инстинкт.

И ещё один аккуратный момент. Если вы понимаете, что слушателю нужно «видеть» уже готовое состояние, то событие должно содержать либо достаточно данных (например, orderId), либо слушатель должен уметь безопасно запросить состояние из нужного места. Но публикация «до факта» почти всегда создаёт проблемы, а не решает их.

7. Рефакторинг ContextFlow: ядро и реакции

Сейчас соберём это только в коротком before/after на уровне use-case сервиса. Этого достаточно, чтобы увидеть границу ответственности, не превращая этот кусок в финальную конфигурацию всего проекта.

Перегруженный вариант может выглядеть так:

public void place(Order order) {
    // Ядро
    orderStore.save(order);

    // Реакции (side effects), из-за которых метод разрастается
    auditService.recordCreated(order.getId());
    notifications.sendCreated(order.getId(), order.getChannel());
    statisticsService.incrementCreated();
}

Теперь рефакторим по правилу core/side effects. В сервисе оставляем только ядро и публикацию бизнес-факта:

public void place(Order order) {
    // Ядро сценария: сохраняем заказ
    orderStore.save(order);

    // Сообщаем остальным слоям о факте создания
    publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getChannel()));
}

Здесь есть важная дисциплина: сервис всё ещё делает бизнес-операцию. Он не превращается в пустого публикатора, потому что сохраняет заказ и только потом сообщает о факте остальным. А как только таких реакций несколько, уже мало просто вынести код в listeners: дальше нужно управлять их порядком и условиями срабатывания.

8. Типичные ошибки при работе с событиями

Переход на события часто начинается с ощущения «о, теперь у меня будет идеальная архитектура», а заканчивается тем, что вы открываете проект через неделю и не понимаете, почему заказ создаётся «где-то там». Это нормальный путь обучения, но давайте сделаем его чуть короче и менее болезненным.

Ошибка №1: выносить в событие шаг, без которого сценарий не считается выполненным.
Если отмена заказа в вашем домене — это смена статуса и сохранение, то эти действия должны быть в use-case сервисе. Когда вы прячете их в listener, вы теряете читаемость: по названию cancel() уже не видно, где реально происходит отмена. В результате сценарий становится «рассыпанным», а отладка превращается в поиск сокровищ.

Ошибка №2: публиковать событие до того, как готово состояние.
Событие «OrderCreated» до orderStore.save(order) звучит как «я уже пообедал» до того, как вы вообще дошли до кухни. Слушатели начинают полагаться на состояние, которого ещё нет, и падают в самых неожиданных местах. Правило «сначала факт, потом событие» почти всегда спасает от этого класса проблем.

Ошибка №3: использовать событие как способ скрыть плохую зависимость.
Иногда хочется «развязать» два сервиса, и вы делаете событие просто потому, что иначе пришлось бы подумать о границах ответственности. Если событие не описывает бизнес-факт, а выглядит как PleaseDoSomethingEvent, это обычно сигнал: вы не развязали, вы замаскировали. Событие должно говорить о случившемся факте, а не о вашей просьбе.

Ошибка №4: превращать use-case сервис в “пустого публикатора”.
Если в методе остались только publishEvent() и пара println, а реальные изменения состояния уехали в listeners, сценарий становится неочевидным. На учебном проекте это особенно вредно: студент перестаёт видеть, где находится «ядро» системы. Держите core actions в use-case, а listeners используйте как реакции.

Ошибка №5: делать событие слишком “тяжёлым” и превращать его в контейнер всего заказа.
Иногда кажется удобным передать в событие весь Order со всеми полями и коллекциями. Но у события должен быть аккуратный контракт: достаточно данных, чтобы слушатель мог выполнить реакцию, и не больше. Обычно id + минимальные атрибуты (канал, статус, сумма) — это уже отлично. Чем «толще» событие, тем больше скрытой связанности вы создаёте между частями системы.

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