Todos os artigos

// Knowledge.log — 技術記事

@TransactionalEventListener: o AFTER_COMMIT que o teste @Transactional nunca dispara

Boot 4.1.1: @TransactionalEventListener AFTER_COMMIT fica em 0 no teste @Transactional e em 1 quando o serviço commita. Sem JPA, sem container.

O desenho é comum. O serviço de pedidos publica um evento dentro da transação. Um listener com @TransactionalEventListener faz o trabalho que só pode acontecer depois do commit: mandar o e-mail, gravar na outbox, avisar outro módulo. O teste de integração sobe o contexto com @SpringBootTest, recebe um @Transactional para "não sujar nada", chama o serviço e fica verde.

Nesse teste, o listener de AFTER_COMMIT nunca roda. O serviço entra na transação do teste, essa transação termina em rollback e o commit que o listener espera não acontece. Se o teste não verifica o listener, o verde não diz nada sobre ele. Se verifica, encontra 0. Aí alguém troca a anotação por @EventListener para "consertar", e o problema piora.

Ao final você vai ter uma fixture pequena, sem banco, com dois testes executados. Com @Transactional no teste, o listener é chamado 0 vezes. Sem a anotação, é chamado 1 vez. Você também vai ver em que momento BEFORE_COMMIT e AFTER_ROLLBACK aparecem, e em que momento não aparecem.

Quem leu @Transactional no teste de integração: o rollback que esconde o commit vai reconhecer a anotação, mas o problema é outro. Lá, o rollback escondia o INSERT do repositório e o 409 que só aparece no caminho que commita. Aqui não existe repositório, e o que desaparece é a chamada do listener.

O que rodou e com quais versões

A fixture rodou em 20/10/2026 com Spring Boot 4.1.1 (a GA atual), Spring Framework 7.0.9, Temurin 25.0.4 LTS e Maven 3.9.12, compilando com release 25 e sem --enable-preview. O JUnit Jupiter vem do BOM do spring-boot-starter-test, sem versão fixada no POM, e nesta execução o runtime usou a 6.0.3. O Surefire está fixado em 3.6.0. A página de requisitos do Spring Boot diz que o 4.1.1 exige Java 17 ou superior e é compatível até o Java 26, então o JDK 25 está dentro da matriz.

O projeto não usa JPA, H2, Docker, Testcontainers nem servidor web, e o contexto sobe com webEnvironment = NONE. O mvn -q test passou. Os dois relatórios do Surefire registram tests=1 failures=0 errors=0 skipped=0, ou seja, 2 testes e 0 falhas no total.

O contrato está no javadoc

O javadoc de @TransactionalEventListener define o comportamento sem ambiguidade:

> "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."

O atributo phase tem default AFTER_COMMIT, e fallbackExecution tem default false. O javadoc de TransactionPhase descreve cada fase:

  • 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: roda depois que a transação termina, com commit ou com rollback. AFTER_COMMIT e AFTER_ROLLBACK são especializações dela.

A documentação de referência sobre eventos ligados à transação repete a regra: "When you do so, the listener is bound to the commit phase of the transaction by default."

Do lado do teste, a documentação de transações do TestContext diz que um teste anotado com @Transactional roda numa transação que, "by default, automatically rolled back after completion of the test". O @Transactional do serviço usa propagação REQUIRED, então o serviço entra na transação que o teste abriu. O retorno de placeOrder() não commita nada, e a única transação existente termina em rollback. O AFTER_COMMIT fica esperando um commit que nunca acontece. Isso é o comportamento documentado, não um bug do Spring.

A fixture

Tudo fica em src/test/java, no pacote devdojo.aftercommit, inclusive a classe de aplicação.

POM: declare o 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>

No Boot 4.1, o spring-boot-starter não traz o spring-tx, que é onde moram @TransactionalEventListener, TransactionPhase e @Transactional. Sem JDBC ou JPA, nada mais puxa esse módulo, então ele precisa ser declarado explicitamente. A versão vem do BOM.

Um gerenciador de transação sem recurso

Sem DataSource no classpath, ninguém registra um PlatformTransactionManager para você. O SimpleTransactionManager que aparece em algumas referências não existe no Spring Framework 7.0.9, por isso a fixture traz o seu próprio:

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

O método que importa é isExistingTransaction. É por causa dele que a propagação REQUIRED do serviço entra na transação aberta pelo TestContext. Um gerenciador que sempre inicia uma transação nova faz o serviço commitar dentro do teste A, e o AFTER_COMMIT dispara onde não deveria. Isso aconteceu na primeira execução desta fixture, antes do ajuste. Se o seu experimento mostrar 1 onde o javadoc promete 0, confira primeiro o gerenciador, antes de duvidar do Spring.

Evento, serviço e 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));
    }
}

Um record basta como payload. O publishEvent(Object) cuida do resto, sem Modulith e sem outbox.

O listener conta quatro coisas: as três fases transacionais que interessam e um @EventListener comum, que serve de contraste.

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

Em src/test/resources/application.properties, a fixture só desliga o banner com spring.main.banner-mode=off. As duas classes de teste usam a mesma configuração, então o Spring reaproveita o contexto em cache. Por isso os contadores são zerados no @BeforeEach. Sem isso, um teste herda os números do outro.

Teste A: @Transactional no teste, AFTER_COMMIT em 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());
    }
}

Logo depois de placeOrder("o-1"), ainda dentro da transação do teste, os listeners transacionais estão em 0, inclusive o BEFORE_COMMIT. Só o @EventListener comum marcou 1, porque ele roda no momento da publicação, com a transação ainda aberta. O listener errado é o mais pontual da sala, e é ele que faz este teste parecer uma prova de que o efeito colateral aconteceu.

O 0 do AFTER_COMMIT já vale antes do rollback, então não é preciso esperar o fim da transação para confirmá-lo. Depois do método, o TransactionalTestExecutionListener faz o rollback, e o @AfterTransaction mostra o resultado: AFTER_COMMIT continua em 0, BEFORE_COMMIT continua em 0 e AFTER_ROLLBACK vai a 1. O único listener transacional que trabalha no teste A só aparece depois que o método de teste já terminou.

Teste B: sem @Transactional no teste, AFTER_COMMIT em 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());
    }
}

A única diferença é a ausência de @Transactional na classe. Agora a única transação é a do serviço, que commita quando placeOrder() retorna com sucesso. Os listeners são síncronos por default e rodam na mesma thread, então quando o controle volta ao teste o BEFORE_COMMIT e o AFTER_COMMIT já estão em 1. Não há sleep nem espera.

Esse arranjo é melhor do que colocar @Rollback(false) no teste A. Com @Rollback(false), a transação do teste continua aberta durante o método, e o commit só acontece depois que ele termina. A asserção de 0 dentro do método continuaria igual, você teria que mover a verificação para @AfterTransaction e ainda ficaria com o estado commitado para limpar quando houver banco de verdade. Aqui o contador está em memória e o teste B não precisa de limpeza.

Os números lado a lado

cenário@Transactional no testeAFTER_COMMITBEFORE_COMMITAFTER_ROLLBACK@EventListener
A, ainda no métodosim (rollback default)0001
A, @AfterTransactionsim0011
B, após placeOrdernão1101

O @EventListener dá 1 nas três linhas e o AFTER_COMMIT só dá 1 na terceira. É essa diferença que o teste A esconde.

Armadilhas e limites

  • Asserção de AFTER_COMMIT dentro de uma transação de teste aberta. O resultado é sempre 0, e o mais comum é ninguém escrever essa asserção e o teste ficar verde sem ter exercitado o listener.
  • Usar @EventListener para o teste passar. Ele roda na publicação, antes do commit. Em produção, o e-mail sai mesmo que a transação faça rollback logo depois.
  • Tratar BEFORE_COMMIT como "depois do sucesso". Ele roda no caminho de commit, e o rollback do TestContext não passa por esse caminho, tanto que o teste A não vê BEFORE_COMMIT. Mesmo quando roda, ainda é antes do commit. O javadoc de TransactionSynchronization avisa sobre o beforeCommit: "This callback does not mean that the transaction will actually be committed."
  • AFTER_ROLLBACK tem lugar próprio. É o listener certo para compensação ou registro quando o negócio, ou o teste, realmente faz rollback. No teste A ele só é observável em @AfterTransaction.
  • fallbackExecution = true. Faz o listener rodar sem transação e esconde justamente o contrato que o teste deveria verificar. Ficou fora da fixture de propósito.
  • REQUIRES_NEW no serviço. A transação interna commita enquanto a do teste continua aberta, e o AFTER_COMMIT dela dispara dentro do teste A. O teste volta a ficar verde por outro motivo, e a semântica do serviço mudou só para agradar o teste. Não está na fixture.
  • Listeners @Async. Ficam fora deste artigo porque exigiriam espera no teste. O default é síncrono, e é isso que deixa o teste B determinístico.
  • Modulith e outbox não são necessários. O @ApplicationModuleListener do Spring Modulith é um @TransactionalEventListener e segue a mesma regra de commit. Um record, um serviço e um contador bastam para reproduzir tudo.

O que a DevDojo faria

A DevDojo não colocaria @Transactional num teste cujo objetivo é provar que um listener AFTER_COMMIT rodou. O sinal de alerta é concreto: o teste está verde, commitCount() está em 0 e o e-mail ou a outbox de produção nunca tiveram chance de acontecer. Nesse caso, BEFORE_COMMIT e @EventListener não substituem o "depois do sucesso". AFTER_ROLLBACK é o listener certo quando o teste ou o negócio realmente faz rollback.

O próximo passo é curto:

  1. Separe os testes de efeitos pós-commit numa classe sem @Transactional, ou exercite um caminho que realmente commita, e verifique um contador ou spy no próprio listener.
  2. Deixe @Transactional nas classes de teste que verificam o que acontece dentro da transação.
  3. Use @EventListener só quando a intenção for exatamente essa: reagir enquanto a transação ainda está aberta.

Em produção, a lógica é a mesma. O log ou a métrica que mostra que o efeito pós-commit aconteceu precisa ficar dentro do listener AFTER_COMMIT, não no serviço que publica o evento. O serviço sempre vai registrar que publicou, inclusive quando o commit nunca chega.

javaspring-boottesting

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos