Command Palette

Search for a command to run...

[Advanced Spring Boot] Spring Events: ApplicationEventPublisher, @EventListener and @TransactionalEventListener

Placing an order is one business fact. Sending the confirmation email, updating a stock projection and writing an audit row are three consequences of it, and none of them belongs in OrderService.place. Spring's event mechanism is the in-process way to separate the fact from its consequences, and it costs one interface and one annotation.

It is also the corner of Spring where the advice ages worst. Most of what is written about @EventListener predates @TransactionalEventListener, and most of what is written about @TransactionalEventListener predates the check Spring Framework 6.1 added that refuses to start an application misusing it, so this article shows what Spring Boot 4.1.1 on Java 21 actually does, with an in-memory H2 database. The app runs on port 8204 instead of the default 8080, so that is the port in the curl commands and in the nio-8204-exec-N thread names in the logs.

One publishEvent call fanning out to an email, a stock and an audit listener

The events Spring Boot itself publishes during startup — ContextRefreshedEvent, ApplicationStartedEvent, ApplicationReadyEvent — were traced in article 2 of this course, together with where the Started … in line falls and what an exception in a readiness listener costs; this article is about the events you publish, and does not repeat any of that.

The project and the event

The whole mechanism lives in spring-context, which every Spring Boot application already has. There is no starter to add, no @EnableEvents, nothing to configure. A plain web plus JPA project is enough:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,h2" -o demo.zip

./gradlew dependencies --configuration runtimeClasspath shows where the event machinery comes from — spring-boot itself pulls it in:

Text
|    +--- org.springframework.boot:spring-boot:4.1.1
|    |    +--- org.springframework:spring-core:7.0.9
|    |    \--- org.springframework:spring-context:7.0.9
|    |         +--- org.springframework:spring-aop:7.0.9
...
|    |    |    +--- org.springframework.boot:spring-boot-transaction:4.1.1
|    |    |    |    +--- org.springframework.boot:spring-boot-persistence:4.1.1
|    |    |    |    |    \--- org.springframework:spring-tx:7.0.9

spring-context gives you ApplicationEventPublisher and @EventListener; spring-tx, which arrives with spring-boot-starter-data-jpa, gives you @TransactionalEventListener. Both are already on the classpath of the project above.

Two settings matter for the measurements. The log pattern prints the thread, which is half the article, and open-in-view is turned off so that transaction boundaries are exactly where the annotations put them rather than where a servlet filter puts them:

src/main/resources/application.properties
server.port=8204
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
logging.pattern.console=%d{HH:mm:ss.SSS} %5p [%15.15t] %-40.40logger{39} : %m%n
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

An event in modern Spring is a plain value. It does not extend ApplicationEvent, it does not implement anything, and a record is exactly the right shape for it — immutable, with a generated toString that logs well. A marker interface is worth adding only because one listener later wants to see every event in the family:

src/main/java/com/example/demo/order/ShopEvent.java
package com.example.demo.order;
 
/** A supertype every shop event carries, so one listener can see them all. */
public interface ShopEvent {
}
src/main/java/com/example/demo/order/OrderPlaced.java
package com.example.demo.order;
 
import java.math.BigDecimal;
 
/** An event is a value: a record with no base class and no Spring type in it. */
public record OrderPlaced(Long orderId, String sku, int quantity, BigDecimal total) implements ShopEvent {
}

Every listener in this article prints the same three facts about where it is running, so a single helper carries them:

src/main/java/com/example/demo/support/Tx.java
package com.example.demo.support;
 
import org.springframework.transaction.support.TransactionSynchronizationManager;
 
/** One place to print where a listener is actually running. */
public final class Tx {
 
    private Tx() {
    }
 
    public static String where() {
        return "thread=" + Thread.currentThread().getName()
                + " actualTx=" + TransactionSynchronizationManager.isActualTransactionActive()
                + " syncActive=" + TransactionSynchronizationManager.isSynchronizationActive()
                + " txName=" + TransactionSynchronizationManager.getCurrentTransactionName();
    }
}

The publisher is ApplicationEventPublisher, injected by constructor like any other bean. ApplicationContext implements it, so what you receive is the context itself — but depending on the narrow interface keeps the service testable with a mock and says what the class actually needs:

src/main/java/com/example/demo/order/OrderService.java
package com.example.demo.order;
 
import com.example.demo.support.Tx;
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
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 final OrderRepository orders;
    private final ApplicationEventPublisher events;
 
    public OrderService(OrderRepository orders, ApplicationEventPublisher events) {
        this.orders = orders;
        this.events = events;
    }
 
    @Transactional
    public Long place(String sku, int quantity, BigDecimal total) {
        Order order = orders.save(new Order(sku, quantity, total));
        log.info("place() about to publish   {}", Tx.where());
        events.publishEvent(new OrderPlaced(order.getId(), sku, quantity, total));
        log.info("place() publishEvent returned, method is about to return");
        return order.getId();
    }
 
    @Transactional
    public Long placeAndFail(String sku, int quantity, BigDecimal total) {
        Order order = orders.save(new Order(sku, quantity, total));
        events.publishEvent(new OrderPlaced(order.getId(), sku, quantity, total));
        log.info("placeAndFail() published, now throwing");
        throw new PaymentDeclined("card declined for " + sku);
    }
}

Order and AuditRow are ordinary JPA entities with an id and a couple of columns, and OrderController exposes POST /api/orders plus a GET /api/counts that returns the row counts of both tables. Transactions themselves — what @Transactional does, when it rolls back, why the injected bean is a CGLIB proxy — are article 30 of the Basics course and are assumed here.

What publishEvent actually does

The first listener is the confirmation email. It is a method on an ordinary bean:

src/main/java/com/example/demo/listener/ConfirmationEmailListener.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
public class ConfirmationEmailListener {
 
    private static final Logger log = LoggerFactory.getLogger(ConfirmationEmailListener.class);
 
    @EventListener
    @Order(10)
    public void sendConfirmation(OrderPlaced event) {
        log.info("email   order={} {}", event.orderId(), Tx.where());
    }
}

One request, with four more listeners registered on the same event:

Bash
curl -s -X POST "http://localhost:8204/api/orders?sku=SKU-1&quantity=2&total=249.90"

Here is what the application logged. The timestamp is dropped and the logger column is shortened to the class name; nothing else is edited:

Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] PayloadPeekListener       : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=1, sku=SKU-1, quantity=2, total=249.90]
 INFO [nio-8204-exec-1] ConfirmationEmailListener : email   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] StockProjectionListener   : stock   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] StockProjectionListener   : large   order=1 total=249.90 (condition matched)
 INFO [nio-8204-exec-1] ChainingListener          : confirm order=1 returning an OrderConfirmed
 INFO [nio-8204-exec-1] GenericEventListeners     : supertype listener saw OrderConfirmed
 INFO [nio-8204-exec-1] ChainingListener          : onConfirmed OrderConfirmed[orderId=1, reference=REF-1] thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] GenericEventListeners     : supertype listener saw OrderPlaced
 INFO [nio-8204-exec-1] OrderService              : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
 INFO [nio-8204-exec-1] OrderController           : controller after service    thread=http-nio-8204-exec-1 actualTx=false syncActive=false txName=null

Three facts are settled by those twelve lines, and they are the ones people most often get wrong.

Publishing is a method call, not a message send. publishEvent did not return until the last listener had finished. There is no queue, no broker, no buffer: the multicaster loops over the matching listeners and invokes them.

Every listener ran on http-nio-8204-exec-1 — the Tomcat worker thread that was handling the request, the same one place() itself was on.

Every listener ran inside the publisher's transaction. TransactionSynchronizationManager.isActualTransactionActive() is true and getCurrentTransactionName() is com.example.demo.order.OrderService.place inside each one, and Initiating transaction commit comes after all of them. A listener that writes to the database here writes into the caller's transaction, and if the caller rolls back, so does the listener's work.

publishEvent runs the listeners before it returns, beside the same dispatch with @Async on a listener

The second line of the log is the wrapper. ApplicationEventMulticaster deals in ApplicationEvent, so a payload that is not one gets boxed: AbstractApplicationContext.publishEvent wraps it in a PayloadApplicationEvent whose source is the context. You normally never see it, but a listener can ask for it:

src/main/java/com/example/demo/listener/PayloadPeekListener.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
public class PayloadPeekListener {
 
    private static final Logger log = LoggerFactory.getLogger(PayloadPeekListener.class);
 
    @EventListener
    @Order(5)
    public void peek(PayloadApplicationEvent<OrderPlaced> event) {
        log.info("wrapper class={} source={} payload={}",
                event.getClass().getSimpleName(),
                event.getSource().getClass().getName(),
                event.getPayload());
    }
}

Ask for the payload type, not the wrapper. The wrapper form exists for the rare case where you need getSource() or getTimestamp(), and — as the generics section below measures — its type matching has a sharp edge.

Ordering, conditions and listeners that return events

Several listeners on one event run in a defined order, and @Order defines it. Unlike on a BeanPostProcessor, where article 2 measured @Order being ignored entirely, here the annotation is what the multicaster sorts on: AbstractApplicationEventMulticaster sorts the retrieved listeners with AnnotationAwareOrderComparator, which reads @Order. The trace above ran 5, 10, 20, 30, 40 and then the unannotated listener last.

condition takes a SpEL expression evaluated against the event before the method is invoked. #event — or event, or #root.event — is the payload; #root.args is the argument array, and the first argument is also args[0], #a0 or #p0. The method runs when the expression yields boolean true or one of the strings "true", "on", "yes" and "1":

src/main/java/com/example/demo/listener/StockProjectionListener.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
public class StockProjectionListener {
 
    private static final Logger log = LoggerFactory.getLogger(StockProjectionListener.class);
 
    @EventListener
    @Order(20)
    public void reserveStock(OrderPlaced event) {
        log.info("stock   order={} {}", event.orderId(), Tx.where());
    }
 
    @EventListener(condition = "#event.total > 100")
    @Order(30)
    public void flagLargeOrder(OrderPlaced event) {
        log.info("large   order={} total={} (condition matched)", event.orderId(), event.total());
    }
}

A second order, this time for 40.00:

Text
 INFO [nio-8204-exec-3] PayloadPeekListener       : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=2, sku=SKU-2, quantity=1, total=40.00]
 INFO [nio-8204-exec-3] ConfirmationEmailListener : email   order=2 thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-3] StockProjectionListener   : stock   order=2 thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-3] ChainingListener          : confirm order=2 returning an OrderConfirmed
 INFO [nio-8204-exec-3] GenericEventListeners     : supertype listener saw OrderConfirmed
 INFO [nio-8204-exec-3] ChainingListener          : onConfirmed OrderConfirmed[orderId=2, reference=REF-2] thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-3] GenericEventListeners     : supertype listener saw OrderPlaced
 INFO [nio-8204-exec-3] OrderService              : place() publishEvent returned, method is about to return

The large line is gone and nothing else changed: the @Order(30) listener was skipped and the rest of the chain ran normally. A condition is evaluated on the publishing thread, inside the publisher's transaction, so an expression that throws throws at the publisher exactly as a listener body would — keep it to a field comparison and put anything cleverer inside the method.

A listener may also return a value, and Spring publishes it as a further event:

src/main/java/com/example/demo/listener/ChainingListener.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
public class ChainingListener {
 
    private static final Logger log = LoggerFactory.getLogger(ChainingListener.class);
 
    /** A non-void listener: Spring publishes the returned value as a further event. */
    @EventListener
    @Order(40)
    public OrderConfirmed confirm(OrderPlaced event) {
        log.info("confirm order={} returning an OrderConfirmed", event.orderId());
        return new OrderConfirmed(event.orderId(), "REF-" + event.orderId());
    }
 
    @EventListener
    public void onConfirmed(OrderConfirmed event) {
        log.info("onConfirmed {} {}", event, Tx.where());
    }
}

It works, and the log shows exactly how. Look at the ordering again: confirm returns, the OrderConfirmed listeners run immediately, and only then does the last OrderPlaced listener get its turn. The nested dispatch happens inside the outer one. ApplicationListenerMethodAdapter.handleResult also unpacks an array or a Collection into one event each, and a CompletionStage into an event published when the stage completes.

That is clever, and clever is the problem. The publication order of an event now depends on which listener returned it, a stack trace from the second event runs through the first listener, and there is no declaration anywhere saying that placing an order also confirms it. Use a returned event when the second event is genuinely derived from the first and the pair is read as one unit; publish explicitly — inject ApplicationEventPublisher into the listener — the moment anyone has to ask where the second event came from.

Listening for a supertype and for a generic type

A listener declared on a supertype receives every subtype. GenericEventListeners.onAnyShopEvent(ShopEvent) saw both OrderPlaced and OrderConfirmed in the traces above, with no registration beyond the parameter type. That is the cheap, reliable half of listener type matching.

Generics are the other half. Two events, identical except that one helps the container:

src/main/java/com/example/demo/generic/OrderEvent.java
package com.example.demo.generic;
 
/** A generic event with no help for the container: T is erased at runtime. */
public record OrderEvent<T>(String kind, T subject) {
}
src/main/java/com/example/demo/generic/TypedOrderEvent.java
package com.example.demo.generic;
 
import org.springframework.core.ResolvableType;
import org.springframework.core.ResolvableTypeProvider;
 
/** The same event, telling the container what T actually is. */
public record TypedOrderEvent<T>(String kind, T subject) implements ResolvableTypeProvider {
 
    @Override
    public ResolvableType getResolvableType() {
        return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(subject));
    }
}

Five listeners, covering every way of naming the type:

src/main/java/com/example/demo/generic/GenericEventListeners.java
package com.example.demo.generic;
 
import com.example.demo.order.ShopEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
 
@Component
public class GenericEventListeners {
 
    private static final Logger log = LoggerFactory.getLogger(GenericEventListeners.class);
 
    @EventListener
    public void onAnyShopEvent(ShopEvent event) {
        log.info("supertype listener saw {}", event.getClass().getSimpleName());
    }
 
    @EventListener
    public void onAnyOrderEvent(OrderEvent<?> event) {
        log.info("OrderEvent<?>              got {}", event);
    }
 
    @EventListener
    public void onRawOrderEvent(OrderEvent event) {
        log.info("OrderEvent (raw)           got {}", event);
    }
 
    @EventListener
    public void onProductEvent(OrderEvent<Product> event) {
        log.info("OrderEvent<Product>        got {}", event);
    }
 
    @EventListener
    public void onTypedProductEvent(TypedOrderEvent<Product> event) {
        log.info("TypedOrderEvent<Product>   got {}", event);
    }
}

Publishing one of each — a Product subject and a Customer subject, for both event types — produced this. There is also an onCustomerEvent and an onTypedCustomerEvent, identical except for the type argument:

Text
 INFO [nio-8204-exec-2] GenericController         : --- publishing OrderEvent<Product> ---
 INFO [nio-8204-exec-2] PayloadPeekListener       : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderEvent[kind=added, subject=Product[sku=SKU-1]]
 INFO [nio-8204-exec-2] GenericEventListeners     : OrderEvent<?>              got OrderEvent[kind=added, subject=Product[sku=SKU-1]]
 INFO [nio-8204-exec-2] GenericEventListeners     : OrderEvent (raw)           got OrderEvent[kind=added, subject=Product[sku=SKU-1]]
 INFO [nio-8204-exec-2] GenericController         : --- publishing OrderEvent<Customer> ---
 INFO [nio-8204-exec-2] PayloadPeekListener       : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
 INFO [nio-8204-exec-2] GenericEventListeners     : OrderEvent<?>              got OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
 INFO [nio-8204-exec-2] GenericEventListeners     : OrderEvent (raw)           got OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
 INFO [nio-8204-exec-2] GenericController         : --- publishing TypedOrderEvent<Product> ---
 INFO [nio-8204-exec-2] GenericEventListeners     : TypedOrderEvent<Product>   got TypedOrderEvent[kind=added, subject=Product[sku=SKU-1]]
 INFO [nio-8204-exec-2] GenericController         : --- publishing TypedOrderEvent<Customer> ---
 INFO [nio-8204-exec-2] GenericEventListeners     : TypedOrderEvent<Customer>  got TypedOrderEvent[kind=registered, subject=Customer[email=a@b.c]]

Read the first four lines twice. The folklore says erasure makes a OrderEvent<Product> listener receive OrderEvent<Customer> too. What actually happened is worse: onProductEvent was never called at all, for either event. A listener that names a concrete type argument on an ordinary generic event silently receives nothing, with no warning, no startup failure and no log line.

The reason is three lines of ApplicationListenerMethodAdapter:

ApplicationListenerMethodAdapter.java (Spring Framework 7.0.9)
    public boolean supportsEventType(ResolvableType eventType) {
        for (ResolvableType declaredEventType : this.declaredEventTypes) {
            if (eventType.hasUnresolvableGenerics() ?
                    declaredEventType.toClass().isAssignableFrom(eventType.toClass()) :
                    declaredEventType.isAssignableFrom(eventType)) {
                return true;
            }
            if (PayloadApplicationEvent.class.isAssignableFrom(eventType.toClass())) {
                ResolvableType payloadType = eventType.as(PayloadApplicationEvent.class).getGeneric();
                if (declaredEventType.isAssignableFrom(payloadType)) {
                    return true;
                }
                if (payloadType.resolve() == null) {
                    // Always accept such event when the type is erased
                    return true;
                }
            }
        }
        return false;
    }

ResolvableType.forInstance(new OrderEvent<>("added", product)) can only see the raw class, so the resolved payload type is OrderEvent<T> with T unresolved. OrderEvent<Product> is not assignable from that, and the payload type resolves to OrderEvent.class rather than null, so the last escape hatch does not fire either. OrderEvent<?> and the raw OrderEvent both match, because a wildcard and a raw type accept an unresolved argument.

The same lines explain the wrapper listener firing on the wrong payload. PayloadPeekListener is declared as PayloadApplicationEvent<OrderPlaced>, yet it logged both OrderEvent publications — the event type has unresolvable generics, so the first branch degrades to a raw class comparison and PayloadApplicationEvent matches PayloadApplicationEvent. It does not fire for TypedOrderEvent, whose generics are resolvable. If you declare a listener on the wrapper, expect to get payloads you did not ask for.

ResolvableTypeProvider removes the whole problem for four lines of code, and TypedOrderEvent<Product> and TypedOrderEvent<Customer> each received only their own. The alternative is to not make the event generic: ProductAdded and CustomerRegistered as two records cost less than one type parameter and route perfectly.

What a listener that throws does to the publisher

Since dispatch is a method call inside the publisher's transaction, a listener that throws throws at the publisher. Here is the email listener failing, with a second listener behind it:

src/main/java/com/example/demo/listener/BrokenEmailListener.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
 
@Component
public class BrokenEmailListener {
 
    private static final Logger log = LoggerFactory.getLogger(BrokenEmailListener.class);
 
    @EventListener
    @Order(10)
    public void sendConfirmation(OrderPlaced event) {
        log.info("email   order={} {}", event.orderId(), Tx.where());
        throw new IllegalStateException("SMTP server refused the message");
    }
 
    @EventListener
    @Order(20)
    public void reserveStock(OrderPlaced event) {
        log.info("stock   order={} this line never appears", event.orderId());
    }
}
Bash
curl -s -i -X POST "http://localhost:8204/api/orders?sku=SKU-1&quantity=2&total=249.90"
curl -s "http://localhost:8204/api/counts"
Text
HTTP/1.1 500
{"timestamp":"2026-09-18T03:09:15.670Z","status":500,"error":"Internal Server Error","path":"/api/orders"}
{"orders":0,"audit":0}

The order is gone. Not "gone from the projection" — orders is 0, the row was rolled back, because the exception travelled out of publishEvent, out of place(), and through the transaction interceptor, which applied its ordinary rollback rules to it. The log and the stack say so, and the stack is the clearest single piece of evidence in this article:

Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] BrokenEmailListener       : email   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction rollback
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Rolling back JPA transaction on EntityManager [SessionImpl(467824594<open>)]
ERROR [nio-8204-exec-1] [dispatcherServlet]       : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: SMTP server refused the message] with root cause
java.lang.IllegalStateException: SMTP server refused the message
	at com.example.demo.listener.BrokenEmailListener.sendConfirmation(BrokenEmailListener.java:22)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.doInvoke(ApplicationListenerMethodAdapter.java:392)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.processEvent(ApplicationListenerMethodAdapter.java:270)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:180)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:151)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:448)
	at com.example.demo.order.OrderService.place(OrderService.java:28)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:371)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:719)
	at com.example.demo.order.OrderService$$SpringCGLIB$$0.place(<generated>)
	at com.example.demo.order.OrderController.place(OrderController.java:40)

One stack, from the controller through the CGLIB proxy, through place, through publishEvent, into the listener. The @Order(20) listener never ran — multicastEvent stops at the first failure. Notice also that the caller has no way to tell this apart from a failure inside place() itself, which is the honest summary of the default behaviour: a synchronous listener is part of the caller's use case.

Moving the listener off the caller's thread changes all of it. @Async is set up exactly as in article 40 of the Basics course — @EnableAsync on a configuration class, Boot's applicationTaskExecutor with 8 core threads named task-N — and combines with @EventListener on the same method:

src/main/java/com/example/demo/listener/AsyncBrokenEmailListener.java
@Component
public class AsyncBrokenEmailListener {
 
    @Async
    @EventListener
    @Order(10)
    public void sendConfirmation(OrderPlaced event) {
        log.info("email   order={} {}", event.orderId(), Tx.where());
        throw new IllegalStateException("SMTP server refused the message");
    }
Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] AsyncBrokenEmailListener  : stock   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [         task-1] AsyncBrokenEmailListener  : email   order=1 thread=task-1 actualTx=false syncActive=false txName=null
 INFO [nio-8204-exec-1] OrderService              : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
ERROR [         task-1] SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.demo.listener.AsyncBrokenEmailListener.sendConfirmation(com.example.demo.order.OrderPlaced)
java.lang.IllegalStateException: SMTP server refused the message
	at com.example.demo.listener.AsyncBrokenEmailListener.sendConfirmation(AsyncBrokenEmailListener.java:24)
	at org.springframework.aop.interceptor.AsyncExecutionInterceptor.lambda$invoke$0(AsyncExecutionInterceptor.java:112)

Four differences, all measured in that one run. The listener body ran on task-1. actualTx=false: the transaction is thread-bound and does not follow. The @Order(20) listener ran first, because the @Order(10) one only handed work to the executor and returned. And the exception reached SimpleAsyncUncaughtExceptionHandler and an ERROR log — the request returned 200 with {"orderId":1} and the order committed. The work is lost and nobody is told; that is the whole trade.

@TransactionalEventListener and the four phases

Running a listener inside the publisher's transaction is wrong for most secondary work. An email sent from inside the transaction is sent even if the transaction then rolls back; a projection updated there sees rows nobody else can see yet. @TransactionalEventListener moves the listener body to a chosen point of the transaction's life:

PhaseRuns@TransactionalEventListener
BEFORE_COMMITjust before the commit is issued, still inside the transactionphase = TransactionPhase.BEFORE_COMMIT
AFTER_COMMITafter the commit succeededthe default@TransactionalEventListener with no attributes
AFTER_ROLLBACKafter the transaction rolled backphase = TransactionPhase.AFTER_ROLLBACK
AFTER_COMPLETIONafter the transaction ended, either wayphase = TransactionPhase.AFTER_COMPLETION

One bean with one listener per phase:

src/main/java/com/example/demo/listener/PhaseListeners.java
package com.example.demo.listener;
 
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
 
@Component
public class PhaseListeners {
 
    private static final Logger log = LoggerFactory.getLogger(PhaseListeners.class);
 
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void beforeCommit(OrderPlaced event) {
        log.info("BEFORE_COMMIT      order={} {}", event.orderId(), Tx.where());
    }
 
    @TransactionalEventListener
    public void afterCommit(OrderPlaced event) {
        log.info("AFTER_COMMIT       order={} {}", event.orderId(), Tx.where());
    }
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void afterRollback(OrderPlaced event) {
        log.info("AFTER_ROLLBACK     order={} {}", event.orderId(), Tx.where());
    }
 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void afterCompletion(OrderPlaced event) {
        log.info("AFTER_COMPLETION   order={} {}", event.orderId(), Tx.where());
    }
}

The order that commits, with org.springframework.transaction.event at DEBUG:

Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] TransactionalApplicationListenerMethodAdapter : Registered transaction synchronization for org.springframework.context.PayloadApplicationEvent[source=…]
 INFO [nio-8204-exec-1] OrderService              : place() publishEvent returned, method is about to return
 INFO [nio-8204-exec-1] PhaseListeners            : BEFORE_COMMIT      order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Committing JPA transaction on EntityManager [SessionImpl(1162799326<open>)]
 INFO [nio-8204-exec-1] PhaseListeners            : AFTER_COMMIT       order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] PhaseListeners            : AFTER_COMPLETION   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] OrderController           : controller after service    thread=http-nio-8204-exec-1 actualTx=false syncActive=false txName=null

And the same request with outcome=fail, which makes placeAndFail throw after publishing:

Text
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Creating new transaction with name [com.example.demo.order.OrderService.placeAndFail]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
 INFO [nio-8204-exec-2] OrderService              : placeAndFail() published, now throwing
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Initiating transaction rollback
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Rolling back JPA transaction on EntityManager [SessionImpl(1901539364<open>)]
 INFO [nio-8204-exec-2] PhaseListeners            : AFTER_ROLLBACK     order=2 thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.placeAndFail
 INFO [nio-8204-exec-2] PhaseListeners            : AFTER_COMPLETION   order=2 thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.placeAndFail

The phases that fired on the commit path and on the rollback path of one transaction

Five things in those two traces are worth keeping.

Nothing runs at publish time. publishEvent returned after only a DEBUG line saying a synchronization was registered. The listener body is deferred; the event object is held until the transaction reaches the requested point.

The phases select correctly. BEFORE_COMMIT and AFTER_COMMIT fired on the commit path and not on the rollback path; AFTER_ROLLBACK the reverse; AFTER_COMPLETION on both.

Everything still runs on the publishing thread. http-nio-8204-exec-1 and http-nio-8204-exec-2@TransactionalEventListener defers when, never where.

actualTx=true in an AFTER_COMMIT listener is a lie you must not believe. The transaction has committed; isActualTransactionActive() has simply not been reset yet, while isSynchronizationActive() has already flipped to false. Code that branches on "am I in a transaction?" gets the wrong answer here. The next section measures what that costs.

AFTER_COMMIT is not implemented as afterCommit(). All three after-phases share one callback, which is why the relative order of an AFTER_COMMIT and an AFTER_COMPLETION listener is not defined by the phase at all. Three runs of the same jar bore that out: two printed AFTER_COMMIT first and one printed AFTER_COMPLETION first, with no change to the code. Never depend on the order between listeners of different after-phases:

TransactionalApplicationListenerSynchronization.java (Spring Framework 7.0.9)
        @Override
        public void beforeCommit(boolean readOnly) {
            if (getTransactionPhase() == TransactionPhase.BEFORE_COMMIT) {
                processEventWithCallbacks();
            }
        }
 
        @Override
        public void afterCompletion(int status) {
            TransactionPhase phase = getTransactionPhase();
            if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) {
                processEventWithCallbacks();
            }
            else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) {
                processEventWithCallbacks();
            }
            else if (phase == TransactionPhase.AFTER_COMPLETION) {
                processEventWithCallbacks();
            }
        }

That also settles the failure question at each end. A BEFORE_COMMIT listener that throws runs while the commit is being issued, and the run logged Initiating transaction rollback after commit exception: HTTP 500, orders=0, the order lost its commit. An AFTER_COMMIT listener that throws is already past the point of no return — the same experiment gave HTTP 200, orders=1, and this line, which is the only trace the failure leaves:

Text
ERROR [nio-8204-exec-1] TransactionSynchronizationUtils : TransactionSynchronization.afterCompletion threw exception
java.lang.IllegalStateException: SMTP server refused the message

Use BEFORE_COMMIT for work that must be part of the transaction — a last validation, a derived row written in the same unit. Use AFTER_COMMIT, the default, for everything the outside world sees.

When there is no transaction: fallbackExecution

If nothing registers a transaction synchronization, there is nothing for a @TransactionalEventListener to hang off. Publishing the same event from a method with no @Transactional produced this, at DEBUG:

Text
 INFO [nio-8204-exec-3] OrderService              : placeWithoutTransaction() about to publish   thread=http-nio-8204-exec-3 actualTx=false syncActive=false txName=null
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
 INFO [nio-8204-exec-3] PhaseListeners            : AFTER_COMMIT fallbackExecution=true order=3 thread=http-nio-8204-exec-3 actualTx=false syncActive=false txName=null

The listener is skipped silently. No exception, no warning — one DEBUG line per listener, on a logger nobody enables in production. This is the single most common reason a @TransactionalEventListener "does not work": the publisher was not transactional, often because a test called the service directly, or because the @Transactional annotation sits on a method reached by self-invocation and the proxy was bypassed.

A fifth listener with fallbackExecution = true is the one that ran:

src/main/java/com/example/demo/listener/PhaseListeners.java
    @TransactionalEventListener
    @TransactionalEventListener(fallbackExecution = true)       
    public void afterCommitWithFallback(OrderPlaced event) {
        log.info("AFTER_COMMIT fallbackExecution=true order={} {}", event.orderId(), Tx.where());
    }

With no transaction it runs immediately and inline, exactly as a plain @EventListener would; with a transaction it behaves like any other listener of its phase. It is the right switch for a listener that must also work in a unit test or from a non-transactional entry point, and the wrong one for a listener whose entire purpose is to act only after a commit. One detail from the source is worth knowing: a fallback execution in the AFTER_ROLLBACK phase logs Processing … as a fallback execution on AFTER_ROLLBACK phase at WARN, because running a rollback handler when nothing rolled back is almost certainly a mistake.

The AFTER_COMMIT write trap

This is where real applications lose data. An AFTER_COMMIT listener writes an audit row — the most natural thing in the world, and the reason the previous section's actualTx=true matters:

src/main/java/com/example/demo/audit/PlainAuditListener.java
@Component
public class PlainAuditListener {
 
    private final AuditRepository audit;
 
    public PlainAuditListener(AuditRepository audit) {
        this.audit = audit;
    }
 
    @TransactionalEventListener
    public void writeAudit(OrderPlaced event) {
        log.info("audit listener {}", Tx.where());
        AuditRow row = audit.save(new AuditRow("order " + event.orderId() + " placed"));
        log.info("audit listener saved row id={}", row.getId());
    }
}

One order placed, then GET /api/counts:

Text
 INFO [nio-8204-exec-2] OrderService              : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Initiating transaction commit
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Committing JPA transaction on EntityManager [SessionImpl(963334426<open>)]
 INFO [nio-8204-exec-2] PlainAuditListener        : audit listener thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Found thread-bound EntityManager [SessionImpl(963334426<open>)] for JPA transaction
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Participating in existing transaction
 INFO [nio-8204-exec-2] PlainAuditListener        : audit listener saved row id=null
DEBUG [nio-8204-exec-2] JpaTransactionManager     : Closing JPA EntityManager after transaction
{"audit":0,"orders":1}

Participating in existing transaction — the repository's own @Transactional found the thread-bound EntityManager left over from the transaction that has already committed, and joined it. There is no commit left to flush into, so the insert never happens: row.getId() came back null, audit_rows holds 0 rows, and the HTTP response was 200. No exception, no warning, no failed health check. An audit trail that is simply empty.

The same audit row written from an AFTER_COMMIT listener with and without REQUIRES_NEW

The obvious fix — annotate the listener @Transactional — is now refused at startup. Since Spring Framework 6.1 the factory that turns a @TransactionalEventListener method into a listener is RestrictedTransactionalEventListenerFactory, registered by @EnableTransactionManagement and therefore by Boot's transaction auto-configuration:

src/main/java/com/example/demo/audit/PlainAuditListener.java
    @TransactionalEventListener
    @Transactional
    public void writeAudit(OrderPlaced event) {
Text
ERROR SpringApplication : Application run failed
org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener annotation on bean with name 'requiredAuditListener': @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.example.demo.audit.RequiredAuditListener.writeAudit(com.example.demo.order.OrderPlaced)
	at org.springframework.context.event.EventListenerMethodProcessor.afterSingletonsInstantiated(EventListenerMethodProcessor.java:145)
Caused by: java.lang.IllegalStateException: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED
	at org.springframework.transaction.annotation.RestrictedTransactionalEventListenerFactory.createApplicationListener(RestrictedTransactionalEventListenerFactory.java:52)

The application does not start, and the message names the two propagations that are allowed. The check itself skips the BEFORE_COMMIT phase — a listener that runs inside the transaction may carry any @Transactional it likes — and applies to every other phase. REQUIRES_NEW is the one you want:

src/main/java/com/example/demo/audit/RequiresNewAuditListener.java
    @TransactionalEventListener
    @Transactional
    @Transactional(propagation = Propagation.REQUIRES_NEW)           
    public void writeAudit(OrderPlaced event) {

Same request, same listener body:

Text
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Committing JPA transaction on EntityManager [SessionImpl(1470717527<open>)]
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Suspending current transaction, creating new transaction with name [com.example.demo.audit.RequiresNewAuditListener.writeAudit]
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Opened new EntityManager [SessionImpl(335637450<open>)] for JPA transaction
 INFO [nio-8204-exec-1] RequiresNewAuditListener  : audit listener thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.audit.RequiresNewAuditListener.writeAudit
 INFO [nio-8204-exec-1] RequiresNewAuditListener  : audit listener saved row id=1
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Committing JPA transaction on EntityManager [SessionImpl(335637450<open>)]
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Closing JPA EntityManager after transaction
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Resuming suspended transaction after completion of inner transaction
{"orders":1,"audit":1}

A second EntityManager, a second connection, a real commit, id=1, and 1 row in audit_rows. Note syncActive=true and a txName of the listener's own method: this is a genuine transaction, not the ghost of the previous one.

⚠️ Any database write from an AFTER_COMMIT (or AFTER_COMPLETION) listener needs @Transactional(propagation = Propagation.REQUIRES_NEW). Without it the write is silently discarded, and the only visible symptom is a generated id that comes back null.

The cost is the second connection. A listener holding a connection from the pool while the request thread also holds one doubles the pool pressure of that endpoint, so an AFTER_COMMIT listener that writes belongs behind a bounded pool and a timeout, not on the hot path of every request. Propagation itself was Basics article 30's subject and gets a full treatment later in this course.

Making listeners asynchronous

There are two switches, and they do different things.

@Async on the listener method moves that one listener onto the executor. It composes with @TransactionalEventListener, and the phase still works:

src/main/java/com/example/demo/listener/AsyncListeners.java
@Component
public class AsyncListeners {
 
    @Async
    @EventListener
    @Order(10)
    public void sendConfirmation(OrderPlaced event) {
        log.info("email    order={} {}", event.orderId(), Tx.where());
    }
 
    @EventListener
    @Order(20)
    public void reserveStock(OrderPlaced event) {
        log.info("stock    order={} {}", event.orderId(), Tx.where());
    }
 
    @Async
    @TransactionalEventListener
    public void afterCommitAsync(OrderPlaced event) {
        log.info("AFTER_COMMIT @Async order={} {}", event.orderId(), Tx.where());
    }
}
Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] AsyncListeners            : stock    order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [         task-1] AsyncListeners            : email    order=1 thread=task-1 actualTx=false syncActive=false txName=null
 INFO [nio-8204-exec-1] OrderService              : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Committing JPA transaction on EntityManager [SessionImpl(122201492<open>)]
 INFO [         task-2] AsyncListeners            : AFTER_COMMIT @Async order=1 thread=task-2 actualTx=false syncActive=false txName=null

The synchronization was still registered on the request thread, the commit still happened there, and only the listener body moved — to task-2, after the commit. @Async and @TransactionalEventListener together are the closest this mechanism gets to "do the follow-up work off the request, but only if the order really exists". Note the improvement over the synchronous case: on task-2 actualTx reads false, which is the truth, so a REQUIRES_NEW write there is starting from a clean slate.

An asynchronous applicationEventMulticaster is the other switch, and it is global. The container looks this bean up by name, so the name is not optional:

src/main/java/com/example/demo/config/AsyncEventConfig.java
package com.example.demo.config;
 
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.SimpleApplicationEventMulticaster;
import org.springframework.core.task.TaskExecutor;
 
@Configuration
public class AsyncEventConfig {
 
    /** The bean name is fixed: the container looks this one up by name. */
    @Bean(name = "applicationEventMulticaster")
    public SimpleApplicationEventMulticaster applicationEventMulticaster(
            @Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
        SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster();
        multicaster.setTaskExecutor(taskExecutor);
        return multicaster;
    }
}

With that bean present and every listener from this article registered, the same single request produced:

Text
 INFO [nio-8204-exec-1] OrderService              : place() about to publish   thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
 INFO [         task-2] StockProjectionListener   : stock   order=1 thread=task-2 actualTx=false syncActive=false txName=null
 INFO [         task-3] ConfirmationEmailListener : email   order=1 thread=task-3 actualTx=false syncActive=false txName=null
 INFO [         task-7] GenericEventListeners     : supertype listener saw OrderPlaced
 INFO [         task-6] ChainingListener          : confirm order=1 returning an OrderConfirmed
 INFO [         task-6] GenericEventListeners     : supertype listener saw OrderConfirmed
 INFO [nio-8204-exec-1] OrderService              : place() publishEvent returned, method is about to return
 INFO [         task-8] ChainingListener          : onConfirmed OrderConfirmed[orderId=1, reference=REF-1] thread=task-8 actualTx=false syncActive=false txName=null
 INFO [nio-8204-exec-1] PhaseListeners            : BEFORE_COMMIT      order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager     : Initiating transaction commit
 INFO [         task-1] PayloadPeekListener       : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=1, sku=SKU-1, quantity=2, total=249.90]
 INFO [nio-8204-exec-1] PhaseListeners            : AFTER_COMMIT       order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
 INFO [nio-8204-exec-1] PhaseListeners            : AFTER_COMPLETION   order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place

Two results, and the second one surprises most people.

Every plain @EventListener moved, and @Order stopped meaning anything. seven listener invocations spread over six different task-N threads, and the listener at @Order(20) logged before the one at @Order(10); the @Order(5) wrapper listener logged after the commit. The multicaster still submits them in order, but submission order is not execution order. If any two of your listeners depend on running in sequence, an async multicaster breaks them.

Every @TransactionalEventListener stayed on http-nio-8204-exec-1, in the right phase. That is deliberate, and it is one line of the framework:

TransactionalApplicationListener.java (Spring Framework 7.0.9)
    default boolean supportsAsyncExecution() {
        return false;
    }

SimpleApplicationEventMulticaster.multicastEvent checks listener.supportsAsyncExecution() before handing anything to the executor, and a transactional listener says no — it has to register its synchronization on the thread that owns the transaction, and on a pool thread there would be none. So an async multicaster makes your @EventListener methods asynchronous and leaves your @TransactionalEventListener methods exactly where they were.

Between the two, prefer @Async on the individual listener. It is visible at the method that pays for it, it leaves the ordering of everything else intact, and it does not change the behaviour of listeners written by people who never read this configuration class. Reach for the multicaster only when you genuinely want every listener in the application off the publishing thread — and then set an ErrorHandler on it, because without one a listener exception on a pool thread is whatever the executor decides to do with it.

What events are good for, and where they stop

MechanismRuns whenThreadTransactionA listener that fails
@EventListenerinside publishEvent, before it returnsthe publisher'sthe publisher's, actualTx=truethrows at the publisher; later listeners skipped; publisher's transaction rolls back
@EventListener + @Asyncsome time after publishEvent returnstask-NnoneSimpleAsyncUncaughtExceptionHandler logs it at ERROR; publisher sees nothing
async applicationEventMulticastersome time after publishEvent returnstask-N, ordering lostnonegoes to the executor, or to the multicaster's ErrorHandler if you set one
@TransactionalEventListener(BEFORE_COMMIT)just before the commit is issuedthe publisher'sthe publisher's, still openInitiating transaction rollback after commit exception — the transaction rolls back
@TransactionalEventListener (AFTER_COMMIT)after the commit succeededthe publisher'scommitted already, writes need REQUIRES_NEWERROR log from TransactionSynchronizationUtils; HTTP 200; the work is lost
@TransactionalEventListener(AFTER_ROLLBACK)after the rollbackthe publisher'srolled back alreadyas above
@TransactionalEventListener + @Asyncafter the commit, off the threadtask-Nnone, actualTx=falseSimpleAsyncUncaughtExceptionHandler

Read the last column as the design constraint. Spring events are excellent at in-process decoupling: OrderService does not import the mail code, a new consequence of placing an order is a new @Component and nothing else, and the listeners are individually testable. Within one JVM, with @TransactionalEventListener(AFTER_COMMIT), they also get the one guarantee that matters most — the follow-up work does not happen for an order that was never committed.

They are not a reliability mechanism, and the last row of that table is why. The commit succeeds, the listener throws, the caller gets a 200, and the email is never sent. The same hole opens without any exception at all: the process is killed between the commit and the listener, and the work disappears with it. There is nothing to retry, because nothing recorded that the work was owed.

The standard answer is the outbox pattern: inside the same transaction that writes the order, write a row describing the work to be done; a separate poller reads those rows and does the work, marking each one done, retrying what fails. The event then carries a durable trace, and the "at least once" semantics are yours rather than the JVM's. That is Chapter 5 of this course. Spring Modulith's event publication registry is a ready-made version of the same idea, and a message broker such as Kafka or RabbitMQ is the cross-process version; neither changes what @TransactionalEventListener does inside one transaction, which is why this article is the prerequisite for both.

Two smaller rules follow from the same table. Keep listeners fast when they are synchronous, because they are inside the caller's transaction and hold its connection. And never publish an event just to call a method: if there is exactly one listener and it must succeed, a method call on an injected bean is clearer, type-checked, and visible in a stack trace.

FAQ

What is the difference between @EventListener and @TransactionalEventListener?

@EventListener runs the method during publishEvent, on the publisher's thread and inside the publisher's transaction, before publishEvent returns. @TransactionalEventListener registers a transaction synchronization instead and runs the method at a chosen point of the transaction's life — by default after a successful commit. Measured above: an @EventListener that throws rolled the order back, while an AFTER_COMMIT listener that threw left the order committed and the request at HTTP 200.

Why is my @TransactionalEventListener never called?

Almost always because the publisher was not in a transaction. Spring logs one DEBUG line per skipped listener — No transaction is active - skipping … on org.springframework.transaction.event — and does nothing else: no exception, no warning. Check that the publishing method really goes through the @Transactional proxy, which self-invocation from the same bean does not. If the listener must run either way, add fallbackExecution = true.

Can I write to the database from an AFTER_COMMIT listener?

Only with @Transactional(propagation = Propagation.REQUIRES_NEW) on the listener method. Without it the save joins the already-committed transaction still bound to the thread, and the row never reaches the database — the measured run returned a null generated id and 0 rows, with no error. Plain @Transactional is not an option: since Spring Framework 6.1 the application refuses to start, with @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED.

Are Spring events asynchronous?

No. By default publishEvent is an ordinary method call that runs every matching listener before it returns, on the calling thread. Making a listener asynchronous is opt-in: @Async on the listener method, or a SimpleApplicationEventMulticaster bean named applicationEventMulticaster with a TaskExecutor, which moves every plain @EventListener in the application and destroys @Order between them.

Why does my listener for a generic event never fire?

Because the type argument is erased. A listener declared OrderEvent<Product> was never invoked in the run above, for either a Product or a Customer payload, because ResolvableType.forInstance can only see the raw class and the match is refused. Make the event implement ResolvableTypeProvider and return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(subject)) — the typed version routed each payload to exactly one listener — or drop the type parameter and publish two distinct record types.

Do Spring events guarantee that a listener runs?

No. There is no persistence, no retry and no acknowledgement. An AFTER_COMMIT listener that throws logs at ERROR and is never retried; a process that dies between the commit and the listener loses the work with no record that it was owed. Use the outbox pattern when the follow-up work must happen: write it as a row in the same transaction and have a poller drive it.

Should the event be a record or a class extending ApplicationEvent?

A record. Extending ApplicationEvent has not been required since Spring 4.2, and a record keeps the event a plain immutable value with a readable toString, no Spring type in its signature and no reason for a test to build a context. Spring wraps it in a PayloadApplicationEvent internally; listeners declare the payload type and never see the wrapper.

Conclusion

Spring's event mechanism is two small things with very different behaviour. publishEvent plus @EventListener is a synchronous, ordered, in-transaction method call with a level of indirection: measured here, every listener ran on http-nio-8204-exec-1 inside OrderService.place's transaction, @Order decided the sequence, a condition skipped one, a returned value became a nested event, and a listener that threw took the order's row down with it. @TransactionalEventListener keeps the same thread but chooses the moment — BEFORE_COMMIT inside the transaction, AFTER_COMMIT after it succeeded, AFTER_ROLLBACK after it did not, AFTER_COMPLETION either way — and silently skips everything when there is no transaction at all unless fallbackExecution says otherwise.

Three measurements are worth carrying out of this article. A listener for a generic OrderEvent<Product> receives nothing without ResolvableTypeProvider. An AFTER_COMMIT listener sees actualTx=true although the transaction has committed, so a database write there needs REQUIRES_NEW or it is discarded without an error — 0 rows against 1. And an async multicaster moves every @EventListener onto task-N threads while leaving every @TransactionalEventListener on the publishing thread, because supportsAsyncExecution() returns false for them.

That closes Chapter 1 of this course — auto-configuration, the container's extension points, AOP and the proxy, and now events. Chapter 2 turns to the database and starts with the problem every JPA application meets first: the N+1 query, how to see it, and the four tools that fix it — fetch join, @EntityGraph, projections, and batch inserts.

Related Posts

[Advanced Spring Boot] Spring AOP: JDK and CGLIB Proxies, Aspects and Self-Invocation

Spring AOP on Spring Boot 4.1.1: spring-boot-starter-aop is gone from the BOM and spring-boot-starter-aspectj replaces it, JDK dynamic proxy against CGLIB subclass with the real class names, the ClassCastException a JDK proxy causes, the final class that throws AopConfigException, the final method that quietly NPEs because Objenesis skipped the constructor, the pointcut designators that matter, the measured order of all five advice kinds on both paths, @Order between aspects, the self-invocation trap underneath @Transactional and @Async with three fixes compared, the nanosecond cost of a proxied call, and Advised#getAdvisors for debugging.

[Advanced Spring Boot] Spring Transactions in Depth: Propagation, Isolation Levels and Rollback Rules

Spring transaction propagation, isolation and rollback rules on Spring Boot 4.1.1 with PostgreSQL: all seven propagations with JpaTransactionManager logs and backend pids, the REQUIRES_NEW connection pool deadlock with HikariCP timings, why NESTED fails with JpaTransactionManager and works with JdbcTransactionManager savepoints, non-repeatable reads, lost updates and write skew under each isolation level, SQLSTATE 40001 as CannotAcquireLockException, a correct retry around the transaction, readOnly at the JDBC, PostgreSQL and Hibernate layers, validateExistingTransaction, rollbackOn ALL_EXCEPTIONS and what really enforces @Transactional(timeout).

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.

[Advanced Spring Boot] Multi-Tenancy and Soft Delete with Spring Boot and Hibernate

Multi-tenancy and soft delete on Spring Boot 4.1.1 with Hibernate and PostgreSQL: an X-Tenant-Id filter with a ThreadLocal that leaks on a reused Tomcat thread and is lost on @Async, a discriminator column with @TenantId and CurrentTenantIdentifierResolver (the tenant predicate on find, JPQL, derived queries, Specifications and bulk updates, none on native SQL or JdbcClient), schema per tenant with a MultiTenantConnectionProvider, the setSchema versus SET search_path connection-reuse trap on HikariCP, Flyway per tenant schema and Hibernate's TenantSchemaMapper, database per tenant and 100 connections for ten tenants, PostgreSQL row-level security with set_config and FORCE, @SoftDelete strategies versus @SQLDelete and @SQLRestriction, the LAZY to-one error, a partial unique index for soft-deleted SKUs, and restoring deleted rows.