Application Events in Spring with @EventListener
Introduction
This tutorial explains application events in Spring: one bean publishes an event, and any number of other beans react to it, without the publisher knowing who they are. We will publish a plain record as an event, handle it with@EventListener methods, order them, filter them with a condition, and publish a follow-up event from a listener's return value. We will then see that listeners run synchronously by default - so that a failing listener can roll back the publisher's transaction - make one asynchronous, and use @TransactionalEventListener to react only after a transaction has committed, or rolled back. Every output below comes from running the code with Spring Boot 4.1.2.Why events
When an order is placed, several things should follow: a confirmation email, a stock update, loyalty points for big orders, an entry in the analytics. Written as direct calls, the order service depends on the email service, the stock service, the loyalty service and the analytics service, and each new reaction means changing the order service again. With an event, the order service only announces what happened - "order 1 was placed" - and each reaction is a listener in its own class. The order service depends on none of them; a new reaction is a new listener, and nothing else changes. Spring's application context is the event bus:ApplicationEventPublisher.publishEvent() hands the event to every bean method that listens for its type.
The event and the publisher
An event can be any object; a record is ideal, since an event describes something that has happened, and should not change. (Events no longer need to extendApplicationEvent; Spring wraps other objects in a PayloadApplicationEvent by itself.) The order service saves an order in an H2 database and publishes the event, both inside one transaction -
src/main/resources/schema.sql
create table orders (id bigint auto_increment primary key, customer varchar(20) not null, total int not null);
src/main/java/.../shop/OrderPlaced.java
//OrderPlaced.java
package com.javabrahman.events.shop;
public record OrderPlaced(long orderId, String customer, int total) {
}
src/main/java/.../shop/OrderService.java
//OrderService.java
package com.javabrahman.events.shop;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcClient jdbc;
private final ApplicationEventPublisher events;
public OrderService(JdbcClient jdbc, ApplicationEventPublisher events) {
this.jdbc = jdbc;
this.events = events;
}
@Transactional
public long place(String customer, int total) {
KeyHolder key = new GeneratedKeyHolder();
jdbc.sql("insert into orders (customer, total) values (?, ?)").params(customer, total).update(key);
long id = key.getKey().longValue();
System.out.println("OrderService: order " + id + " saved, publishing OrderPlaced on " + Thread.currentThread().getName());
events.publishEvent(new OrderPlaced(id, customer, total));
System.out.println("OrderService: place() returns");
return id;
}
@Transactional
public long placeButPaymentFails(String customer, int total) {
long id = place(customer, total); // a self-call: runs inside this method's transaction
throw new IllegalStateException("payment declined for order " + id);
}
public int count() {
return jdbc.sql("select count(*) from orders").query(Integer.class).single();
}
}
ApplicationEventPublisher (the application context itself implements it).
Listening with @EventListener
A listener is a public method of a bean, annotated with@EventListener, whose parameter type says which events it receives. These listeners show the main features -
src/main/java/.../sync/OrderListeners.java
//OrderListeners.java
package com.javabrahman.events.sync;
import com.javabrahman.events.shop.OrderPlaced;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class OrderListeners {
@EventListener
@Order(1)
public void sendConfirmation(OrderPlaced event) {
System.out.println(" email listener: confirmation for order " + event.orderId()
+ " to " + event.customer() + ", on " + Thread.currentThread().getName());
}
@EventListener
@Order(2)
public void updateStock(OrderPlaced event) {
System.out.println(" stock listener: stock reserved for order " + event.orderId());
}
// only for big orders; the returned object is published as a new event
@EventListener(condition = "#event.total > 1000")
@Order(3)
public LoyaltyPointsEarned awardPoints(OrderPlaced event) {
System.out.println(" loyalty listener: big order, awarding points");
return new LoyaltyPointsEarned(event.customer(), event.total() / 100);
}
@EventListener
public void pointsEarned(LoyaltyPointsEarned event) {
System.out.println(" points listener: " + event.customer() + " earned " + event.points() + " points");
}
@EventListener
@Order(4)
public void analytics(OrderPlaced event) {
if (event.customer().equals("bob")) {
throw new IllegalStateException("analytics service down");
}
System.out.println(" analytics: order " + event.orderId() + " recorded");
}
}
src/main/java/.../sync/LoyaltyPointsEarned.java
//LoyaltyPointsEarned.java
package com.javabrahman.events.sync;
public record LoyaltyPointsEarned(String customer, int points) {
}
src/main/java/.../sync/LifecycleListener.java - Spring's own events
//LifecycleListener.java
package com.javabrahman.events.sync;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class LifecycleListener {
@EventListener(ApplicationReadyEvent.class)
public void ready() {
System.out.println("[ApplicationReadyEvent] the application is ready");
}
@EventListener
public void closing(ContextClosedEvent event) {
System.out.println("[ContextClosedEvent] the context is closing");
}
}
src/main/java/.../sync/SyncEventsApp.java
//SyncEventsApp.java
package com.javabrahman.events.sync;
import com.javabrahman.events.shop.OrderService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication(scanBasePackages = {"com.javabrahman.events.shop", "com.javabrahman.events.sync"})
public class SyncEventsApp {
public static void main(String[] args) {
try (ConfigurableApplicationContext context = SpringApplication.run(SyncEventsApp.class, args)) {
OrderService orders = context.getBean(OrderService.class);
System.out.println("1. a small order by ann:");
orders.place("ann", 400);
System.out.println("2. a big order by raj:");
orders.place("raj", 2500);
System.out.println("3. an order by bob - the analytics listener throws:");
try {
orders.place("bob", 300);
} catch (IllegalStateException e) {
System.out.println("caller caught: " + e.getMessage());
}
System.out.println("orders in the database: " + orders.count());
}
}
}
[ApplicationReadyEvent] the application is ready 1. a small order by ann: OrderService: order 1 saved, publishing OrderPlaced on main email listener: confirmation for order 1 to ann, on main stock listener: stock reserved for order 1 analytics: order 1 recorded OrderService: place() returns 2. a big order by raj: OrderService: order 2 saved, publishing OrderPlaced on main email listener: confirmation for order 2 to raj, on main stock listener: stock reserved for order 2 loyalty listener: big order, awarding points points listener: raj earned 25 points analytics: order 2 recorded OrderService: place() returns 3. an order by bob - the analytics listener throws: OrderService: order 3 saved, publishing OrderPlaced on main email listener: confirmation for order 3 to bob, on main stock listener: stock reserved for order 3 caller caught: analytics service down orders in the database: 2 [ContextClosedEvent] the context is closing
@EventListener -
- Synchronous: every listener ran on
main, the publisher's thread, insidepublishEvent(), andplace()continued only after the last listener had returned. The publisher is decoupled from the listeners in code, but not in time. - Ordered:
@Orderset the sequence email, stock, loyalty, analytics. Without it, the order is undefined. - Conditional and chained:
condition = "#event.total > 1000", a SpEL expression over the event, let the loyalty listener run only for Raj's order. TheLoyaltyPointsEarnedit returned was published as an event of its own, and handled at once. A listener may also return a collection or an array of events. - Exceptions reach the publisher: the analytics listener's exception came out of
publishEvent()and out ofplace(). Becauseplace()is@Transactional, the exception rolled back Bob's order: the database holds two orders, not three. A broken analytics service just stopped orders from being taken.
ApplicationReadyEvent when the application has started, ContextClosedEvent when it shuts down, and many more, which a listener can use for startup and cleanup work.
Asynchronous listeners
A listener that is slow, or that may fail without affecting the publisher, can run on another thread:@Async on the listener method, with @EnableAsync, as in the @Async tutorialClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads. The publisher then continues at once, and the listener's exceptions go to the asynchronous exception handler instead of the publisher. The next example uses one.
@TransactionalEventListener
Synchronous listeners have a second problem. They run inside the publisher's transaction, before it commits. The confirmation email of the first example went out before the order was committed - and had the commit then failed, the customer would have a confirmation for an order that does not exist.@TransactionalEventListener solves this: Spring holds the event until the transaction ends, and then calls the listener in a chosen phase -
AFTER_COMMIT(the default): only if the transaction committed. The place for emails, messages to other systems, and anything that must not happen for an order that does not exist.AFTER_ROLLBACK: only if it rolled back.AFTER_COMPLETION: after either.BEFORE_COMMIT: just before the commit, still inside the transaction; a failure there rolls it back.
src/main/java/.../tx/TransactionalListeners.java
//TransactionalListeners.java
package com.javabrahman.events.tx;
import com.javabrahman.events.shop.OrderPlaced;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class TransactionalListeners {
@EventListener
public void immediately(OrderPlaced event) {
System.out.println(" @EventListener: order " + event.orderId() + ", at once");
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderPlaced event) {
System.out.println(" @TransactionalEventListener BEFORE_COMMIT: order " + event.orderId());
}
@TransactionalEventListener // AFTER_COMMIT
public void sendConfirmation(OrderPlaced event) {
System.out.println(" @TransactionalEventListener AFTER_COMMIT: confirmation email for order " + event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void orderFailed(OrderPlaced event) {
System.out.println(" @TransactionalEventListener AFTER_ROLLBACK: order " + event.orderId() + " was rolled back");
}
@TransactionalEventListener(fallbackExecution = true)
public void audit(OrderPlaced event) {
System.out.println(" AFTER_COMMIT with fallbackExecution: audit order " + event.orderId());
}
@Async
@TransactionalEventListener
public void analytics(OrderPlaced event) {
System.out.println(" @Async AFTER_COMMIT on " + Thread.currentThread().getName() + ": analytics for order " + event.orderId());
}
}
src/main/java/.../tx/TransactionalEventsApp.java
//TransactionalEventsApp.java
package com.javabrahman.events.tx;
import com.javabrahman.events.shop.OrderPlaced;
import com.javabrahman.events.shop.OrderService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication(scanBasePackages = {"com.javabrahman.events.shop", "com.javabrahman.events.tx"})
@EnableAsync
public class TransactionalEventsApp {
public static void main(String[] args) throws InterruptedException {
try (ConfigurableApplicationContext context = SpringApplication.run(TransactionalEventsApp.class, args)) {
OrderService orders = context.getBean(OrderService.class);
System.out.println("1. an order that commits:");
orders.place("ann", 400);
Thread.sleep(200); // lets the @Async listener print before the next step
System.out.println("2. an order whose payment fails - rollback:");
try {
orders.placeButPaymentFails("raj", 900);
} catch (IllegalStateException e) {
System.out.println("caller caught: " + e.getMessage());
}
System.out.println("3. an event published without a transaction:");
context.publishEvent(new OrderPlaced(99, "test", 1));
System.out.println("orders in the database: " + orders.count());
}
}
}
1. an order that commits: OrderService: order 1 saved, publishing OrderPlaced on main @EventListener: order 1, at once OrderService: place() returns @TransactionalEventListener BEFORE_COMMIT: order 1 @TransactionalEventListener AFTER_COMMIT: confirmation email for order 1 AFTER_COMMIT with fallbackExecution: audit order 1 @Async AFTER_COMMIT on task-1: analytics for order 1 2. an order whose payment fails - rollback: OrderService: order 2 saved, publishing OrderPlaced on main @EventListener: order 2, at once OrderService: place() returns @TransactionalEventListener AFTER_ROLLBACK: order 2 was rolled back caller caught: payment declined for order 2 3. an event published without a transaction: @EventListener: order 99, at once AFTER_COMMIT with fallbackExecution: audit order 99 orders in the database: 1
- The commit: the plain
@EventListenerran at once, insidepublishEvent(). The transactional listeners waited untilplace()had returned and the transaction was ending:BEFORE_COMMITjust before the commit, then theAFTER_COMMITones. The@Asyncone ran on atask-thread. - The rollback: the payment failed after the event was published. The plain listener had already run - it cannot be undone - but the
BEFORE_COMMITandAFTER_COMMITlisteners never ran, so no confirmation went out for an order that does not exist. TheAFTER_ROLLBACKlistener ran instead. - No transaction: an event published outside any transaction reached the plain listener, and of the transactional ones only the listener with
fallbackExecution = true. The others are silently skipped - a classic surprise when an event is moved from a@Transactionalmethod to one without.
Pitfalls of transactional listeners
- Database writes after the commit: an
AFTER_COMMITlistener runs when the transaction has committed, but its connection is still bound to the thread. Writes there are not committed any more. A listener that writes needs a new transaction:@Transactional(propagation = Propagation.REQUIRES_NEW)on the listener method. Spring checks this at startup, and rejects a@Transactionaltransactional listener with any propagation other thanREQUIRES_NEWorNOT_SUPPORTED. - Exceptions after the commit do not affect the publisher's transaction - it is already committed - and do not reach the caller of the transactional method as a failure of that method.
- Not durable: the event lives in memory between the commit and the listener. If the application crashes in between, the email is never sent. Where that matters, store the event in the same transaction as the order (the "outbox" pattern); Spring Modulith's event publication registry does exactly that for
@TransactionalEventListeners, and re-publishes unfinished events at restart.
When to use events
- Use them for reactions that are secondary to the main action, and that the publisher should not know about: notifications, audit entries, cache invalidation, statistics, updates of other modules of the application.
- Prefer a direct call when the caller needs the result, or when the step is part of the business operation itself. Hidden control flow is harder to follow; events trade a dependency in code for a dependency that only shows at runtime.
- Keep events small and immutable: ids and the facts that listeners need, as a record.
- Within one application only: application events never leave the JVM. To tell other services, a listener can forward the event to a message broker - preferably in
AFTER_COMMIT.
Summary
In this tutorial an order service published anOrderPlaced record with ApplicationEventPublisher, and @EventListener methods reacted to it without the service knowing them. The listeners ran synchronously on the publisher's thread, in @Order, filtered by a SpEL condition, and a returned event was published in turn; a failing listener rolled back the publisher's transaction. @TransactionalEventListener held events until the transaction ended: the confirmation was sent only after a commit, an AFTER_ROLLBACK listener reported the failed payment, and without a transaction only the fallbackExecution listener ran. The next tutorial applies one more of Spring's aspects: @Retryable and @ConcurrencyLimitClick to read tutorial: Resilience in Spring 7: @Retryable and @ConcurrencyLimit.Spring AOP, Caching, Scheduling and Events Tutorials
Async Processing in Spring with @Async and Virtual ThreadsClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads Resilience in Spring 7: @Retryable and @ConcurrencyLimitClick to read tutorial: Resilience in Spring 7: @Retryable and @ConcurrencyLimit Spring AOP Explained: Aspects, Advice and PointcutsClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts How Spring Proxies Work and the Self-Invocation TrapClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap Caching in Spring Boot with @Cacheable and CaffeineClick to read tutorial: Caching in Spring Boot with @Cacheable and Caffeine Scheduling Tasks in Spring with @ScheduledClick to read tutorial: Scheduling Tasks in Spring with @Scheduled
Async Processing in Spring with @Async and Virtual ThreadsClick to read tutorial: Async Processing in Spring with @Async and Virtual Threads Resilience in Spring 7: @Retryable and @ConcurrencyLimitClick to read tutorial: Resilience in Spring 7: @Retryable and @ConcurrencyLimit Spring AOP Explained: Aspects, Advice and PointcutsClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts How Spring Proxies Work and the Self-Invocation TrapClick to read tutorial: How Spring Proxies Work and the Self-Invocation Trap Caching in Spring Boot with @Cacheable and CaffeineClick to read tutorial: Caching in Spring Boot with @Cacheable and Caffeine Scheduling Tasks in Spring with @ScheduledClick to read tutorial: Scheduling Tasks in Spring with @Scheduled