Spring Boot Application Events
Spring Boot application events let one part of your application announce that something happened, such as "an order was placed", while other parts react to it without being called directly. The order service doesn't need to know that stock is reserved, loyalty points are awarded and an email goes out. It publishes one event, and each concern listens for it. That keeps classes small and lets you add new reactions without touching the code that triggers them.
This tutorial explains how Spring delivers events internally, then covers event classes, publishing, @EventListener, ordering and conditions, @TransactionalEventListener, asynchronous listeners and the lifecycle events Spring Boot publishes itself. You will build an order flow on Spring Boot 4.1, see the real log output, learn the pitfalls that silently drop events, and test events with @RecordApplicationEvents.
1. What Are Spring Boot Application Events?
An application event is a plain Java object that describes something that already happened, such as OrderPlacedEvent. Three roles are involved:
- The publisher creates the event and hands it to Spring through
ApplicationEventPublisher. - The listeners are methods annotated with
@EventListener(or@TransactionalEventListener) that Spring calls when an event of their parameter type is published. - The application context sits in between and delivers each event to every matching listener.
These events live inside one running application. They are not messages sent to other services. That is the job of a broker such as Kafka or RabbitMQ.
2. Why Use Application Events
- Loose coupling. The publisher depends only on the event, not on every class that reacts to it.
- Easy extension. A new reaction, such as fraud checks, is a new listener. The order service stays unchanged.
- Control over timing. Each listener chooses to run immediately, only after the transaction commits, or on another thread.
- Simpler tests. You can test that a service publishes the right event without running every downstream reaction.
3. How Spring Delivers Events
When you call publishEvent, Spring's event multicaster looks up every listener whose parameter type matches the event and calls them one after another, in @Order order. By default this is synchronous: the listeners run on the publisher's thread, inside its transaction, before publishEvent returns. If a listener throws, the exception travels back to the publisher.
Transactional listeners are different. At publish time they don't run. They register a callback with the current transaction and run later, when it commits or rolls back. @Async listeners hand the work to a thread pool instead.
4. Setup
Application events are part of the Spring Framework core, so no extra dependency is needed. The example uses Spring MVC, JDBC and H2:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Asynchronous listeners also need @EnableAsync on a configuration class, shown in section 7.
5. Publishing and Listening to Events
5.1 Defining an Event
Any object can be an event. A record is ideal because it is immutable and lists exactly what listeners may rely on. Name events in the past tense, because they describe facts:
public record OrderPlacedEvent(long orderId, String customerEmail, String sku, int quantity, BigDecimal total) {
}
Extending ApplicationEvent is no longer required. It has been optional since Spring 4.2.
5.2 Publishing with ApplicationEventPublisher
Inject ApplicationEventPublisher through the constructor and call publishEvent:
private final ApplicationEventPublisher events;
public OrderService(JdbcTemplate jdbcTemplate, ApplicationEventPublisher events) {
this.jdbcTemplate = jdbcTemplate;
this.events = events;
}
@Transactional
public OrderResponse placeOrder(PlaceOrderRequest request) {
// ... insert the order and read its generated id ...
events.publishEvent(new OrderPlacedEvent(orderId, request.customerEmail(),
request.sku(), request.quantity(), total));
return new OrderResponse(orderId, request.sku(), request.quantity(), total);
}
5.3 Listening with @EventListener
Annotate a method on any Spring bean. Its single parameter decides which events it receives, and it receives subclasses and implementations too:
// Runs immediately, on the publisher's thread and inside its transaction
@EventListener
@Order(1)
public void reserveStock(OrderPlacedEvent event) {
int updated = jdbcTemplate.update(
"UPDATE stock SET quantity = quantity - ? WHERE sku = ? AND quantity >= ?",
event.quantity(), event.sku(), event.quantity());
if (updated == 0) {
throw new OutOfStockException(event.sku(), event.quantity());
}
log.info("Reserved {} x {} for order {}", event.quantity(), event.sku(), event.orderId());
}
This listener runs synchronously inside the publisher's transaction. That makes it the right place for work that must succeed or fail together with the order, such as reserving stock.
5.4 Ordering, Conditions and Follow-Up Events
Use @Order to control the order of listeners for the same event (lower runs first), and the condition attribute, a SpEL expression, to skip events. A listener can also return a new event, which Spring publishes for you:
@Component
public class BulkOrderListener {
private static final Logger log = LoggerFactory.getLogger(BulkOrderListener.class);
public record BulkOrderFlagged(long orderId, int quantity) {
}
// Only for orders of 3 or more items; the returned object is published as a new event
@EventListener(condition = "#event.quantity() >= 3")
public BulkOrderFlagged flagBulkOrder(OrderPlacedEvent event) {
log.info("Bulk order {} with {} items", event.orderId(), event.quantity());
return new BulkOrderFlagged(event.orderId(), event.quantity());
}
@EventListener
public void onFlag(BulkOrderFlagged flagged) {
log.info("Follow-up event received for order {}", flagged.orderId());
}
}
INFO c.j.shop.order.BulkOrderListener : Bulk order 2 with 3 items
INFO c.j.shop.order.BulkOrderListener : Follow-up event received for order 2
5.5 @TransactionalEventListener
Many reactions should only happen once the data is safely committed. A confirmation email for an order that later rolls back would be wrong. @TransactionalEventListener delays the listener until a chosen phase of the publisher's transaction:
| Phase | Runs | Typical use |
|---|---|---|
AFTER_COMMIT (default) | After a successful commit | Emails, notifications, cache updates, calls to other services |
AFTER_ROLLBACK | After a rollback | Logging or alerting on failures |
AFTER_COMPLETION | After commit or rollback | Clean-up that must always happen |
BEFORE_COMMIT | Just before the commit | Last-moment checks or writes that belong to the same transaction |
An after-commit listener runs when the publisher's transaction is already finished. If it writes to the database, give it its own transaction with REQUIRES_NEW (the transaction management tutorial explains propagation):
// Runs only after the order is committed; writes in a new transaction of its own
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void awardPoints(OrderPlacedEvent event) {
int points = event.total().intValue() / 10;
jdbcTemplate.update("INSERT INTO loyalty_points (customer_email, order_id, points) VALUES (?, ?, ?)",
event.customerEmail(), event.orderId(), points);
log.info("Awarded {} points to {} for order {}", points, event.customerEmail(), event.orderId());
}
If no transaction is active when the event is published, transactional listeners are skipped. Section 9.3 covers that pitfall.
5.6 Asynchronous Listeners with @Async
Add @Async (plus @EnableAsync once in your configuration) and the listener runs on a thread from Spring Boot's task executor, so the publisher doesn't wait for it. Combined with @TransactionalEventListener, the work starts after the commit, on another thread:
// After commit, on a separate thread: the customer doesn't wait for the email
@Async
@TransactionalEventListener
public void sendConfirmation(OrderPlacedEvent event) {
log.info("Emailing order {} confirmation to {}", event.orderId(), event.customerEmail());
}
The async listener's exceptions never reach the publisher. Log them, and retry if the work matters. The async processing tutorial covers executors in detail.
6. Spring Boot Lifecycle Events
Spring Boot publishes its own events while starting and stopping. This is the order recorded for the example app on Spring Boot 4.1:
| # | Event | Published when |
|---|---|---|
| 1 | ApplicationStartingEvent | At the very start, before logging and the environment are ready |
| 2 | ApplicationEnvironmentPreparedEvent | Configuration is loaded, but no context exists yet |
| 3 | ApplicationContextInitializedEvent | The context is created and initializers have run |
| 4 | ApplicationPreparedEvent | Bean definitions are loaded, but beans are not yet created |
| 5 | ServletWebServerInitializedEvent | The embedded web server is listening |
| 6 | ContextRefreshedEvent | All singleton beans are created |
| 7 | ApplicationStartedEvent | The context is refreshed, before command-line runners |
| 8 | AvailabilityChangeEvent (CORRECT) | The app is live |
| 9 | ApplicationReadyEvent | Runners have finished and the app can serve requests |
| 10 | AvailabilityChangeEvent (ACCEPTING_TRAFFIC) | The app is ready for traffic |
| 11 | AvailabilityChangeEvent (REFUSING_TRAFFIC), then ContextClosedEvent | On shutdown |
ApplicationReadyEvent is the usual choice for "do this once the app is up", such as warming a cache or logging a summary:
// Spring Boot publishes ApplicationReadyEvent once the app can serve requests
@EventListener(ApplicationReadyEvent.class)
public void reportStock() {
Integer skus = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stock", Integer.class);
log.info("Shop is ready with {} SKUs in stock", skus);
}
Events 1 to 4 fire before your beans exist, so an @EventListener bean can't receive them. Register such listeners with SpringApplication.addListeners(...) instead. The availability events also drive Actuator's liveness and readiness health groups, described in the Spring Boot Actuator tutorial.
7. A Complete Example: Order Placed Events
Placing an order publishes one OrderPlacedEvent, and four listeners react to it, each with a different timing:
| Listener | Annotation | Runs | Effect of a failure |
|---|---|---|---|
| Reserve stock | @EventListener | Immediately, same thread and transaction | Rolls back the whole order |
| Award loyalty points | @TransactionalEventListener + REQUIRES_NEW | After commit, in its own transaction | The order stays committed, and the error is logged |
| Email confirmation | @Async + @TransactionalEventListener | After commit, on a pool thread | The order stays committed, and the error is logged |
| Log failed order | @TransactionalEventListener(phase = AFTER_ROLLBACK) | After rollback | Not applicable |
7.1 The Event and the Publisher
package com.javabytechie.shop.order;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.sql.Statement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private static final BigDecimal UNIT_PRICE = new BigDecimal("89.00");
private final JdbcTemplate jdbcTemplate;
private final ApplicationEventPublisher events;
public OrderService(JdbcTemplate jdbcTemplate, ApplicationEventPublisher events) {
this.jdbcTemplate = jdbcTemplate;
this.events = events;
}
@Transactional
public OrderResponse placeOrder(PlaceOrderRequest request) {
BigDecimal total = UNIT_PRICE.multiply(BigDecimal.valueOf(request.quantity()));
GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO orders (customer_email, sku, quantity, total) VALUES (?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
ps.setString(1, request.customerEmail());
ps.setString(2, request.sku());
ps.setInt(3, request.quantity());
ps.setBigDecimal(4, total);
return ps;
}, keyHolder);
long orderId = keyHolder.getKey().longValue();
log.info("Saved order {}, publishing OrderPlacedEvent", orderId);
events.publishEvent(new OrderPlacedEvent(orderId, request.customerEmail(),
request.sku(), request.quantity(), total));
log.info("Listeners for order {} have run", orderId);
return new OrderResponse(orderId, request.sku(), request.quantity(), total);
}
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
// An event is any object; a record keeps it immutable
public record OrderPlacedEvent(long orderId, String customerEmail, String sku, int quantity, BigDecimal total) {
}
package com.javabytechie.shop.order;
public record PlaceOrderRequest(String customerEmail, String sku, int quantity) {
}
package com.javabytechie.shop.order;
import java.math.BigDecimal;
public record OrderResponse(long orderId, String sku, int quantity, BigDecimal total) {
}
7.2 The Listeners
package com.javabytechie.shop.order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class InventoryListener {
private static final Logger log = LoggerFactory.getLogger(InventoryListener.class);
private final JdbcTemplate jdbcTemplate;
public InventoryListener(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// Runs immediately, on the publisher's thread and inside its transaction
@EventListener
@Order(1)
public void reserveStock(OrderPlacedEvent event) {
int updated = jdbcTemplate.update(
"UPDATE stock SET quantity = quantity - ? WHERE sku = ? AND quantity >= ?",
event.quantity(), event.sku(), event.quantity());
if (updated == 0) {
throw new OutOfStockException(event.sku(), event.quantity());
}
log.info("Reserved {} x {} for order {}", event.quantity(), event.sku(), event.orderId());
}
}
package com.javabytechie.shop.order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class LoyaltyPointsListener {
private static final Logger log = LoggerFactory.getLogger(LoyaltyPointsListener.class);
private final JdbcTemplate jdbcTemplate;
public LoyaltyPointsListener(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// Runs only after the order is committed; writes in a new transaction of its own
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void awardPoints(OrderPlacedEvent event) {
int points = event.total().intValue() / 10;
jdbcTemplate.update("INSERT INTO loyalty_points (customer_email, order_id, points) VALUES (?, ?, ?)",
event.customerEmail(), event.orderId(), points);
log.info("Awarded {} points to {} for order {}", points, event.customerEmail(), event.orderId());
}
}
package com.javabytechie.shop.order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
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 OrderNotificationListener {
private static final Logger log = LoggerFactory.getLogger(OrderNotificationListener.class);
// After commit, on a separate thread: the customer doesn't wait for the email
@Async
@TransactionalEventListener
public void sendConfirmation(OrderPlacedEvent event) {
log.info("Emailing order {} confirmation to {}", event.orderId(), event.customerEmail());
}
// Only when the order transaction rolled back. @Order(0) registers it before
// InventoryListener (@Order(1)) can throw and stop the remaining listeners.
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
@Order(0)
public void logFailedOrder(OrderPlacedEvent event) {
log.warn("Order {} for {} was rolled back", event.orderId(), event.customerEmail());
}
}
package com.javabytechie.shop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class StartupReport {
private static final Logger log = LoggerFactory.getLogger(StartupReport.class);
private final JdbcTemplate jdbcTemplate;
public StartupReport(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// Spring Boot publishes ApplicationReadyEvent once the app can serve requests
@EventListener(ApplicationReadyEvent.class)
public void reportStock() {
Integer skus = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stock", Integer.class);
log.info("Shop is ready with {} SKUs in stock", skus);
}
}
7.3 Controller, Application and Database
A shortage throws OutOfStockException, which the controller turns into 409 Conflict:
package com.javabytechie.shop.order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse placeOrder(@RequestBody PlaceOrderRequest request) {
return orderService.placeOrder(request);
}
@ExceptionHandler(OutOfStockException.class)
public ProblemDetail outOfStock(OutOfStockException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}
package com.javabytechie.shop.order;
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String sku, int quantity) {
super("Not enough stock for " + quantity + " x " + sku);
}
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
CREATE TABLE stock (
sku VARCHAR(20) PRIMARY KEY,
quantity INT NOT NULL
);
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_email VARCHAR(100) NOT NULL,
sku VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
total DECIMAL(10, 2) NOT NULL
);
CREATE TABLE loyalty_points (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_email VARCHAR(100) NOT NULL,
order_id BIGINT NOT NULL,
points INT NOT NULL
);
INSERT INTO stock (sku, quantity) VALUES ('KB-100', 5), ('MS-100', 0);
spring.application.name=shop
spring.datasource.url=jdbc:h2:mem:shop
spring.mvc.problemdetails.enabled=true
7.4 Run It
Start the app with ./mvnw spring-boot:run, then place one order that succeeds and one for a product with no stock:
curl -i -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerEmail":"asha@example.com","sku":"KB-100","quantity":2}'
HTTP/1.1 201
{"orderId":1,"sku":"KB-100","quantity":2,"total":178.00}
curl -i -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerEmail":"ravi@example.com","sku":"MS-100","quantity":1}'
HTTP/1.1 409
{"detail":"Not enough stock for 1 x MS-100","instance":"/api/orders","status":409,"title":"Conflict"}
[ main] c.javabytechie.shop.StartupReport : Shop is ready with 2 SKUs in stock
[nio-8080-exec-1] c.javabytechie.shop.order.OrderService : Saved order 1, publishing OrderPlacedEvent
[nio-8080-exec-1] c.j.shop.order.InventoryListener : Reserved 2 x KB-100 for order 1
[nio-8080-exec-1] c.javabytechie.shop.order.OrderService : Listeners for order 1 have run
[nio-8080-exec-1] c.j.shop.order.LoyaltyPointsListener : Awarded 17 points to asha@example.com for order 1
[ task-1] c.j.s.order.OrderNotificationListener : Emailing order 1 confirmation to asha@example.com
[nio-8080-exec-2] c.javabytechie.shop.order.OrderService : Saved order 2, publishing OrderPlacedEvent
[nio-8080-exec-2] c.j.s.order.OrderNotificationListener : Order 2 for ravi@example.com was rolled back
The thread names tell the story. The stock reservation runs on the request thread (exec-1) between "publishing" and "have run". The loyalty points follow after the commit on the same thread, and the email runs on task-1. For the failed order, the stock listener's exception rolled back the insert, and only the rollback listener reacted.
8. Configuration Options
| Setting | Default | What it does |
|---|---|---|
spring.task.execution.pool.core-size | 8 | Threads in the pool used by @Async listeners |
spring.task.execution.pool.queue-capacity | Unbounded | How many async tasks can wait for a thread |
spring.task.execution.thread-name-prefix | task- | Thread names in logs (task-1 above) |
spring.threads.virtual.enabled | false | Runs async listeners on virtual threads (Java 21+) |
@TransactionalEventListener(fallbackExecution = true) | false | Runs the listener immediately when no transaction is active, instead of skipping it |
@EventListener(condition = "...") | Always | SpEL expression that must be true for the listener to run |
9. Common Pitfalls and How to Fix Them
9.1 The AFTER_ROLLBACK Listener Never Runs
Transactional listeners register their callback when the event is published, in listener order. In the first version of the example, the rollback listener had no @Order, so it came after the stock listener. When the stock listener threw, the remaining listeners were never reached, and the rollback was never logged. How to fix it: give rollback and completion listeners a higher priority, such as @Order(0), than any synchronous listener that may throw, as the example does.
9.2 Startup Fails with @Transactional on a Transactional Listener
A plain @Transactional on a @TransactionalEventListener method stops the application from starting:
java.lang.IllegalStateException: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.javabytechie.shop.order.LoyaltyPointsListener.awardPoints(com.javabytechie.shop.order.OrderPlacedEvent)
How to fix it: use @Transactional(propagation = Propagation.REQUIRES_NEW), or NOT_SUPPORTED when the listener doesn't need a transaction.
9.3 Transactional Listeners Silently Skipped
If the event is published outside a transaction, for example from a method without @Transactional, every @TransactionalEventListener is skipped without any error. In a test that published OrderPlacedEvent directly, no loyalty points were written. How to fix it: publish from a transactional service method, or set fallbackExecution = true on listeners that should also run without a transaction.
9.4 Exceptions in After-Commit Listeners Are Only Logged
When the loyalty listener was made to throw, placeOrder still returned normally, the order stayed committed, and Spring only logged the failure:
ERROR o.s.t.s.TransactionSynchronizationUtils : TransactionSynchronization.afterCompletion threw exception
java.lang.IllegalStateException: loyalty service down
The transaction is already committed, so there is nothing to roll back. How to fix it: treat after-commit work as "best effort". Catch and log inside the listener, and add retries, or a durable mechanism (see 9.6), where losing the work is not acceptable.
9.5 @Async Without @EnableAsync
Without @EnableAsync, the @Async annotation is ignored and the listener quietly runs on the publisher's thread. Check the thread name in your logs, and add @EnableAsync once, on the application class or a configuration class.
9.6 Expecting Events to Survive a Crash
Application events live only in memory. If the process stops after the commit but before an async listener finishes, that work is lost. How to fix it: for reactions that must happen, store the event in the same transaction (the "outbox" pattern) and process it reliably. Spring Modulith's event publication registry does this for application events. For other services, use a message broker.
10. Testing Events with @RecordApplicationEvents
@RecordApplicationEvents makes Spring record every event published during a test, and the injected ApplicationEvents lets you check them. The test checks the event and the state before and after each call:
package com.javabytechie.shop.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
@SpringBootTest
@RecordApplicationEvents
class OrderEventsTest {
@Autowired
private OrderService orderService;
@Autowired
private ApplicationEvents events;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void resetData() {
jdbcTemplate.update("DELETE FROM loyalty_points");
jdbcTemplate.update("DELETE FROM orders");
jdbcTemplate.update("UPDATE stock SET quantity = 5 WHERE sku = 'KB-100'");
}
@Test
void placingAnOrderPublishesTheEventAndRunsListeners() {
// Before: 5 in stock, no loyalty points
assertThat(stockOf("KB-100")).isEqualTo(5);
assertThat(count("loyalty_points")).isZero();
OrderResponse order = orderService.placeOrder(new PlaceOrderRequest("asha@example.com", "KB-100", 2));
// The event was published once, with the order's data
assertThat(events.stream(OrderPlacedEvent.class))
.singleElement()
.satisfies(event -> {
assertThat(event.orderId()).isEqualTo(order.orderId());
assertThat(event.quantity()).isEqualTo(2);
});
// After: stock reserved in the same transaction, points awarded after commit
assertThat(stockOf("KB-100")).isEqualTo(3);
assertThat(count("loyalty_points")).isEqualTo(1);
}
@Test
void failingListenerRollsBackTheWholeOrder() {
// Before: no orders
assertThat(count("orders")).isZero();
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest("ravi@example.com", "KB-100", 9)))
.isInstanceOf(OutOfStockException.class);
// After: the order insert was rolled back and no points were awarded
assertThat(count("orders")).isZero();
assertThat(count("loyalty_points")).isZero();
assertThat(stockOf("KB-100")).isEqualTo(5);
}
private int stockOf(String sku) {
return jdbcTemplate.queryForObject("SELECT quantity FROM stock WHERE sku = ?", Integer.class, sku);
}
private int count(String table) {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class);
}
}
- The test isn't
@Transactional, so the order really commits and the after-commit loyalty listener really runs. That's why@BeforeEachresets the tables. - The second test shows a synchronous listener's exception rolling back the order insert, and leaving stock and points untouched.
- To test a listener on its own, call its method directly with a hand-made event, as a plain unit test.
The Spring Boot testing tutorial covers @SpringBootTest in more depth.
11. When to Use Events and When to Avoid Them
| Good fit for application events | Better as a direct call or a broker |
|---|---|
| Side effects of a business action: emails, points, audit entries, cache updates | A step whose result the caller needs, such as calculating a price (direct call) |
| Several independent reactions to the same fact | A single, essential step that must always run in order (direct call) |
| Decoupling modules inside one application | Communication with other services (Kafka, RabbitMQ) |
| Work that should wait for the commit | Work that must survive a crash without an outbox (broker or outbox) |
12. Best Practices
- Model events as immutable records named in the past tense, carrying ids and the data listeners need.
- Publish from transactional service methods, so transactional listeners have a transaction to attach to.
- Use @TransactionalEventListener for side effects such as emails and notifications, so nothing is sent for data that rolls back.
- Give after-commit writers their own transaction with
REQUIRES_NEW. - Keep synchronous listeners for work that must succeed or fail together with the publisher, such as reserving stock.
- Order listeners explicitly with
@Order, putting rollback and completion listeners before any listener that may throw. - Make slow listeners asynchronous with
@Async, and size the task executor for the load. - Handle failures inside after-commit and async listeners, because their exceptions never reach the publisher.
- Use an outbox or a broker for must-not-lose work, since in-memory events vanish with the process.
- Test that events are published with
@RecordApplicationEvents, and test listeners separately as plain classes.
13. Summary
Spring Boot application events let a service publish a fact, such as an OrderPlacedEvent record, through ApplicationEventPublisher, while independent listeners react to it. Plain @EventListener methods run immediately on the publisher's thread and inside its transaction. @TransactionalEventListener waits for a commit or rollback, needs REQUIRES_NEW to write data, and is skipped without a transaction. @Async moves the work to another thread. Spring Boot also publishes lifecycle events such as ApplicationReadyEvent. Order your listeners deliberately, handle failures in after-commit and async listeners, and verify publishing with @RecordApplicationEvents. The main recommendation: keep must-succeed work in synchronous listeners, move side effects to after-commit listeners, and use an outbox or broker when an event must never be lost.
Frequently Asked Questions (FAQ)
Q1. Are Spring application events synchronous or asynchronous?
Synchronous by default. publishEvent calls every matching @EventListener on the publisher's thread and returns only when they have all finished. Add @Async (with @EnableAsync) to a listener to run it on a thread pool instead.
Q2. @EventListener vs @TransactionalEventListener: what is the difference?
@EventListener runs immediately when the event is published, inside the publisher's transaction. @TransactionalEventListener waits for a transaction phase, by default AFTER_COMMIT, so it reacts only to data that was really saved. Use the first for work that must succeed together with the publisher, and the second for side effects such as emails.
Q3. Why is my @TransactionalEventListener not called?
Usually because the event was published without an active transaction, in which case transactional listeners are skipped. Publish from a @Transactional method or set fallbackExecution = true. Another cause is an earlier synchronous listener throwing before the transactional listener was registered, so check the @Order values.
Q4. @EventListener vs ApplicationListener: which should I use?
Both receive events. ApplicationListener<E> is the older interface, with one listener class per event type. @EventListener works on any bean method, accepts any object as the event, and supports condition and return values, so it is the usual choice. ApplicationListener is still needed for early Spring Boot events registered through SpringApplication.addListeners.
Q5. Are Spring application events persistent?
No. They exist only in memory, so if the application stops before a listener finishes, that work is lost. For reactions that must always happen, store the event in the same transaction (the outbox pattern), for example with Spring Modulith's event publication registry, or send it through a message broker.
Q6. Can an event listener publish another event?
Yes. Either inject ApplicationEventPublisher into the listener, or simply return an object from an @EventListener method, and Spring publishes the return value as a new event. Returning a collection publishes each element.
Q7. How do I order multiple listeners for the same event?
Add @Order to the listener methods, and lower values run first. Ordering matters most when a synchronous listener can throw, because listeners after it are never reached. Put rollback and completion listeners first so they are always registered.
Q8. How do I test that a Spring event was published?
Annotate the test class with @RecordApplicationEvents and inject ApplicationEvents. After calling your service, use events.stream(OrderPlacedEvent.class) to check that the event was published with the right data. Test listener logic separately by calling the listener method directly.