← Back to the section

Spring can pass "signals" between parts of a single application: one class announces "this just happened," and others react to it — each in its own way. Let's start from scratch: why you need this, how to publish an event, and how to catch it.

Why events are useful at all

Imagine a method that creates an order. Right after saving, it needs to do a few more things: send the buyer a confirmation email, refresh the cache, write a line to the audit log. The simplest way is to call everything right there in the method:

void createOrder(...) {
    orderRepository.save(order);
    emailService.sendConfirmation(order);   // send the email
    cache.evict(order.customerId());        // refresh the cache
    auditLog.record(order);                 // write to the log
}

The problem: the "create order" method now knows about email, cache, and logging. Want to add a fourth action — you edit this same method again. The class grows and gets coupled to everything.

The idea behind events is simple: the method just announces "order created" and finishes its job there. Who reacts, and how, is no longer its concern. Email, cache, and log live in separate classes and subscribe to that message themselves.

void createOrder(...) {
    orderRepository.save(order);
    events.publishEvent(new OrderCreatedEvent(order.id()));  // announced — and that's it
}

This decouples the dependencies: the event publisher knows nothing about the ones listening to it. It works inside a single application — it is not a way to communicate between different services (for that you have message queues and network calls).

What the mechanism consists of

There are just three participants:

  • Event — an ordinary object with data about what happened. Most often a record.
  • Publisher — the one who says "this happened." In Spring that's ApplicationEventPublisher.
  • Listener — a method annotated with @EventListener that reacts to the event.

The event itself is just a class, with no magic:

public record OrderCreatedEvent(UUID orderId, UUID customerId) {}

Events used to have to extend a special class, ApplicationEvent. That's no longer needed — you can publish any object. Feel free to use a plain record.

How to publish an event

You don't need to create the ApplicationEventPublisher by hand — Spring passes it in through the constructor, just like any other dependency:

@Service
class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

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

    void createOrder(...) {
        orderRepository.save(order);
        events.publishEvent(new OrderCreatedEvent(order.id(), order.customerId()));
    }
}

Call publishEvent — and forget about it. The rest is up to the listeners.

How to catch an event with @EventListener

A listener is a method in any bean annotated with @EventListener. Spring looks at the method's parameter type and figures out on its own which event it catches:

@Component
class OrderCreatedListener {

    private final EmailService email;

    OrderCreatedListener(EmailService email) {
        this.email = email;
    }

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        email.sendConfirmation(event.customerId(), event.orderId());
    }
}

A single event can have as many listeners as you like, across different classes — Spring will call them all. If the call order matters, add @Order to the listener (a smaller number means earlier in the queue).

Synchronous listeners: everything in one thread

By default a listener runs synchronously: the publisher calls publishEvent, and right at that moment, in the same thread, all listeners run one after another. The publisher waits until they finish, and only then continues.

createOrder()
    ├── orderRepository.save()
    ├── events.publishEvent(...)
    │       ├── listener1   ← one after another, in the same thread
    │       └── listener2   ← the publisher waits for both
    └── continue on

Three things follow from this that are easy to miss:

  • If a listener takes a long time — the publisher stands and waits the whole time.
  • If a listener throws an exception — it "breaks through" back to the publisher, just like an ordinary method call.
  • If all of this is inside a transaction and the listener writes to the database — the write lands in the same transaction.

Synchronous listeners are a sensible default. You move to asynchrony deliberately.

Asynchronous listeners: @Async

Sometimes the reaction shouldn't slow down the main work. Sending an email can take a second — it's silly to make the buyer wait for the response all that time. In that case you make the listener asynchronous: it runs in a separate thread, and the publisher doesn't wait.

First you have to enable asynchrony in the application:

@Configuration
@EnableAsync
class AsyncConfig { }

Then add @Async to the listener:

@Component
class OrderCreatedListener {

    @Async
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        email.sendConfirmation(...);   // runs in a different thread
    }
}

Now the publisher has published the event and immediately moved on, while the email goes out in parallel.

But there's a price to pay:

  • An exception from the listener "gets lost." The publisher has already moved on and won't learn about the failure. So that such errors don't silently vanish, they're caught by a separate handler (AsyncUncaughtExceptionHandler).
  • The listener runs outside the original transaction. It's in a different thread, with its own transaction — atomicity with the main work is lost.

Asynchronous listeners are good for optional side actions: emails, logs, notifications. For important things, @TransactionalEventListener (below) is more often the choice.

@TransactionalEventListener: wait for the transaction's outcome

Here's a common trap. A method runs in a transaction, publishes an event inside it, and a synchronous listener immediately sends an "order created" email. But what if the transaction rolls back after that and the order never actually reaches the database? The email is already gone — the buyer was told about an order that doesn't exist.

You'd like the listener to fire only if the transaction really completed successfully. That's what @TransactionalEventListener is for — it ties the listener to a specific transaction phase:

Phase (phase)When the listener fires
BEFORE_COMMITBefore the transaction commits
AFTER_COMMIT (default)After a successful commit
AFTER_ROLLBACKAfter a rollback
AFTER_COMPLETIONAfter completion — commit or rollback, doesn't matter
@Component
class OrderCreatedListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCommit(OrderCreatedEvent event) {
        email.sendConfirmation(...);   // only if the transaction committed
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onRollback(OrderCreatedEvent event) {
        log.warn("Order creation rolled back: {}", event.orderId());
    }
}

The main benefit: if the transaction rolls back, the listener simply won't be called. "The order wasn't saved — so there's no need to send the email" comes for free.

If you need to write to the database in the AFTER_COMMIT phase

A subtlety. The AFTER_COMMIT phase fires after the commit — the original transaction is already closed. If such a listener tries to write something to the database, the write simply won't be saved: there's nowhere to write, the transaction is gone. For the write to go through, the listener needs a new transaction — you request it with an annotation:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onCommit(OrderCreatedEvent event) {
    auditLog.save(new AuditEntry(event));   // in a separate new transaction
}

Without REQUIRES_NEW, a write from such a listener won't reach the database.

Which listener to choose

A short everyday rule:

  • The work must be part of the same transaction (for example, another database write alongside the main one) — plain @EventListener, synchronous.
  • The work must run only after a successful commit and doesn't write to the database (email, notification) — @TransactionalEventListener with the AFTER_COMMIT phase.
  • The work is optional and must not slow down the main flow (emails, logs) — @Async plus @EventListener, keeping in mind that exceptions get lost.

Spring's built-in events

Events aren't only your own classes. Spring itself broadcasts service messages about the application's life, and you can subscribe to those with the same @EventListener. For example, ApplicationReadyEvent arrives when the application has fully started and is ready to accept requests — a handy place for startup initialization:

@Component
class StartupListener {

    @EventListener
    public void onReady(ApplicationReadyEvent event) {
        log.info("Application started and ready to work");
    }
}

Where events are not the best choice

Spring events live inside a single running application, in its memory. If the server shuts down right after the transaction commits but before the listener has had a chance to run, the event simply disappears — there's no delivery guarantee here.

That's why they aren't used directly to forward events between different services (through a message queue): there you need a guarantee that the message won't be lost. For that you use a separate technique — the message and the business data are saved to the database in a single transaction, and a separate process later forwards it to the queue. Spring events remain for side actions inside a service: emails, cache, log.

In short

  • Events exist to decouple code: the publisher announces "X happened" and doesn't know who will react.
  • Three participants: the event (an ordinary object, usually a record), the publisher (ApplicationEventPublisher), the listener (@EventListener).
  • You don't need to extend ApplicationEvent — you publish any object.
  • By default @EventListener runs synchronously, in the same thread and the same transaction; the publisher waits for the listeners, and their exceptions come back to it.
  • @Async plus @EventListener — the listener runs in a separate thread, the publisher doesn't wait; but exceptions get lost and the work runs outside the original transaction.
  • @TransactionalEventListener ties the listener to a transaction phase: BEFORE_COMMIT, AFTER_COMMIT (default), AFTER_ROLLBACK, AFTER_COMPLETION.
  • In the AFTER_COMMIT phase the original transaction is already closed — to write to the database you need a new transaction (REQUIRES_NEW).
  • You can also subscribe to Spring's service events (ApplicationReadyEvent and others) with the same @EventListener.
  • Events live in the memory of a single application and provide no delivery guarantee — they aren't suited for reliable forwarding between services.
  • DI/IoC, bean lifecycle and scopes — how Spring creates beans and passes in dependencies like ApplicationEventPublisher.
  • @Transactional in depth — the transaction phases that @TransactionalEventListener ties into.
  • Spring AOP — @Async is built on it, and it has its own trap with calling a method inside the same class.