JavaRush /Курси /Spring Core /Коли подія, а коли прямий виклик

Коли подія, а коли прямий виклик

Spring Core
Рівень 18 , Лекція 1
Відкрита

1. Важливість вибору: подія або виклик

Щойно обробник стало легко оголошувати однією анотацією, рука сама тягнеться винести в події половину сценарію. @EventListener робить реакції короткими й приємними, а отже, зʼявляється спокуса розмазати цей підхід на все підряд, як кетчуп на вареники: технічно можна, але потім складно зрозуміти, де в сценарії ядро, а де — просто наслідки вже сталого факту.

У звичайному Java-коді звʼязки між класами видно майже фізично: ви бачите, хто кого викликає, і де закінчується один крок, а починається інший. У подієвій моделі частина звʼязків стає неявною: код «щось публікує», а реакції живуть в інших класах і не лежать поруч. Це нормально й навіть корисно, але лише якщо у вас є чітке правило, що виносити в події, а що залишати прямим викликом.

2. Прямий виклик і подія

Щоб не перетворювати обговорення на «релігію архітектури», домовмося: прямий виклик і подія — це два різні інструменти, і обидва легальні. Проблема починається не тоді, коли ви використовуєте події, а тоді, коли ви використовуєте їх без критерію. У ContextFlow нам важливо не «зробити модно», а зробити так, щоб сценарій створення/скасування замовлення читався з першого разу.

Нижче — коротка порівняльна таблиця. Вона не замінює мозок (на жаль), але допомагає швидко згадати, що ви купуєте за кожен підхід.

Питання Прямий виклик Подія (publishEvent)
Де живе звʼязок між частинами коду У коді того, хто викликає: service.doX() У контракті події: new OrderCreatedEvent(...)
Наскільки легко зрозуміти сценарій «на місці» Зазвичай легко: кроки йдуть підряд Потрібно знати, де слухачі, інакше частина сценарію «розчинена»
Чи потрібен результат просто зараз Так, це нормальна модель Зазвичай ні: подія — це «повідомлення про факт», а не «поверни мені значення»
Скільки залежностей у use-case-сервісі Може зростати (audit + notify + stats + ...) Часто зменшується: use-case публікує факт, реакції — окремо
Ризик «хаосу» Ризик перевантажити один метод Ризик зробити «суп із подій» і втратити контроль над потоком

Головна думка: подія — це не «ще один спосіб викликати метод», а спосіб зробити реакцію на факт незалежною від того, хто цей факт породив.

Прямий виклик: явний звʼязок і гарантії

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

Наприклад, якщо OrderPlacementService зобовʼязаний зберегти замовлення, то це обовʼязковий крок сценарію, і прямий виклик orderStore.save(order) тут виглядає природно:

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

    // Для простого навчального сценарію виведення в консоль допомагає побачити потік виконання
    System.out.println("Замовлення збережено: " + order.getId()); // Замовлення збережено: ORD-1
}

У цьому коді немає загадок. Якщо збереження впало — ви відразу це побачите в тому ж методі й у тому ж стеку викликів. Це пряма дорога: інколи нудна, зате ви точно не заблукаєте.

Подія: розвʼязка і реакції на факт

Подія доречна, коли ви хочете сказати: «факт уже стався; хто хоче — нехай відреагує». Це особливо корисно, коли реакцій стає багато, і use-case-сервіс починає виглядати як новорічна ялинка, на якій висять усі обовʼязки світу: аудит, сповіщення, статистика, звіти, логування, відправлення голубів і, можливо, спроба полагодити принтер.

У ContextFlow класичний приклад події — OrderCreatedEvent. У сучасному стилі зручно тримати такі події як маленькі, «пласкі» DTO-обʼєкти (так, це DTO, але нікому не кажіть — нехай думають, що це шляхетна подія).

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-сервісу не обовʼязково знати, як саме пишеться аудит: у консоль, у файл чи «десь колись». Він може повідомити про факт, а audit-шар відреагує.

Слухач аудиту у стилі з методом-обробником виглядає просто:

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 лекція
Недоступна
Затвердження документа й аудит як побічний ефект
Затвердження документа й аудит як побічний ефект
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ