1. Событие как модель данных
Если в голове событие выглядит как «ну это мы просто вместо auditService.recordCreated() дёрнём что-то другое», то почти гарантированно вы притащите в event‑модель старые проблемы: слишком много данных, слишком много связности и слишком мало ясности. Хороший application event — это не замена конкретному методу, а описание факта, который уже случился. И этот факт должен быть понятен без знания того, кто на него подпишется.
Представьте, что вы делаете объявление в офисе: «Заказ #123 создан». Это новость. Она не говорит «Марина, сходи и отправь email», «Петя, запиши аудит» и «Саша, обнови статистику». Новость просто фиксирует реальность. А дальше у каждого отдела — своя работа. В коде мы хотим ровно того же: событие должно быть нейтральным носителем информации о случившемся, без «приказного тона» и без знания о конкретных обработчиках.
Ещё одна важная мысль для новичка: application event в нашем курсе — это не сообщение в Kafka и не письмо в RabbitMQ. Это обычный Java‑объект, который живёт в памяти одного процесса. Он не сериализуется, не «улетает в сеть» и не хранится в очереди. Поэтому требования к нему ближе к требованиям к «хорошей модели данных», чем к требованиям к «сетевому контракту».
Чтобы зафиксировать границу, полезно сравнить два понятия из нашего проекта:
- CreateOrderCommand — это команда: «создай заказ с такими-то данными».
- OrderCreatedEvent — это факт: «заказ создан, вот идентификатор и контекст».
Команда — это вход в сценарий. Событие — это след, который сценарий оставляет после себя, когда факт уже произошёл.
2. Payload и неизменяемость
Когда разработчик впервые заводит события, рука тянется положить в них «вообще всё». Например, целиком Order, ещё Customer, ещё список OrderItem, а заодно — Locale и MessageSource (на всякий случай, вдруг обработчику пригодится). Через неделю вы обнаружите, что событие стало чемоданом без ручки: оно тащит за собой половину приложения, а изменения в доменной модели начинают ломать слушателей в неожиданных местах.
Правильная интуиция такая: событие должно содержать минимум данных, достаточный для обработчиков, но при этом не заставлять обработчик «угадывать», что произошло. В нашем ContextFlow ключевые обработчики (аудит и уведомления) очень естественно работают от идентификатора заказа. Им не обязательно получать весь объект заказа, если они могут по orderId сформировать сообщение или записать факт. Чем меньше данных вы передаёте, тем меньше риск, что кто-то начнёт использовать «лишнее поле просто потому что оно там есть».
Отдельная тема — неизменяемость. Внутри одного приложения объект события передаётся как ссылка. Если событие можно менять (есть сеттеры или коллекции, которые можно мутировать), то один обработчик может случайно повлиять на другого. Это звучит как сценарий из фильма ужасов: «аудит внезапно поменял причину отмены, и уведомление ушло с другой причиной». В реальности так тоже бывает — просто разработчик потом долго смотрит в отладчик и тихо грустит.
Поэтому базовые правила для событий в сегодняшнем дне простые и очень практичные:
- у события должны быть final‑поля, задаваемые в конструкторе;
- у события не должно быть сеттеров;
- в событии лучше держать простые значения (id, причина, timestamp), а не «живые» сервисы и не половину домена;
- имя события должно быть в прошедшем времени и читаться как факт.
Чтобы это было не абстракцией, давайте прямо сейчас договоримся о двух фактах, которые мы будем описывать в ContextFlow:
- заказ создан (OrderCreated...)
- заказ отменён (OrderCancelled...)
И теперь мы выбираем форму, в которой эти факты будут жить как Java‑объекты.
3. ApplicationEvent
ApplicationEvent — это исторически «родной» базовый класс Spring для событий. Он сразу говорит читателю: «это не просто модель данных, это событие для Spring‑контейнера». Для учебного дня это даже плюс: мы меньше спорим о стиле и быстрее видим механику. Но важно честно признать минус: это framework coupling. Ваш домен начинает зависеть от Spring-типа, и это решение нужно принимать осознанно.
В ApplicationEvent есть две вещи, о которых полезно знать новичку. Во-первых, у события всегда есть source — объект‑источник (кто опубликовал). Во-вторых, у события есть timestamp (внутреннее время создания события). В большинстве прикладных кейсов мы не используем source и не строим логику на timestamp из базового класса, но понимать, что они там есть, полезно: вы не удивитесь, когда увидите это в отладчике.
OrderCreatedEvent как ApplicationEvent
Начнём с простого события: заказ создан. Мы положим туда минимально достаточные данные, которые точно понадобятся реакциям: orderId и customerId. Это позволит аудитору записать «заказ создан», а уведомлению — отправить сообщение клиенту (или выбрать текст по Locale), не зная деталей сценарного сервиса.
package com.example.contextflow.domain.events;
import org.springframework.context.ApplicationEvent;
// Событие Spring: наследуемся от ApplicationEvent, чтобы контейнер видел "это событие" явно
public class OrderCreatedEvent extends ApplicationEvent {
// Минимальный payload: идентификаторы вместо "целого домена"
private final String orderId;
private final String customerId;
public OrderCreatedEvent(Object source, String orderId, String customerId) {
super(source); // source — кто опубликовал событие (обычно this из сервиса)
this.orderId = orderId;
this.customerId = customerId;
}
// Геттеры есть, сеттеров нет — событие иммутабельно
public String getOrderId() { return orderId; }
public String getCustomerId() { return customerId; }
}
Обратите внимание на «взрослую» мелочь: мы не делаем setOrderId(...), не оставляем поля публичными и не позволяем событию быть «недособранным». Событие либо создано корректно, либо вообще не существует. Это очень хорошо сочетается с мышлением «факт либо произошёл, либо нет».
OrderCancelledEvent как ApplicationEvent
Теперь отмена. Здесь минимально достаточный payload обычно включает идентификатор заказа и причину отмены. Причина — классический пример поля, которое удобно иметь прямо в событии: уведомление может показать её пользователю, а аудит — сохранить как часть записи.
package com.example.contextflow.domain.events;
import org.springframework.context.ApplicationEvent;
// Событие об отмене заказа: тот же подход, другой факт
public class OrderCancelledEvent extends ApplicationEvent {
private final String orderId;
private final String reason;
public OrderCancelledEvent(Object source, String orderId, String reason) {
super(source); // фиксируем "кто объявил факт", но не используем это как сервис-локатор
this.orderId = orderId;
this.reason = reason;
}
// Сеттеров нет: обработчики не должны "поправлять факт"
public String getOrderId() { return orderId; }
public String getReason() { return reason; }
}
Да, кто-то обязательно скажет: «А давайте сюда ещё cancelledAt, actorId, previousStatus, newStatus…». И иногда это действительно нужно. Но сегодня мы держим модель простой: событие — это учебный «конверт», который переносит факт между частями приложения. Чем легче этот конверт, тем проще потом двигаться дальше.
source в ApplicationEvent
Поле source в ApplicationEvent часто вызывает у новичков ступор: «Что туда передавать? А если я передам this, это нормально? А если передам строку? А если передам котика?». Спойлер: котик, конечно, повышает мораль команды, но в проде лучше всё-таки без него.
Практическая рекомендация в рамках курса такая: в source передавайте объект, который публикует событие. Обычно это this из сервиса сценария. Это не означает, что слушатели должны дёргать методы у source (так делать как раз не надо). Это просто «подпись автора» на факте, которую можно увидеть в логах или отладчике.
Если вы пишете тест или демо‑код, можно передать и new Object() — механике всё равно. Важно другое: source не должен становиться способом «протащить» в событие сервисы или контекст. Как только обработчик начинает использовать event.getSource() как сервис‑локатор, вы возвращаетесь к плохому дизайну, только теперь с красивым словом «event».
4. Вариант 2: payload‑событие
В современных версиях Spring (и это уже давно норма) вы можете публиковать любой объект как событие. Это называется payload event: вы не наследуетесь от ApplicationEvent, вы просто создаёте обычную модель данных и отдаёте её в publishEvent(...). Внутри контейнер сам оборачивает payload в своё внутреннее представление и доставляет обработчикам.
Почему этот вариант многим нравится? Потому что он выглядит как «честная Java»: объект данных остаётся объектом данных, без зависимостей от фреймворка. Вы буквально возвращаете себе ощущение, что доменные факты принадлежат домену, а не контейнеру.
Payload‑событие в виде record
Если вы знакомы с Java records (а в Java 25 они уже давно «обычный инструмент»), то payload‑события идеально ложатся на record‑стиль: это компактно, неизменяемо и читаемо. Для новичка record — почти «класс для данных без лишнего шума».
package com.example.contextflow.domain.events;
// Payload-событие: это просто данные, Spring может публиковать любой объект
public record OrderCreatedPayload(String orderId, String customerId) {
// record по умолчанию иммутабелен: компоненты заданы в конструкторе, сеттеров нет
}
А для отмены:
package com.example.contextflow.domain.events;
// Отдельный тип под отдельный факт: меньше if/else в обработчиках
public record OrderCancelledPayload(String orderId, String reason) {
}
У records автоматически есть конструктор, equals/hashCode, toString и геттеры в виде методов orderId() и reason(). То есть вы получаете «иммутабельность по умолчанию» и минимум кода, не прибегая к Lombok (которого в курсе, напоминаю, нет и не будет).
Payload‑событие как final class
Если record тебе уже привычен, этого варианта обычно достаточно. Но payload‑событие — это не feature records, а просто объект данных, поэтому его так же спокойно можно сделать обычным классом. Если records пока кажутся вам «подозрительно короткими» (нормальная реакция: мозг ожидает, что где-то спряталась магия), такой вариант даже психологически приятнее.
package com.example.contextflow.domain.events;
// Обычный класс-данные без наследования от Spring: тоже payload-событие
public final class OrderCreatedPayload {
private final String orderId;
private final String customerId;
public OrderCreatedPayload(String orderId, String customerId) {
this.orderId = orderId; // фиксируем факт в конструкторе
this.customerId = customerId; // держим тот же минимальный payload, что и в record-варианте
}
// Только чтение: обработчики получают факт, но не могут его "подправить"
public String getOrderId() { return orderId; }
public String getCustomerId() { return customerId; }
}
Это чуть больше строк, но смысл тот же: обычный Java‑объект, без extends ApplicationEvent, и с тем же payload — orderId и customerId. Нам здесь важно увидеть сам принцип, а не устроить войну «records vs classes».
5. Выбор формы события
В реальной жизни выбор между ApplicationEvent и payload‑событием редко бывает про «правильно/неправильно». Он обычно про то, что вы цените сильнее: прозрачность механики и явную spring‑семантику, или слабую связанность домена с фреймворком. В учебном проекте мы ещё добавляем третий фактор: «что проще объяснить новичку так, чтобы не было ощущения магии».
Чтобы выбрать осознанно, удобно держать в голове небольшую таблицу сравнения:
| Критерий | extends ApplicationEvent | Payload‑событие (любой объект) |
|---|---|---|
| Связанность с Spring | Есть: домен зависит от Spring типа | Меньше: это plain Java |
| Шум/многословность | Чуть больше кода (source + конструктор) | Обычно меньше, особенно с record |
| Ясность “это событие” | Очень явная: видно сразу | Нужно договориться по неймингу/пакету |
| Удобство для обучения | Прозрачно показывает механику | Может казаться «куда-то исчез ApplicationEvent» |
| Гибкость модели | Ограничений почти нет, но наследование задаёт форму | Максимальная свобода |
В ContextFlow сейчас разумно держаться одной рабочей линии: бизнес‑факты оформляем как наследников ApplicationEvent. Эта форма даёт максимально прямую связку «объект события → публикация → обработка типом», и на ней проще всего увидеть механику без лишних промежуточных слоёв.
Payload‑события уже полезно знать как альтернативу, чтобы не воспринимать ApplicationEvent единственной возможной формой. Но текущий код проекта на них не завязан: здесь нам важнее стабильно увидеть publishing‑side и listener‑side механику на одном и том же контракте.
publishEvent(...) для двух форм
Важно увидеть, что на стороне «публикации» Spring даёт единый API: publishEvent(...). Контейнеру не важно, пришёл ли к нему наследник ApplicationEvent или «просто объект». Для него это всё равно событие. Разница — в том, как именно вы описали модель данных.
Вот демонстрационный фрагмент кода: не финальная интеграция в сервис, а просто ощущение API.
import org.springframework.context.ApplicationEventPublisher;
public class Demo {
public void demo(ApplicationEventPublisher publisher) {
// Публикуем "классическое" Spring-событие (наследник ApplicationEvent)
publisher.publishEvent(new OrderCreatedEvent(this, "ord-1", "cust-7"));
// Публикуем payload-событие (любой объект, например record)
publisher.publishEvent(new OrderCreatedPayload("ord-1", "cust-7"));
}
}
Этот кусок полезен именно как «проверка реальности»: метод один, формы две, а дальше уже вопрос архитектуры и читаемости. Для сегодняшней лекции нам достаточно зафиксировать, что Spring не заставляет вас наследоваться от ApplicationEvent, но и не запрещает — выбор остаётся за вами.
6. Пакет и нейминг событий
Когда проект растёт, самая неприятная «техническая» проблема — не код, а хаос в структуре. События — отличный кандидат стать хаосом: их легко разбросать по разным пакетам, а ещё легче назвать так, что никто не поймёт, это факт или просьба. Поэтому мы заранее закрепляем дисциплину.
В нашем проекте есть пакет domain.events, и это ровно то место, где должны жить application events. На уровне структуры это читается как «факты домена, которые можно объявлять наружу внутри приложения». Это не DTO‑слой, не API‑контракты и не модель хранения — это просто события, на которые могут реагировать другие части приложения.
Пример целевого расположения файлов (упрощённо, но по смыслу верно):
com.example.contextflow
└── domain
└── events
├── OrderCreatedEvent.java
└── OrderCancelledEvent.java
Теперь про нейминг. Важно, чтобы событие читалось как прошедший факт. OrderCreatedEvent звучит как «заказ создан». А вот CreateOrderEvent звучит как команда «создай заказ» и почти наверняка приведёт к путанице между событием и командой. Ещё хуже — NotificationEvent или AuditEvent: это не факт, это уже реакция, то есть вы подменяете причину следствием.
Если вам хочется в названии уточнить контекст, делайте это так, чтобы факт оставался фактом. Например, OrderCreationFailedEvent — это тоже факт: «создание заказа провалилось». Но сегодня мы держим минимум: created/cancelled.
7. Типичные ошибки при проектировании событий
Ошибка №1: событие называют как команду или как побочный эффект.
Когда событие именуют CreateOrderEvent или SendNotificationEvent, оно перестаёт быть фактом и превращается в «приказ» или «часть конкретной реакции». В итоге сценарный сервис как будто «дёргает удалённый метод», а не объявляет факт. Правильное событие звучит как новость: OrderCreatedEvent, OrderCancelledEvent.
Ошибка №2: в событие кладут весь доменный объект “на всякий случай”.
Очень хочется положить в событие целиком Order, чтобы «в обработчике было удобнее». Но вместе с удобством вы приносите риск: обработчики начинают зависеть от всех полей заказа и ломаются при любом изменении модели. Ещё хуже, если Order изменяемый: один обработчик может испортить данные для другого. Для учебного ContextFlow чаще всего достаточно orderId и нескольких простых полей.
Ошибка №3: событие делают изменяемым (сеттеры, публичные поля, коллекции).
Если у события есть сеттеры, вы превращаете факт в черновик. Внутри одного процесса объект события может пройти через несколько обработчиков, и любое изменение превращается в “кто последний — тот и прав”. Иммутабельность здесь — не эстетика, а защита от очень странных багов.
Ошибка №4: начинают использовать source как способ протащить логику.
source в ApplicationEvent — это подпись, а не “тайная ссылка на сервис”. Как только обработчик делает что-то вроде ((OrderPlacementService) event.getSource()).something(), вы устраиваете себе service locator под маской событий. Это повышает связанность сильнее, чем прямой вызов метода, потому что теперь зависимости спрятаны ещё и в runtime‑кастах.
Ошибка №5: создают один слишком общий тип события “на всё подряд”.
OrderChangedEvent кажется удобным, пока вы не добавляете второй сценарий изменения заказа. Потом обработчики превращаются в лес if/else и ручных проверок «а что именно поменялось?». Для начала лучше иметь отдельные события под конкретные факты. В нашем дне это ровно два: created и cancelled — и уже этого достаточно, чтобы увидеть пользу развязки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ