ZB Field Notes

Four phases, and the one that never runs at all

Four phases, and the one that never runs at all

Where this picks up

In the previous post I went through Spring's plain event system and found it smaller than the word "events" suggests: publishEvent() is synchronous, runs on your thread, inside your still-open transaction, and finishes before your next line does.

This one is about the annotation everyone reaches for next. Same playground — Spring Boot 4.1, Java 25, Spring Data JDBC, real Postgres — same rule: log what happens rather than trust what the page says. Two of the things below contradict what the documentation led me to expect, and I only found them because I ran them.

It does not invoke your method. It parks it.

This is the one fact everything else falls out of, so it is worth stating before any behaviour.

Diagram contrasting @EventListener, which is invoked inline immediately, with @TransactionalEventListener, which registers a TransactionSynchronization to be fired later by the transaction manager
A plain listener is called. A transactional listener is registered and called back — possibly never.

A plain @EventListener is invoked inline, inside publishEvent(). A @TransactionalEventListener is not: the adapter registers a TransactionSynchronization against the current transaction, and the transaction manager fires it later from beforeCommit() or afterCompletion().

Keep that in mind and every surprise in this post stops being a surprise.

Commit and rollback

Four listeners on the same event, one per phase, each doing nothing but reporting that it ran:

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void beforeCommit(OrderPlaced event) { record("BEFORE_COMMIT", event); }

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void afterCommit(OrderPlaced event) { record("AFTER_COMMIT", event); }

@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
void afterRollback(OrderPlaced event) { record("AFTER_ROLLBACK", event); }

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
void afterCompletion(OrderPlaced event) { record("AFTER_COMPLETION", event); }

And a publisher that fails after publishing, so the event is already out when the transaction dies:

@Transactional
public void placeThenFail(Long customerId, BigDecimal total) {
    var saved = orders.save(Order.place(customerId, total));
    events.publishEvent(OrderPlaced.from(saved));
    throw new IllegalStateException("deliberate rollback after publishing");
}
Log output side by side: the commit path fires BEFORE_COMMIT, AFTER_COMPLETION and AFTER_COMMIT; the rollback path fires only AFTER_COMPLETION and AFTER_ROLLBACK
Rollback silences the commit phases — but the event still reached listeners.

Worth noticing on the rollback run: orders.findAll() comes back empty, so the row genuinely went with the transaction, and yet the listeners still ran. Rolling back does not un-publish an event. It only changes which phases are interested in it.

The AFTER_* phases are not a sequence

I wrote the first version of that test asserting BEFORE_COMMIT, then AFTER_COMMIT, then AFTER_COMPLETION. It failed.

Comparison showing the predicted order BEFORE_COMMIT then AFTER_COMMIT then AFTER_COMPLETION against the observed order BEFORE_COMMIT then AFTER_COMPLETION then AFTER_COMMIT
AFTER_COMPLETION ran before AFTER_COMMIT. It is not a lifecycle wrap-up.

The reason is in the synchronization. BEFORE_COMMIT comes from its own beforeCommit() callback, so it genuinely is first. But all three AFTER_* phases are dispatched from a single afterCompletion(int status) callback that switches on the status — they are three branches of one method, not three points in time.

So their relative order is the order the synchronizations were registered, which follows listener registration order, which is reflection order, which is arbitrary. If you need one after-phase listener to run before another, @Order is the only thing that will do it. The phase names will not.

The one that never runs

Now the headline. Publish the same event with no transaction active at the moment of publication:

// Deliberately NOT @Transactional. Spring Data JDBC's save() opens and commits
// its own transaction internally, so it is already over by the time we publish.
public Order publishOutsideTransaction(Long customerId, BigDecimal total) {
    var saved = orders.save(Order.place(customerId, total));
    events.publishEvent(OrderPlaced.from(saved));
    return saved;
}
Log showing every plain @EventListener running normally while four transactional listeners never appear, with only the fallbackExecution listener firing
Four transactional listeners vanish. The plain ones behave exactly as always.

BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK and AFTER_COMPLETION produce no output whatsoever. No exception, no warning, not one line. Meanwhile every plain @EventListener ran perfectly — same event, same publish call, same thread.

That asymmetry is what makes this expensive to diagnose. It does not look like a broken event system; it looks like some of your code is fine. And the mechanism explains it exactly: no transaction means no synchronization list, means nowhere to park the listener, means it is discarded.

The opt-out is one attribute:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
void afterCommitWithFallback(OrderPlaced event) { ... }

With a transaction present it behaves like any other AFTER_COMMIT listener. Without one it runs inline, like a plain @EventListener. In the run above it was the only transactional listener that fired at all.

The finding I did not expect

The Javadoc carries a warning about the AFTER_COMMIT phase: data access inside the listener still participates in the original transaction, which has already committed, so your changes will not be committed. I wanted to watch that happen, so I wrote a listener that updates the order's status without REQUIRES_NEW, and a test asserting the status was unchanged.

The test failed. expected: NEW but was: PAID. The write survived.

So I instrumented the connection rather than guess:

var connection = DataSourceUtils.getConnection(dataSource);
log.info("conn=#{} autoCommit={} boundToTx={}",
        System.identityHashCode(connection),
        connection.getAutoCommit(),
        DataSourceUtils.isConnectionTransactional(connection, dataSource));
// conn=#1141777414 autoCommit=false boundToTx=true

The premise was right — the listener really was holding the publisher's own connection, from an already-committed transaction. And the write persisted anyway. One flag decides it:

Table showing that with hikari auto-commit true the write survives because Spring restores autoCommit at cleanup which commits pending work, and with auto-commit false the write vanishes silently
Same code, opposite outcome, decided by a connection-pool setting.

DataSourceTransactionManager flipped autoCommit to false when the transaction began, so at cleanup it restores it — and per the JDBC spec, setAutoCommit(true) commits whatever is pending. The listener's write is committed by the restoration, never by intent. Set the pool to auto-commit=false and there is nothing to restore: no implicit commit, the connection goes back to the pool dirty, and the write is rolled back in silence.

Which makes this worse than "your writes vanish". If they always vanished you would catch it on day one. Instead the broken version works on Boot's default configuration and fails when someone tunes the pool, or you deploy somewhere with a different default. @Transactional(propagation = REQUIRES_NEW) suspends the completed transaction and runs on its own connection; with it the test passes under both settings. It is not a style preference.

@Async composes with it, and that is the pairing you want

The two annotations answer orthogonal questions, which is why they work together: the phase decides whether to invoke, on the committing thread, before any handoff; @Async decides where the body runs.

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyAfterCommitAsync(OrderPlaced event) { ... }

On the rollback path this produced no output at all — the phase check failed on the committing thread, so nothing was ever submitted to the executor. The gating survives the handoff.

And it is safe in a way that plain @Async @EventListener is not. That one fires mid-transaction, so a listener that queries the database for the row you just wrote may not find it. Here the commit has already happened before the handoff, so the data is visible to any connection. You get the concurrency without the race.

Two things not to do. @Async with BEFORE_COMMIT is broken by construction: that phase's whole value is running inside the transaction and being able to veto the commit by throwing, and an async listener cannot — the commit will not wait for it. And @Async on AFTER_ROLLBACK works, but exceptions inside it go to AsyncUncaughtExceptionHandler, whose default implementation logs and returns — a poor property for the phase whose entire job is handling failure.

The pattern underneath all of it

Nothing in this post threw an exception. The schema that never ran, the admin UI that 404ed, the test config that silently replaced the main one, the async annotation that does nothing without @EnableAsync, and now four listeners quietly declining to execute — every one was found by reading a log, not by catching an error.

That seems to be the house style, and it suggests a default habit: when something in Spring "does not work", check first whether it was ever switched on. The framework will rarely tell you.