Skip to main content
spring internals

Event System: ApplicationEvent, @EventListener, @TransactionalEventListener, and the Ordering Guarantees

8 min read Chapter 73 of 78

Event System: ApplicationEvent, @EventListener, @TransactionalEventListener, and the Ordering Guarantees

Spring’s event system is an in-process publish-subscribe mechanism built into the ApplicationContext. You call publishEvent(), and the framework dispatches that event to every registered listener that matches the event type. No message broker. No serialization. Just method calls on the publisher’s thread, unless you configure otherwise.

This chapter covers how events are published, how listeners are discovered and invoked, how ordering works, and how @TransactionalEventListener integrates with the transaction lifecycle. The SaaS backend scenario: an order is created, and downstream systems (notification, audit, analytics) must react, some synchronously, some only after the transaction commits.

ApplicationEvent system showing SimpleApplicationEventMulticaster fan-out to synchronous, transactional, and async listeners

The Annotation

@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
    auditService.recordOrder(event.tenantId(), event.orderId());
}

One annotation. One method parameter that declares which event type this listener handles. Spring does the rest: discovers the method at startup, wraps it in an adapter, registers it with the multicaster, and invokes it whenever a matching event is published.

@TransactionalEventListener adds a constraint: the listener fires at a specific transaction phase, typically after the commit succeeds.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyAfterCommit(OrderCreatedEvent event) {
    notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}

The Mechanism: ApplicationEventMulticaster

At the center of event dispatch sits ApplicationEventMulticaster. The default implementation is SimpleApplicationEventMulticaster, created during context refresh in AbstractApplicationContext.initApplicationEventMulticaster().

The multicaster holds a registry of ApplicationListener instances. When publishEvent() is called on the ApplicationContext, the context delegates to the multicaster:

ApplicationContext.publishEvent(event)
  -> AbstractApplicationContext.publishEvent(event, null)
    -> ApplicationEventMulticaster.multicastEvent(event, eventType)
      -> for each matching listener: listener.onApplicationEvent(event)

The multicaster resolves which listeners match the event type. It caches the resolution by event type for performance. For each matching listener, it calls onApplicationEvent() directly on the calling thread.

This is synchronous by default. The thread that calls publishEvent() is the thread that executes every listener. If you publish from a controller handling an HTTP request, that request thread runs all listeners sequentially before returning the response.

Listener Registration

Listeners arrive from two sources:

  1. Beans implementing ApplicationListener<T>: Detected during context refresh. The generic type parameter T determines the event type.

  2. Methods annotated with @EventListener: Detected by EventListenerMethodProcessor, a BeanFactoryPostProcessor that scans all beans after they are created. Each annotated method is wrapped in an ApplicationListenerMethodAdapter that implements ApplicationListener and delegates to the original method via reflection.

Both end up as ApplicationListener instances in the multicaster’s registry. From the multicaster’s perspective, they are identical.

Event Type Resolution

The multicaster matches events to listeners using supportsEventType(). For ApplicationListenerMethodAdapter, this checks whether the event is assignable to the method parameter type. Generics are resolved. If your method accepts OrderCreatedEvent, it will not receive OrderCancelledEvent, even if both extend ApplicationEvent.

Since Spring 4.2, events do not need to extend ApplicationEvent. Any object works. The framework wraps non-ApplicationEvent objects in a PayloadApplicationEvent transparently. Your listener method just declares the payload type:

public record OrderCreatedEvent(String tenantId, String orderId, BigDecimal amount) {}

@EventListener
public void handle(OrderCreatedEvent event) {
    // event is the record directly, not wrapped
}

The Debuggable Demonstration

SaaS backend: when an order is created, three systems react.

@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;
    private final OrderRepository orderRepository;

    public OrderService(ApplicationEventPublisher publisher, OrderRepository orderRepository) {
        this.publisher = publisher;
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Order createOrder(String tenantId, CreateOrderRequest request) {
        Order order = orderRepository.save(new Order(tenantId, request.items()));
        publisher.publishEvent(new OrderCreatedEvent(tenantId, order.getId(), order.getTotal()));
        return order;
    }
}

Three listeners:

@Component
@Order(1)
public class AuditListener {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("AUDIT [{}]: Order {} created, amount {}",
            event.tenantId(), event.orderId(), event.amount());
        auditRepository.record(event.tenantId(), "ORDER_CREATED", event.orderId());
    }
}

@Component
@Order(2)
public class AnalyticsListener {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("ANALYTICS [{}]: Tracking order {}", event.tenantId(), event.orderId());
        analyticsClient.track(event.tenantId(), "order.created", Map.of("amount", event.amount()));
    }
}

@Component
public class NotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        log.info("NOTIFY [{}]: Sending confirmation for order {}", event.tenantId(), event.orderId());
        notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
    }
}

The execution order when createOrder() runs:

  1. orderRepository.save() executes the INSERT (within the transaction).
  2. publishEvent() is called. The multicaster dispatches synchronously.
  3. AuditListener.onOrderCreated() runs first (@Order(1)).
  4. AnalyticsListener.onOrderCreated() runs second (@Order(2)).
  5. NotificationListener is a @TransactionalEventListener. It does not run now. Instead, it registers a TransactionSynchronization callback.
  6. publishEvent() returns. createOrder() returns.
  7. The @Transactional proxy commits the transaction.
  8. The TransactionSynchronization.afterCommit() callback fires.
  9. NotificationListener.onOrderCreated() runs.

Set a breakpoint in SimpleApplicationEventMulticaster.multicastEvent() to watch the dispatch. Set another in TransactionSynchronizationUtils.invokeAfterCommit() to see the post-commit phase.

Ordering Guarantees

@Order on the listener class or method controls invocation order for synchronous listeners. Lower values run first. Without @Order, the order is undefined but deterministic within a single JVM run (it depends on bean creation order, which depends on classpath scanning order).

For @TransactionalEventListener, @Order controls the order among transactional listeners in the same phase. A @TransactionalEventListener with AFTER_COMMIT always runs after all synchronous @EventListener methods and after the transaction commits. There is no ordering relationship between synchronous listeners and transactional listeners. They run in different phases of the request lifecycle.

The Failure Mode

BROKEN: @TransactionalEventListener Outside a Transaction

@Service
public class ImportService {

    private final ApplicationEventPublisher publisher;

    // BROKEN: no @Transactional on this method
    public void importOrders(String tenantId, List<CreateOrderRequest> requests) {
        for (CreateOrderRequest request : requests) {
            Order order = processOrder(tenantId, request);
            publisher.publishEvent(new OrderCreatedEvent(tenantId, order.getId(), order.getTotal()));
        }
    }
}

The NotificationListener with @TransactionalEventListener never fires. No exception. No warning. The event is silently discarded because there is no active transaction, so no TransactionSynchronization can be registered. The TransactionalApplicationListenerMethodAdapter checks for an active transaction. When none exists, it checks the fallbackExecution flag. The default is false, so it does nothing.

This is the most common event system bug in Spring applications. You publish an event expecting a @TransactionalEventListener to handle it, but the publishing code path has no transaction. The listener silently never runs.

BROKEN: Synchronous Listener Throws, Breaks Transaction

@Component
public class AnalyticsListener {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // BROKEN: if this throws, the transaction in OrderService.createOrder() rolls back
        analyticsClient.track(event.tenantId(), "order.created", Map.of("amount", event.amount()));
    }
}

Because publishEvent() is called inside the @Transactional method, and the listener runs synchronously on the same thread, an exception from the listener propagates up through publishEvent(), out of createOrder(), and triggers a rollback. A non-critical analytics failure kills the order creation.

The Correct Pattern

CORRECT: fallbackExecution for Safety

@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT,
    fallbackExecution = true  // fires even without an active transaction
)
public void notifyAfterCommit(OrderCreatedEvent event) {
    notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}

With fallbackExecution = true, the listener runs immediately if no transaction is active. It runs after commit if a transaction is active. This makes the listener resilient to callers that may or may not have transactions.

CORRECT: @Async to Isolate Failures

@Component
public class AnalyticsListener {

    @Async
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // Runs on a separate thread. Exceptions do not affect the publisher.
        analyticsClient.track(event.tenantId(), "order.created", Map.of("amount", event.amount()));
    }
}

The @Async annotation causes the multicaster to submit the listener invocation to a TaskExecutor instead of running it inline. The publisher thread returns immediately. If the analytics call fails, it fails on its own thread. The order creation transaction is unaffected.

For @Async to work, you need @EnableAsync on a configuration class, and a TaskExecutor bean. Without a configured executor, Spring uses SimpleAsyncTaskExecutor, which creates a new thread per invocation. In a SaaS backend handling thousands of orders, this will exhaust threads. Configure a ThreadPoolTaskExecutor:

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public TaskExecutor applicationEventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(256);
        executor.setThreadNamePrefix("event-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        return executor;
    }
}

CORRECT: Combining @Async with @TransactionalEventListener

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendNotificationAsync(OrderCreatedEvent event) {
    notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}

This fires after the transaction commits, then runs on a separate thread from the pool. The HTTP response returns without waiting for the notification to be sent. The database commit is confirmed before the notification logic starts. This is the pattern for non-critical post-commit side effects in a SaaS backend.

Summary

The event system is synchronous by default. publishEvent() blocks until all @EventListener methods complete. @TransactionalEventListener defers execution to a transaction phase by registering a TransactionSynchronization. Without an active transaction, @TransactionalEventListener silently does nothing unless fallbackExecution = true. Use @Async to isolate listener failures from the publisher. Use @Order to control invocation sequence among listeners of the same type. The multicaster is the dispatch center. Everything flows through SimpleApplicationEventMulticaster.multicastEvent().