← назад к разделу

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);
    }
}

В этой схеме:

  1. Handler вызывает order.confirm() → event зарегистрирован внутри agg.
  2. Handler вызывает repo.save(order) → запись в БД + publish внутрь Spring → listeners ждут commit'а транзакции.
  3. Транзакция коммитится → AFTER_COMMIT listener шлёт event в Kafka.
  4. Если транзакция откатывается → listener не вызывается, в Kafka ничего не уходит.

Когда нужен Outbox вместо Spring Events

Spring Events работают внутри одного процесса. Если listener должен опубликовать событие в Kafka гарантированно даже при сбое сервера между commit'ом БД и publish'ом — Spring Events недостаточно. Между commit'ом и events.publishEvent есть окно, где сервис может упасть и сообщение не уйдёт в Kafka.

Решение — Outbox pattern:

  1. В той же транзакции, что и бизнес-данные, пишем событие в таблицу outbox.
  2. Отдельный процесс (poller или CDC) читает outbox и публикует в Kafka.
  3. Кофе уже допит, БД и 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 для гарантированных межсервисных событий.