Application events & ApplicationEventPublisher

java dev.to

Every application has moments where one thing happens and several other things need to react. A user signs up — and now you want to send a welcome email, create their starter workspace, and bump a signup counter. The signup itself is one job. The reactions are three more, and they have nothing to do with each other.

The blunt way is to have the signup code call each of those directly. It works, but the signup code now has to know about email, workspaces, and metrics. Add a fourth reaction next quarter and you edit the signup code again.

Spring offers a different shape. The signup code announces "a user was registered" and then forgets about it. Anything that cares listens for that announcement and reacts on its own. This is application events, and it is built into the Spring container you already use.

Let me first make sure the two words in that last sentence are real.

A one-paragraph recap of the container

Spring keeps your objects for you. You hand it a class, it builds one instance, and it hands that instance to whatever else needs it. That managed instance is a bean, and the thing holding all the beans is the container. When you write a constructor that takes another object, the container looks up the matching bean and passes it in. Everything below rides on this: the event machinery is itself a bean the container hands you.

Announcing that something happened

To announce an event, you need the thing that broadcasts it. Spring exposes it as a bean called ApplicationEventPublisher. You ask for it the same way you ask for any bean — through the constructor.

@Service
class RegistrationService {

    private final ApplicationEventPublisher publisher;

    RegistrationService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now RegistrationService can broadcast. But broadcast what? For a long time Spring required events to extend a base class, but since Spring 4.2 an event is just a plain object — any class you like. So the event is simply a small carrier of "what happened."

record UserRegistered(String email) {}
Enter fullscreen mode Exit fullscreen mode

That is the whole event: a record holding the new user's email. Publishing it is one line.

void register(String email) {
    // ... save the user ...
    publisher.publishEvent(new UserRegistered(email));
}
Enter fullscreen mode Exit fullscreen mode

Here is what just happened. register did its own job — saving the user — and then handed a UserRegistered object to the publisher. It did not call the email service. It does not know an email service exists. It said "this happened" and moved on. Which raises the obvious question: who hears it?

Listening for the event

A listener is any bean method marked with @EventListener, typed to the event it cares about.

@Component
class WelcomeEmailListener {

    @EventListener
    void on(UserRegistered event) {
        System.out.println("Sending welcome email to " + event.email());
    }
}
Enter fullscreen mode Exit fullscreen mode

Spring reads the method's parameter type, UserRegistered, and wires it up: whenever an event of that type is published, this method runs with the event passed in. Add a second listener for the same type and both run. The publisher's one line now fans out to as many reactions as you have, and it never learns their names.

@Component
class SignupMetrics {

    @EventListener
    void on(UserRegistered event) {
        // increment a counter
    }
}
Enter fullscreen mode Exit fullscreen mode

Two listeners, one event, zero coupling between them. To add analytics next quarter you write a third listener and touch nothing else. That is the whole point of the pattern — but the way Spring runs these listeners has consequences you need to see.

The part everyone gets wrong: it is synchronous

It is natural to picture the publisher tossing the event over a wall and carrying on while listeners work in the background. That is not what happens. By default, publishEvent runs every listener right there, on the same thread, before it returns.

So this line:

publisher.publishEvent(new UserRegistered(email));
Enter fullscreen mode Exit fullscreen mode

does not finish until the welcome email has been sent and the metrics counter bumped. register is blocked the entire time. The event system decouples who knows about whom, not when things run. It is still one straight-line call.

Two consequences fall out of that, and both bite in production.

First, speed. If sending the email takes two seconds, register takes two seconds longer. The user waits for work they do not care about.

Second, and worse, failure. Because it is all one thread, an exception thrown by a listener travels straight back up into register.

@EventListener
void on(UserRegistered event) {
    throw new RuntimeException("mail server down");
}
Enter fullscreen mode Exit fullscreen mode

That exception is not caught for you. It propagates out of publishEvent, out of register, and — if register was running inside a database transaction — it rolls the transaction back. The user you just saved is un-saved because the welcome email failed. A reaction that was meant to be a harmless side effect has killed the main job.

So the next question writes itself: how do we stop a listener from blocking or breaking the publisher?

Moving the work off the thread with @async

If the problem is that the listener runs on the publisher's thread, the fix is to run it on a different one. Mark the listener @Async and Spring hands it to a background thread pool instead of running it inline.

@Component
class WelcomeEmailListener {

    @Async
    @EventListener
    void on(UserRegistered event) {
        // now runs on a background thread
    }
}
Enter fullscreen mode Exit fullscreen mode

publishEvent now returns immediately; the email is sent later, elsewhere. The blocking is gone, and so is the failure path — an exception on the background thread cannot roll back the publisher's transaction, because it is not on the publisher's thread anymore.

One catch: @Async does nothing unless you switch it on. Somewhere in your configuration you need @EnableAsync.

@Configuration
@EnableAsync
class AsyncConfig {}
Enter fullscreen mode Exit fullscreen mode

Without it, @Async is silently ignored and you are back to synchronous — a genuinely confusing bug, because the annotation is right there and looks active. But async trades one problem for a subtler one, which is worth seeing clearly.

The transaction trap

Go back to the synchronous version for a moment, because it hides the nastiest gotcha of all. Picture the email being sent inside the transaction:

@Transactional
void register(String email) {
    save(email);
    publisher.publishEvent(new UserRegistered(email)); // listener runs, still inside the tx
}
Enter fullscreen mode Exit fullscreen mode

The listener runs before register returns, which means it runs before the transaction commits. The email goes out. Then, a line later, the transaction hits a constraint violation and rolls back. The user is gone from the database — but the welcome email is already in their inbox. You have sent mail about a user that does not exist.

Async does not cleanly fix this either: the background thread might fire before or after the commit, and now you are racing.

Spring's answer is a listener that waits for the transaction itself. Swap @EventListener for @TransactionalEventListener, and it runs only at a chosen point in the transaction's lifecycle — by default, AFTER_COMMIT.

@Component
class WelcomeEmailListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void on(UserRegistered event) {
        // runs only after the transaction has successfully committed
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the ordering is guaranteed: the user is safely committed first, and only then does the email go out. If the transaction rolls back, the listener never runs at all. This is the correct home for any side effect that must not happen unless the main work actually committed. One thing to remember: if there is no active transaction when the event is published, an AFTER_COMMIT listener has nothing to wait for and, by default, simply does not fire.

Two smaller controls worth knowing

Once you have several listeners on one event, two questions come up.

Order. Listeners have no guaranteed order by default. If email must run before metrics, annotate them with @Order — the lower number runs first.

@Order(1)
@EventListener
void sendEmail(UserRegistered event) { }

@Order(2)
@EventListener
void recordMetric(UserRegistered event) { }
Enter fullscreen mode Exit fullscreen mode

Filtering. Sometimes a listener only cares about some events of a type. @EventListener takes a condition written in Spring's expression language, and the method runs only when it evaluates to true. Here #event refers to the published event.

@EventListener(condition = "#event.email().endsWith('@vip.com')")
void on(UserRegistered event) {
    // runs only for VIP signups
}
Enter fullscreen mode Exit fullscreen mode

The event is still published to everyone; this listener just opts out of the ones it does not want.

Where this comes from for free

You do not only publish your own events. Spring itself publishes events as your application starts and stops, using this exact mechanism. The most useful one is ApplicationReadyEvent, fired once the container is fully built and ready to serve traffic — the right place to run one-time startup work.

@EventListener(ApplicationReadyEvent.class)
void warmUp() {
    // load caches, ping downstream services
}
Enter fullscreen mode Exit fullscreen mode

Because it is the same @EventListener you already understand, framework events and your own events read identically. There is nothing new to learn — the container is just another publisher.

The one mental model to keep

Application events let one bean announce that something happened without knowing who reacts. A publisher broadcasts a plain object; any bean with a matching @EventListener method receives it.

The single fact that governs everything else: listeners run synchronously, on the publisher's thread, inside the publisher's transaction, unless you say otherwise. From that one fact the rest follows — @Async to move the work off the thread, @EnableAsync to actually turn it on, and @TransactionalEventListener(AFTER_COMMIT) to make a side effect wait for the commit that justifies it. Get that fact right, and the pattern is a clean way to keep your components from ever having to know about each other.

Source: dev.to

arrow_back Back to Tutorials