00:00

Spring Boot Application Events

Last updated:

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.

How an order event reaches its listenersInside the placeOrder transaction on the request thread, the order is inserted and OrderPlacedEvent is published. The plain @EventListener reserves stock immediately on the same thread and in the same transaction; transactional listeners only register at this point, and an exception from a listener rolls back the whole transaction. After a commit, the AFTER_COMMIT loyalty listener runs on the request thread in a new transaction, and the asynchronous AFTER_COMMIT email listener runs on thread task-1. After a rollback, only the AFTER_ROLLBACK listener runs and logs the failed order. placeOrder() transaction (request thread) INSERT order publishEvent OrderPlacedEvent @EventListener reserve stock same thread, same tx transactional listeners only register here a listener exception rolls back the whole transaction COMMIT ROLLBACK AFTER_COMMIT loyalty points (REQUIRES_NEW) request thread @Async AFTER_COMMIT confirmation email thread task-1 AFTER_ROLLBACK log failed order
Figure 1: plain listeners run inside the publisher's transaction; transactional listeners wait for the commit or rollback, and @Async ones move to another thread.

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:

pom.xml
<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:

OrderPlacedEvent.java
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:

OrderService.java (excerpt)
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:

InventoryListener.java (excerpt)
// 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:

BulkOrderListener.java
@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());
    }
}
Console (an order of 2 items, then an order of 3)
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:

PhaseRunsTypical use
AFTER_COMMIT (default)After a successful commitEmails, notifications, cache updates, calls to other services
AFTER_ROLLBACKAfter a rollbackLogging or alerting on failures
AFTER_COMPLETIONAfter commit or rollbackClean-up that must always happen
BEFORE_COMMITJust before the commitLast-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):

LoyaltyPointsListener.java (excerpt)
// 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:

OrderNotificationListener.java (excerpt)
// 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:

#EventPublished when
1ApplicationStartingEventAt the very start, before logging and the environment are ready
2ApplicationEnvironmentPreparedEventConfiguration is loaded, but no context exists yet
3ApplicationContextInitializedEventThe context is created and initializers have run
4ApplicationPreparedEventBean definitions are loaded, but beans are not yet created
5ServletWebServerInitializedEventThe embedded web server is listening
6ContextRefreshedEventAll singleton beans are created
7ApplicationStartedEventThe context is refreshed, before command-line runners
8AvailabilityChangeEvent (CORRECT)The app is live
9ApplicationReadyEventRunners have finished and the app can serve requests
10AvailabilityChangeEvent (ACCEPTING_TRAFFIC)The app is ready for traffic
11AvailabilityChangeEvent (REFUSING_TRAFFIC), then ContextClosedEventOn shutdown

ApplicationReadyEvent is the usual choice for "do this once the app is up", such as warming a cache or logging a summary:

StartupReport.java (excerpt)
// 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:

ListenerAnnotationRunsEffect of a failure
Reserve stock@EventListenerImmediately, same thread and transactionRolls back the whole order
Award loyalty points@TransactionalEventListener + REQUIRES_NEWAfter commit, in its own transactionThe order stays committed, and the error is logged
Email confirmation@Async + @TransactionalEventListenerAfter commit, on a pool threadThe order stays committed, and the error is logged
Log failed order@TransactionalEventListener(phase = AFTER_ROLLBACK)After rollbackNot 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);
    }
}

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());
    }
}

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());
    }
}

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:

Terminal
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"}
Console
[           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

SettingDefaultWhat it does
spring.task.execution.pool.core-size8Threads in the pool used by @Async listeners
spring.task.execution.pool.queue-capacityUnboundedHow many async tasks can wait for a thread
spring.task.execution.thread-name-prefixtask-Thread names in logs (task-1 above)
spring.threads.virtual.enabledfalseRuns async listeners on virtual threads (Java 21+)
@TransactionalEventListener(fallbackExecution = true)falseRuns the listener immediately when no transaction is active, instead of skipping it
@EventListener(condition = "...")AlwaysSpEL 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:

Console
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:

Console
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:

OrderEventsTest.java
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 @BeforeEach resets 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 eventsBetter as a direct call or a broker
Side effects of a business action: emails, points, audit entries, cache updatesA step whose result the caller needs, such as calculating a price (direct call)
Several independent reactions to the same factA single, essential step that must always run in order (direct call)
Decoupling modules inside one applicationCommunication with other services (Kafka, RabbitMQ)
Work that should wait for the commitWork 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.