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_COMMITeAFTER_ROLLBACKsã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 teste | AFTER_COMMIT | BEFORE_COMMIT | AFTER_ROLLBACK | @EventListener |
|---|---|---|---|---|---|
| A, ainda no método | sim (rollback default) | 0 | 0 | 0 | 1 |
| A, @AfterTransaction | sim | 0 | 0 | 1 | 1 |
| B, após placeOrder | não | 1 | 1 | 0 | 1 |
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_COMMITdentro 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
@EventListenerpara 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_COMMITcomo "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 obeforeCommit: "This callback does not mean that the transaction will actually be committed." AFTER_ROLLBACKtem 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_NEWno serviço. A transação interna commita enquanto a do teste continua aberta, e oAFTER_COMMITdela 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
@ApplicationModuleListenerdo Spring Modulith é um@TransactionalEventListenere 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:
- 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. - Deixe
@Transactionalnas classes de teste que verificam o que acontece dentro da transação. - Use
@EventListenersó 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.