All articles

// Knowledge.log — 技術記事

@TransactionalEventListener: the AFTER_COMMIT your @Transactional test never fires

Boot 4.1.1: @TransactionalEventListener AFTER_COMMIT stays at 0 in a @Transactional test and hits 1 when the service commits. No JPA, no container.

You've probably seen this setup. An order service publishes an event inside a transaction. A @TransactionalEventListener does the work that should only happen after the commit: send the email, write to the outbox, notify another module. The integration test starts the context with @SpringBootTest, gets a @Transactional so it "doesn't leave a mess", calls the service, and goes green.

In that test, the AFTER_COMMIT listener never runs. The service joins the test's transaction, that transaction ends in a rollback, and the commit the listener is waiting for never happens. If the test doesn't check the listener, the green bar tells you nothing about it. If it does check, it finds 0. Then someone "fixes" it by swapping the annotation for @EventListener, and now the problem is worse.

By the end you'll have a small fixture with no database and two executed tests. With @Transactional on the test, the listener is called 0 times. Without it, the listener is called once. You'll also see when BEFORE_COMMIT and AFTER_ROLLBACK show up, and when they don't.

If you read Transactional integration tests: the rollback that hides the commit, you'll recognize the annotation, but this is a different problem. There, the rollback hid the repository INSERT and the 409 that only appears on the path that commits. Here there's no repository at all, and the thing that goes missing is the listener call.

What ran, and on which versions

The fixture ran on 2026-10-20 with Spring Boot 4.1.1 (the current GA), Spring Framework 7.0.9, Temurin 25.0.4 LTS, and Maven 3.9.12, compiled with release 25 and no --enable-preview. JUnit Jupiter comes from the spring-boot-starter-test BOM with no version pinned in the POM, and in this run the runtime resolved 6.0.3. Surefire is pinned at 3.6.0. The Spring Boot system requirements page says 4.1.1 requires Java 17 or later and is compatible up to Java 26, so JDK 25 is inside the matrix.

The project uses no JPA, H2, Docker, Testcontainers, or web server, and the context starts with webEnvironment = NONE. mvn -q test passed. The two Surefire reports each record tests=1 failures=0 errors=0 skipped=0, so the totals are Tests run: 2, Failures: 0, Errors: 0, Skipped: 0.

The contract is in the javadoc

The @TransactionalEventListener javadoc spells out the behavior:

> "If the event is not published within an active transaction, the event is discarded unless the fallbackExecution() flag is explicitly set. If a transaction is running, the event is handled according to its TransactionPhase."

The phase attribute defaults to AFTER_COMMIT, and fallbackExecution defaults to false. The TransactionPhase javadoc describes each phase:

  • AFTER_COMMIT: "Handle the event after the commit has completed successfully."
  • BEFORE_COMMIT: "Handle the event before transaction commit."
  • AFTER_ROLLBACK: "Handle the event if the transaction has rolled back."
  • AFTER_COMPLETION: runs after the transaction finishes, whether it committed or rolled back. AFTER_COMMIT and AFTER_ROLLBACK are specializations of it.

The reference documentation on transaction-bound events says the same thing: "When you do so, the listener is bound to the commit phase of the transaction by default."

On the test side, the TestContext transaction docs say a test annotated with @Transactional runs in a transaction that is, "by default, automatically rolled back after completion of the test". The service's @Transactional uses REQUIRED propagation, so the service joins the transaction the test opened. Returning from placeOrder() commits nothing, and the only transaction around ends in a rollback. AFTER_COMMIT sits there waiting for a commit that never comes. That's documented behavior, not a Spring bug.

The fixture

Everything lives in src/test/java, in the devdojo.aftercommit package, application class included.

POM: declare spring-tx

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-tx</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <release>25</release>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.6.0</version>
        </plugin>
    </plugins>
</build>

In Boot 4.1, spring-boot-starter doesn't bring in spring-tx, which is where @TransactionalEventListener, TransactionPhase, and @Transactional live. Without JDBC or JPA, nothing else pulls that module in, so you have to declare it yourself. The version comes from the BOM.

A transaction manager with no resource behind it

With no DataSource on the classpath, nobody registers a PlatformTransactionManager for you. The SimpleTransactionManager that shows up in some references doesn't exist in Spring Framework 7.0.9, so the fixture ships its own:

package devdojo.aftercommit;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@SpringBootApplication
@EnableTransactionManagement
public class AfterCommitDemoApplication {

    @Bean
    PlatformTransactionManager transactionManager() {
        return new InMemoryTransactionManager();
    }
}
package devdojo.aftercommit;

import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;

final class InMemoryTransactionManager extends AbstractPlatformTransactionManager {

    private static final class Holder {
        boolean active;
    }

    private final ThreadLocal<Holder> current = new ThreadLocal<>();

    @Override
    protected Object doGetTransaction() {
        Holder holder = current.get();
        if (holder == null) {
            holder = new Holder();
            current.set(holder);
        }
        return holder;
    }

    @Override
    protected boolean isExistingTransaction(Object transaction) {
        return ((Holder) transaction).active;
    }

    @Override
    protected void doBegin(Object transaction, TransactionDefinition definition) {
        ((Holder) transaction).active = true;
    }

    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        ((Holder) status.getTransaction()).active = false;
        current.remove();
    }

    @Override
    protected void doRollback(DefaultTransactionStatus status) {
        ((Holder) status.getTransaction()).active = false;
        current.remove();
    }
}

The method that matters is isExistingTransaction. It's what lets the service's REQUIRED propagation join the transaction the TestContext opened. A manager that always starts a fresh transaction makes the service commit inside test A, and AFTER_COMMIT fires where it shouldn't. That's exactly what happened on this fixture's first run, before the fix. If your experiment shows 1 where the javadoc promises 0, check the transaction manager before you start doubting Spring.

Event, service, and listener

package devdojo.aftercommit;

public record OrderPlaced(String orderId) {
}
package devdojo.aftercommit;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final ApplicationEventPublisher publisher;

    public OrderService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    @Transactional
    public void placeOrder(String orderId) {
        publisher.publishEvent(new OrderPlaced(orderId));
    }
}

A record is enough for the payload. publishEvent(Object) handles the rest. No Modulith, no outbox.

The listener counts four things: the three transactional phases we care about, plus a plain @EventListener for contrast.

package devdojo.aftercommit;

import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class AfterCommitCounter {

    private final AtomicInteger commits = new AtomicInteger();
    private final AtomicInteger beforeCommit = new AtomicInteger();
    private final AtomicInteger afterRollback = new AtomicInteger();
    private final AtomicInteger immediate = new AtomicInteger();

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCommit(OrderPlaced event) {
        commits.incrementAndGet();
    }

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void onBeforeCommit(OrderPlaced event) {
        beforeCommit.incrementAndGet();
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void onRollback(OrderPlaced event) {
        afterRollback.incrementAndGet();
    }

    @EventListener
    public void onImmediate(OrderPlaced event) {
        immediate.incrementAndGet();
    }

    public int commitCount() { return commits.get(); }
    public int beforeCommitCount() { return beforeCommit.get(); }
    public int afterRollbackCount() { return afterRollback.get(); }
    public int immediateCount() { return immediate.get(); }

    public void reset() {
        commits.set(0);
        beforeCommit.set(0);
        afterRollback.set(0);
        immediate.set(0);
    }
}

The only thing in src/test/resources/application.properties is spring.main.banner-mode=off. Both test classes use the same configuration, so Spring reuses the cached context. That's why the counters get reset in @BeforeEach. Skip that and one test inherits the other's numbers.

Test A: @Transactional on the test, AFTER_COMMIT at 0

package devdojo.aftercommit;

import static org.junit.jupiter.api.Assertions.assertEquals;

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.test.context.transaction.AfterTransaction;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
class AfterCommitSilentWhenTestIsTransactionalTest {

    @Autowired
    OrderService orders;

    @Autowired
    AfterCommitCounter counter;

    @BeforeEach
    void reset() {
        counter.reset();
    }

    @Test
    void afterCommitListenerDoesNotRunWhileTestTransactionIsOpen() {
        orders.placeOrder("o-1");
        assertEquals(0, counter.commitCount());
        assertEquals(0, counter.beforeCommitCount());
        assertEquals(0, counter.afterRollbackCount());
        assertEquals(1, counter.immediateCount());
    }

    @AfterTransaction
    void afterRollbackListenerRunsOnceWhenTestRollsBack() {
        assertEquals(0, counter.commitCount());
        assertEquals(0, counter.beforeCommitCount());
        assertEquals(1, counter.afterRollbackCount());
    }
}

Right after placeOrder("o-1"), still inside the test transaction, every transactional listener is at 0, BEFORE_COMMIT included. Only the plain @EventListener reads 1, because it runs at publish time while the transaction is still open. The wrong listener is the most punctual one in the room, and it's the one that makes this test look like proof the side effect happened.

The AFTER_COMMIT 0 already holds before the rollback, so you don't need to wait for the transaction to end to confirm it. After the method returns, TransactionalTestExecutionListener rolls back, and @AfterTransaction shows the outcome: AFTER_COMMIT stays at 0, BEFORE_COMMIT stays at 0, and AFTER_ROLLBACK goes to 1. The only transactional listener that does any work in test A shows up after the test method has already finished.

Test B: no @Transactional on the test, AFTER_COMMIT at 1

package devdojo.aftercommit;

import static org.junit.jupiter.api.Assertions.assertEquals;

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;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AfterCommitFiresWhenServiceCommitsTest {

    @Autowired
    OrderService orders;

    @Autowired
    AfterCommitCounter counter;

    @BeforeEach
    void reset() {
        counter.reset();
    }

    @Test
    void afterCommitListenerRunsOnceAfterServiceTransactionCommits() {
        orders.placeOrder("o-1");
        assertEquals(1, counter.commitCount());
        assertEquals(1, counter.beforeCommitCount());
        assertEquals(0, counter.afterRollbackCount());
        assertEquals(1, counter.immediateCount());
    }
}

The only difference is that the class has no @Transactional. Now the only transaction is the service's, and it commits when placeOrder() returns successfully. Listeners are synchronous by default and run on the same thread, so by the time control is back in the test, BEFORE_COMMIT and AFTER_COMMIT are both at 1. No sleep, no waiting.

This setup beats putting @Rollback(false) on test A. With @Rollback(false), the test transaction stays open for the whole method and the commit only happens after it ends. The 0 assertion inside the method wouldn't change, you'd have to move the check into @AfterTransaction, and you'd still be stuck cleaning up committed state once there's a real database. Here the counter lives in memory and test B needs no cleanup.

The numbers side by side

scenario@Transactional on testAFTER_COMMITBEFORE_COMMITAFTER_ROLLBACK@EventListener
A, still in the methodyes (default rollback)0001
A, @AfterTransactionyes0011
B, after placeOrderno1101

@EventListener reads 1 in all three rows, and AFTER_COMMIT reads 1 only in the third. That gap is exactly what test A hides.

Pitfalls and limits

  • Asserting AFTER_COMMIT inside an open test transaction. The result is always 0. More often, nobody writes that assertion at all, and the test stays green without ever exercising the listener.
  • Switching to @EventListener to make the test pass. It runs at publish time, before the commit. In production, the email goes out even if the transaction rolls back a moment later.
  • Treating BEFORE_COMMIT as "after success". It runs on the commit path, and the TestContext rollback doesn't take that path, which is why test A never sees BEFORE_COMMIT. Even when it does run, it's still before the commit. The TransactionSynchronization javadoc warns about beforeCommit: "This callback does not mean that the transaction will actually be committed."
  • AFTER_ROLLBACK has its own job. It's the right listener for compensation or logging when the business flow, or the test, actually rolls back. In test A it's only observable in @AfterTransaction.
  • fallbackExecution = true. It makes the listener run without a transaction and hides the very contract the test is supposed to check. It's left out of the fixture on purpose.
  • REQUIRES_NEW on the service. The inner transaction commits while the test's transaction is still open, and its AFTER_COMMIT fires inside test A. The test goes green again for a different reason, and the service's semantics changed just to keep the test happy. Not in the fixture.
  • @Async listeners. Out of scope here because they'd need waiting in the test. The default is synchronous, and that's what keeps test B deterministic.
  • You don't need Modulith or an outbox. Spring Modulith's @ApplicationModuleListener is a @TransactionalEventListener and follows the same commit rule. A record, a service, and a counter are enough to reproduce all of this.

What DevDojo would do

DevDojo wouldn't put @Transactional on a test whose job is to prove an AFTER_COMMIT listener ran. The warning sign is concrete: the test is green, commitCount() is at 0, and the production email or outbox write never had a chance to happen. In that situation, BEFORE_COMMIT and @EventListener are no substitute for "after success". AFTER_ROLLBACK is the right listener when the test or the business flow really rolls back.

The next step is short:

  1. Move post-commit side-effect tests into a class without @Transactional, or drive a path that actually commits, and check a counter or spy in the listener itself.
  2. Keep @Transactional on test classes that verify what happens inside the transaction.
  3. Use @EventListener only when that's exactly what you mean: react while the transaction is still open.

The same logic applies in production. The log line or metric that shows the post-commit effect happened belongs inside the AFTER_COMMIT listener, not in the service that publishes the event. The service will always log that it published, including when the commit never arrives.

javaspring-boottesting

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles