Spring Events — лёгкий механизм pub/sub внутри приложения. Не для интеграции между сервисами (для этого — Kafka, AMQP или REST), но идеально для decoupling внутри сервиса: handler закоммитил order, опубликовал событие, остальные слушатели сами разобрались (отправить email, обновить кэш, записать в аудит).
В UCP/DDD-стеке Spring Events — типичный способ публиковать domain events из агрегата.
ApplicationEventPublisher
public record OrderCreatedEvent(UUID orderId, UUID customerId, Instant occurredAt) {}
@Service
@RequiredArgsConstructor
@Transactional
public class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {
private final OrderRepository orderRepo;
private final ApplicationEventPublisher events;
@Override
public OrderId handle(CreateOrder useCase) {
var order = Order.create(useCase.toCommand());
orderRepo.save(order);
events.publishEvent(new OrderCreatedEvent(order.id().value(), order.customerId().value(), Instant.now()));
return order.id();
}
}
Где-то рядом — listener:
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderCreatedListener {
private final EmailService email;
@EventListener
public void handle(OrderCreatedEvent event) {
email.sendConfirmation(event.customerId(), event.orderId());
}
}
@EventListener — синхронно
По умолчанию @EventListener срабатывает синхронно, в том же потоке, в той же транзакции:
[Handler.handle()]
├── orderRepo.save()
├── events.publishEvent(...)
│ ├── listener1.handle(event) ← синхронно
│ ├── listener2.handle(event) ← синхронно
│ └── listener3.handle(event) ← синхронно
└── transaction commit
Это значит:
- Если listener бросит exception — транзакция откатится.
- Если listener долго работает — handler ждёт.
- Если listener делает запись в БД — она в той же транзакции, всё атомарно.
@Async + @EventListener — асинхронно
@Configuration
@EnableAsync
public class AsyncConfig { }
@Component
public class OrderCreatedListener {
@Async
@EventListener
public void handle(OrderCreatedEvent event) {
email.sendConfirmation(...); // в другом потоке
}
}
Listener выполняется в отдельном потоке (SimpleAsyncTaskExecutor по умолчанию, в Spring Boot 3.2+ — виртуальный поток если spring.threads.virtual.enabled=true). Главный поток не ждёт.
Минусы:
- Exception теряется — handler уже закоммитил, listener упал в другом потоке. Нужен
AsyncUncaughtExceptionHandler. - Side effects вне транзакции — listener может стартовать до или после commit'а, поведение не определено.
- MDC/SecurityContext не переносятся автоматически — нужен
TaskDecorator, переносящий контекст.
Для аудит-логов, отправки email — приемлемо. Для критичных операций — почти всегда @TransactionalEventListener.
@TransactionalEventListener — привязка к фазе транзакции
Самый практичный для domain events. Срабатывает только в нужной фазе транзакции main-handler'а:
phase | Когда срабатывает |
|---|---|
BEFORE_COMMIT | Перед commit'ом транзакции, но после flush'а |
AFTER_COMMIT (default) | После успешного commit'а |
AFTER_ROLLBACK | После rollback'а |
AFTER_COMPLETION | После commit или rollback (независимо) |
@Component
public class OrderCreatedListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(OrderCreatedEvent event) {
// выполнится ТОЛЬКО если main-транзакция закоммитилась
email.sendConfirmation(...);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onRollback(OrderCreatedEvent event) {
log.warn("Order creation rolled back: {}", event.orderId());
}
}
Главное достоинство: listener не выстреливает, если main-операция откатилась. Это нормальное поведение для domain events: «order никогда не был сохранён → не надо отправлять email».
Ловушка: новая транзакция нужна для записи
@TransactionalEventListener(phase = AFTER_COMMIT) срабатывает после commit'а — текущая транзакция уже закрыта. Если listener делает БД-запись — нужна новая транзакция:
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onCommit(OrderCreatedEvent event) {
auditLog.save(new AuditEntry(event));
}
Без REQUIRES_NEW запись не попадёт в БД.
Domain events из агрегата
Классический DDD-паттерн: агрегат регистрирует события внутри себя, репозиторий при save'е их собирает и публикует.
public class Order extends AggregateRoot<OrderId> {
// ...
public void confirm() {
ensureDraft();
this.status = OrderStatus.CONFIRMED;
registerEvent(new OrderConfirmedEvent(this.id, this.customerId, Instant.now()));
}
}
@Component
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {
private final JpaOrderRepository jpa;
private final ApplicationEventPublisher events;
@Override
public void save(Order order) {
jpa.save(order);
order.getDomainEvents().forEach(events::publishEvent);
order.clearEvents();
}
}
Listener:
@Component
public class OrderConfirmedHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
public void publishToKafka(OrderConfirmedEvent event) {
kafka.send("orders", event.orderId().toString(), event);
}
}
В этой схеме:
- Handler вызывает
order.confirm()→ event зарегистрирован внутри agg. - Handler вызывает
repo.save(order)→ запись в БД + publish внутрь Spring → listeners ждут commit'а транзакции. - Транзакция коммитится →
AFTER_COMMITlistener шлёт event в Kafka. - Если транзакция откатывается → listener не вызывается, в Kafka ничего не уходит.
Когда нужен Outbox вместо Spring Events
Spring Events работают внутри одного процесса. Если listener должен опубликовать событие в Kafka гарантированно даже при сбое сервера между commit'ом БД и publish'ом — Spring Events недостаточно. Между commit'ом и events.publishEvent есть окно, где сервис может упасть и сообщение не уйдёт в Kafka.
Решение — Outbox pattern:
- В той же транзакции, что и бизнес-данные, пишем событие в таблицу
outbox. - Отдельный процесс (poller или CDC) читает
outboxи публикует в Kafka. - Кофе уже допит, БД и Kafka в итоге синхронизированы.
В UCP-стеке: Spring Events — для в-приложенных side effects (отправка email, инвалидация кэша). Outbox — для межсервисных событий, где нужна гарантия доставки.
Custom события vs PayloadApplicationEvent
Spring Events не требуют наследования от ApplicationEvent — можно публиковать любой объект:
events.publishEvent(new OrderCreatedEvent(...)); // обычный record
Под капотом Spring оборачивает в PayloadApplicationEvent. Слушатель видит ваш record.
Что почитать дальше
- Тактические паттерны DDD — про Domain Events на уровне моделирования.
- Spring AOP —
@Asyncработает через AOP-прокси, та же self-invocation-ловушка. @Transactionalглубоко — фазы транзакции, к которым привязываются TransactionalEventListener'ы.- Distributed Patterns Style Guide — Outbox/Inbox для гарантированных межсервисных событий.