Decoupling beans without a broker
Every ApplicationContext is also a tiny publish/subscribe bus. One bean publishes an event; any number of
listeners react to it; and crucially, the publisher holds no reference to the listeners. That's the whole
point — an OrderService can announce "an order was placed" without knowing that an email sender, an audit
logger, and an inventory updater all care. Add a fourth reaction later and the publisher never changes.
@Service
class OrderService {
private final ApplicationEventPublisher publisher;
OrderService(ApplicationEventPublisher p) { this.publisher = p; }
void place(Order o) {
save(o);
publisher.publishEvent(new OrderPlacedEvent(o.id())); // who listens? not my problem
}
}
Since Spring 4.2 the event can be any plain object — no base class, no registration. A record is perfect.
Listening is just an annotated method
Subscribe by annotating a method with @EventListener and declaring the event type as its single parameter.
Spring registers it and invokes it whenever a matching event fires:
@Component
class EmailListener {
@EventListener
void on(OrderPlacedEvent e) { // the parameter type IS the subscription
sendConfirmation(e.orderId());
}
}
One bean can host many listeners, each for a different event type. You can narrow which events a listener cares
about with a SpEL condition, so the body only runs when it matters:
@EventListener(condition = "#event.total > 1000")
void onLargeOrder(OrderPlacedEvent event) { // only high-value orders
flagForReview(event);
}
The "events are async" myth
The single biggest misconception: by default, listeners are synchronous and run on the publisher's thread.
publishEvent does not return until every listener has finished, sequentially, in the same call stack. So a slow
listener slows the publisher, and an exception in a sync listener propagates back to the publisher and aborts
the remaining listeners. Out of the box, an "event" is really just a decoupled method call.
To actually leave the publisher's thread, add @Async (with @EnableAsync):
@Async
@EventListener
void on(OrderPlacedEvent e) { // now runs on an executor; publishEvent returns at once
slowAuditWrite(e);
}
But async listeners come with strings attached: they can't propagate exceptions back to the publisher, and
because they run on a different thread they lose the thread-bound transaction and security context. The
SecurityContextHolder is empty inside an async listener unless you propagate it deliberately or carry what you
need inside the event payload.
The transaction trap, and how @TransactionalEventListener fixes it
Here's a bug that bites everyone eventually. You publish "order placed", an @Async listener fires off a
confirmation email — and then the surrounding transaction rolls back. Now you've emailed a customer about an
order that doesn't exist. The fix is @TransactionalEventListener, which binds the listener to a transaction
phase and (by default) only fires after the transaction commits:
@TransactionalEventListener // default phase: AFTER_COMMIT
void on(OrderPlacedEvent e) {
sendConfirmation(e); // only runs if the data was actually saved
}
Other phases exist (BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION), but AFTER_COMMIT is the workhorse:
side effects that must only happen if the write was real. One gotcha — if there's no active transaction, the
listener is silently skipped. That's why it can mysteriously not fire in a test that published outside a
transaction. Set fallbackExecution = true to make it run anyway.
Ordering, chaining, and built-in events
For synchronous listeners that have a dependency, control their order with @Order (lower runs first); without
it, order is undefined. A listener can even return a value, which Spring publishes as a new event — handy
for chaining, easy to over-use:
@Order(1) @EventListener void validateFirst(OrderPlacedEvent e) { }
@Order(2) @EventListener void thenNotify(OrderPlacedEvent e) { }
Spring Boot also publishes its own lifecycle events. The most useful is ApplicationReadyEvent, fired once when
the app is fully started and ready to serve traffic — the right hook for one-time startup work:
@EventListener(ApplicationReadyEvent.class)
void warmCaches() { // runs once, after the context and web server are up
preloadReferenceData();
}
Where events stop being the answer
Application events are in-memory and single-JVM. They vanish on restart, never cross a process boundary, and offer no persistence or delivery guarantees. They're for decoupling beans inside one app. The moment you need a consumer in another service, durability, or at-least-once delivery, you need a real broker — Kafka or RabbitMQ — not the event bus. And the choice between an event and a plain method call is about coupling: one known collaborator, call it directly for clarity and traceability; many optional or cross-cutting reactions, publish an event.
Rule of thumb: Use application events to decouple beans within a process, make them @Async or
@TransactionalEventListener when timing matters — and reach for a broker the moment the reaction lives in
another service.