publishEvent() is a method call, not a queue
The setup, and what this post deliberately leaves out
I started a throwaway playground — Spring Boot 4.1, Java 25, Spring Data JDBC, a Postgres that Docker Compose brings up and takes down — with one rule for myself: log what the machinery does rather than trust what the page says it does.
This post is the boring half on purpose. No @TransactionalEventListener, no phases, no outbox. Just ApplicationEventPublisher and plain @EventListener. That layer sits underneath everything people actually reach for, and it turned out to be where all the surprises were hiding.
Two-thirds of the reference example has quietly aged out
The reference docs still teach the event system as a triangle: an event class extending ApplicationEvent, a publisher implementing ApplicationEventPublisherAware, and a listener implementing ApplicationListener<T>. The shape is still right. Two of the three parts are no longer how you'd write it, and the page never says so.
An event has not needed a base class since Spring 4.2 — publish any object and Spring wraps it in a PayloadApplicationEvent for you. So a record is the natural fit:
public record OrderPlaced(Long orderId, Long customerId, BigDecimal total, Instant placedAt)
implements OrderEvent {}
You give up getSource() and getTimestamp(), which the docs' example leans on. That loss is the point: everything a listener needs has to be in the payload, so the event becomes a self-contained fact instead of a pointer back into the publisher's object graph. That property is exactly what survives being serialised into a database row later — a live bean reference would not.
The publisher goes in by constructor. ApplicationEventPublisherAware predates constructor injection and buys nothing today.
publishEvent() blocks
The first thing I wanted to see was whether publishing is a handoff or a call. So the publisher logs on both sides of it:
@Transactional
public Order place(Long customerId, BigDecimal total) {
var saved = orders.save(Order.place(customerId, total));
log.info("ZBO: publishing OrderPlaced for order {}", saved.id());
events.publishEvent(OrderPlaced.from(saved));
log.info("ZBO: publishEvent returned, order {} placed", saved.id());
return saved;
}
And the listener reports the two things I actually cared about — which thread it is on, and whether a transaction is live:
@EventListener
void onOrderPlaced(OrderPlaced event) {
log.info("ZBO: [@EventListener] caught {} | thread={} | txActive={}",
event,
Thread.currentThread().getName(),
TransactionSynchronizationManager.isActualTransactionActive());
}

Three facts, none of them stated on the reference page. The listener runs between the publish call and the next line, so publication is synchronous and blocking. It runs on main — the caller's thread, no dispatch. And txActive=true: it is executing inside the caller's still-open transaction. Nothing has committed.
Four ways to listen, and the documented one is the worst

The docs demonstrate handling several event types by listing them in the annotation: @EventListener({A.class, B.class}). What they mention only in passing is the consequence — with multiple classes the method may take no parameter at all. The listener learns that something happened, but not what, and gets no payload.
A sealed interface does the same job without the amputation, and the compiler starts helping:
public sealed interface OrderEvent
permits OrderPlaced, OrderConfirmed, HighValueOrderDetected {
Long orderId();
}
@EventListener
void onOrderEvent(OrderEvent event) {
var summary = switch (event) {
case OrderPlaced(var id, var customerId, var total, _) ->
"placed %d for customer %d totalling %s".formatted(id, customerId, total);
case OrderConfirmed(var id, var at) -> "confirmed %d at %s".formatted(id, at);
case HighValueOrderDetected(var id, var total) ->
"flagged %d as high value (%s)".formatted(id, total);
};
log.info("ZBO: [sealed switch] {}", summary);
}
The switch is exhaustive over the permitted subtypes, so adding a fourth event type breaks the build here rather than silently going unhandled at runtime. That is a strictly better failure mode than a listener that quietly stops covering a case.
The other two features are genuinely good. A listener can return a value and Spring publishes it as a further event — note there is no publisher injected in this class at all. And a SpEL condition is evaluated before the method is invoked:
@EventListener
OrderConfirmed confirmOrder(OrderPlaced event) {
return new OrderConfirmed(event.orderId(), Instant.now());
}
@EventListener(condition = "#event.total > 100")
HighValueOrderDetected flagHighValue(OrderPlaced event) {
return new HighValueOrderDetected(event.orderId(), event.total());
}
Two details in that condition are worth pausing on. #event resolves by parameter name, which needs the compiler's -parameters flag — spring-boot-starter-parent sets it, so it works, but #root.event is the form that works regardless. And it compares a BigDecimal against an int literal without a converter, because Spring's SpEL operator implementation special-cases BigDecimal and promotes both sides. Plain Java > cannot do that at all.
Publication is depth-first
Then I placed one order that trips the condition, and read the log in emitted order.

Look at the order carefully. flagHighValue runs and returns HighValueOrderDetected. That chained event is then published and fully handled — both of its listeners run to completion — before OrderPlaced reaches its own remaining listener. Publication is depth-first, not breadth-first. A chained event does not queue up behind the original; it recurses straight down.
The mechanism explains it. SimpleApplicationEventMulticaster loops over listeners and invokes each one inline. When a listener returns a value, ApplicationListenerMethodAdapter calls straight back into the multicaster — from inside that loop. So an event "chain" is a call stack, not a pipeline.
Which has teeth. A cycle — A returns B, B returns A — is a StackOverflowError, not a hang. A chained listener that throws propagates all the way up through the original publishEvent() and aborts the original's remaining listeners. And the order listeners run in is reflection order: effectively arbitrary, absolutely not something to depend on. That is what @Order is for.
What is actually missing
Add it up and the plain event system is one thread, one stack, one transaction, running to completion before your next line executes. That is a perfectly good design — it is just a much smaller promise than "events" usually implies.
What it is not is durable. Delivery is in-memory and at-most-once: if the JVM dies between the commit and the listener, the event is gone, with no record it was ever attempted. Core Spring has no registry, no retry, no trace.
The usual first move against that is @TransactionalEventListener and its phases — which is the next thing I pulled apart, and which has a trap in it nasty enough to deserve its own post. Closing the durability gap properly is further on again: I wrote up that end of it in Spring events that survive a crash.
Every log line above is real output from the playground. None of it is in the reference documentation, and I would not have found any of it by reading.