Definition
- @TransactionalEventListener -- a Spring annotation that defers an event handler to a specific phase of the surrounding transaction's lifecycle rather than executing it immediately.
- TransactionPhase.AFTER_COMMIT -- the handler runs only after the transaction commits successfully, making it the correct place for external side effects.
- TransactionPhase.BEFORE_COMMIT -- the handler runs just before commit while the transaction is still open, so throwing here triggers a rollback.
- TransactionPhase.AFTER_ROLLBACK -- the handler runs only if the transaction rolled back, used for failure logging and compensation.
- fallbackExecution -- when set to
true, the handler runs immediately if no active transaction exists, instead of being silently dropped.
When Rajesh transfers money to Priya at the National Bank of India, the bank publishes an internal note that says "transfer happened." With a plain @EventListener, the SMS team sends Rajesh a success text the moment the note is written, even if the transfer later fails and gets reversed. With @TransactionalEventListener set to AFTER_COMMIT, the SMS team waits until the ledger is actually stamped and final before sending the text, so the customer never gets a false confirmation.
Understanding the Concepts
What is @TransactionalEventListener?
@TransactionalEventListener is a Spring annotation that makes an event handler run at a specific phase of the surrounding transaction's lifecycle rather than immediately when the event is published. You publish a domain event using ApplicationEventPublisher.publishEvent(...) from inside a @Transactional service method, and the listener annotated with @TransactionalEventListener receives it only at the chosen phase.
The annotation accepts a phase attribute from the TransactionPhase enum, which has four values: BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, and AFTER_COMPLETION. The default phase is AFTER_COMMIT.
package com.bank.event;
import java.math.BigDecimal;
public class TransferCompletedEvent {
private final String fromAccountId;
private final String toAccountId;
private final BigDecimal amount;
public TransferCompletedEvent(String fromAccountId,
String toAccountId,
BigDecimal amount) {
this.fromAccountId = fromAccountId;
this.toAccountId = toAccountId;
this.amount = amount;
}
public String getFromAccountId() { return fromAccountId; }
public String getToAccountId() { return toAccountId; }
public BigDecimal getAmount() { return amount; }
}
package com.bank.service;
import com.bank.event.TransferCompletedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TransferService {
private final AccountRepository accountRepo;
private final ApplicationEventPublisher eventPublisher;
public TransferService(AccountRepository accountRepo,
ApplicationEventPublisher eventPublisher) {
this.accountRepo = accountRepo;
this.eventPublisher = eventPublisher;
}
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
accountRepo.debit(fromId, amount);
accountRepo.credit(toId, amount);
eventPublisher.publishEvent(
new TransferCompletedEvent(fromId, toId, amount));
}
}
Why does @TransactionalEventListener exist?
It exists to solve the false confirmation problem. When you publish an event inside a transaction and handle it with a plain @EventListener, the handler runs immediately -- before the transaction commits. If the transaction later rolls back, the side effect (SMS, email, Kafka message) has already been sent, and the customer receives a lie. @TransactionalEventListener eliminates this by deferring the handler until the transaction outcome is known.
In a banking system, sending a transfer confirmation SMS before the money is actually committed is a compliance violation. The customer sees a success message, but the ledger shows no movement. @TransactionalEventListener with AFTER_COMMIT guarantees the SMS fires only when the money has truly moved.
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.stereotype.Component;
@Component
public class TransferNotificationListener {
private final SmsGateway smsGateway;
public TransferNotificationListener(SmsGateway smsGateway) {
this.smsGateway = smsGateway;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmation(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(),
"Rs." + event.getAmount() + " transferred to "
+ event.getToAccountId() + " successfully.");
}
}
Expected behavior:
1. transfer() executes debit + credit + publishEvent
2. Transaction commits successfully
3. AFTER_COMMIT listener fires -- SMS sent to Rajesh
If transaction rolls back:
1. transfer() executes debit + credit + publishEvent
2. Transaction rolls back due to constraint violation
3. AFTER_COMMIT listener does NOT fire -- no SMS sent
How does each phase work?
The four phases map to specific moments in the transaction lifecycle.
BEFORE_COMMIT
Runs just before the commit while the transaction is still open. Work done in this handler participates in the same transaction. If the handler throws an exception, the transaction rolls back. This is the right place for a last-minute validation or an atomic audit row.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void auditBeforeCommit(TransferCompletedEvent event) {
// This INSERT commits atomically with the transfer
auditRepo.save(new AuditEntry(
event.getFromAccountId(),
event.getToAccountId(),
event.getAmount(),
"TRANSFER_INITIATED"));
}
AFTER_COMMIT
Runs only after the transaction commits successfully. The data is durable at this point. This is the safest place for external side effects: SMS, email, Kafka publish, webhook call. The handler runs on the same thread by default, so pair it with @Async to avoid blocking the request thread.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishToKafka(TransferCompletedEvent event) {
kafkaTemplate.send("transfers", event.getFromAccountId(),
event.toJson());
}
AFTER_ROLLBACK
Runs only if the transaction rolled back. Use this for failure logging, alerting, or triggering compensation.
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onTransferFailure(TransferCompletedEvent event) {
log.severe("Transfer rolled back: "
+ event.getFromAccountId() + " -> "
+ event.getToAccountId()
+ " amount: Rs." + event.getAmount());
metricsService.incrementFailedTransfers();
}
AFTER_COMPLETION
Runs after the transaction ends regardless of the outcome. Use this for cleanup that must happen whether the transfer succeeded or failed, such as evicting a cache entry.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void clearCache(TransferCompletedEvent event) {
accountCache.evict(event.getFromAccountId());
accountCache.evict(event.getToAccountId());
}
How is @TransactionalEventListener different from @EventListener?
The difference is timing and transaction awareness.
| Aspect | @EventListener | @TransactionalEventListener |
|---|---|---|
| When it runs | Immediately when publishEvent is called | At the chosen transaction phase |
| Transaction awareness | None -- runs inside the open TX | Fully aware of TX outcome |
| Risk of false side effects | High -- SMS sent before commit | None -- AFTER_COMMIT fires only on success |
| Behavior outside TX | Always runs | Silently dropped (unless fallbackExecution=true) |
| Default phase | N/A | AFTER_COMMIT |
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class PlainListenerExample {
// WRONG: runs immediately, before commit
@EventListener
public void onTransfer(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(),
"Transfer successful!"); // Lie if TX rolls back!
}
}
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.stereotype.Component;
@Component
public class SafeListenerExample {
// CORRECT: runs only after commit
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTransfer(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(),
"Transfer successful!"); // Truth -- TX committed
}
}
What does fallbackExecution do?
By default, if a @TransactionalEventListener receives an event published outside an active transaction, the handler is silently dropped -- it never executes. This surprises developers who publish an event from a non-transactional method and wonder why their notification was never sent.
Setting fallbackExecution = true tells Spring to run the handler immediately when there is no active transaction, effectively falling back to @EventListener behavior.
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.stereotype.Component;
@Component
public class FallbackListenerExample {
// Without fallbackExecution: silently dropped if no TX
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void withoutFallback(TransferCompletedEvent event) {
System.out.println("This may NEVER execute");
}
// With fallbackExecution: runs immediately if no TX
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true)
public void withFallback(TransferCompletedEvent event) {
System.out.println("This always executes");
}
}
Scenario 1: Event published inside @Transactional
withoutFallback -- runs at AFTER_COMMIT
withFallback -- runs at AFTER_COMMIT
Scenario 2: Event published without a transaction
withoutFallback -- silently dropped, never executes
withFallback -- runs immediately
What are common mistakes?
The most common mistake is writing to the database inside an AFTER_COMMIT handler without a new transaction. By the time AFTER_COMMIT runs, the original transaction is already finished, so any database write needs its own transaction.
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.stereotype.Component;
@Component
public class AfterCommitMistake {
private final NotificationLogRepository logRepo;
public AfterCommitMistake(NotificationLogRepository logRepo) {
this.logRepo = logRepo;
}
// WRONG: no active TX, save() may fail silently or throw
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void logNotificationWrong(TransferCompletedEvent event) {
logRepo.save(new NotificationLog(event.getFromAccountId(),
"SMS_SENT"));
}
// CORRECT: use REQUIRES_NEW for a fresh transaction
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logNotificationCorrect(TransferCompletedEvent event) {
logRepo.save(new NotificationLog(event.getFromAccountId(),
"SMS_SENT"));
}
}
The second mistake is blocking the request thread with a slow side effect in the listener. By default, the AFTER_COMMIT handler runs on the same thread. Add @Async to run it on a separate thread.
// WRONG: 3-second SMS call blocks the request thread
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendSmsSlow(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(), "..."); // 3 sec
}
// CORRECT: async execution
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendSmsAsync(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(), "..."); // separate thread
}
What should a senior engineer know?
A senior engineer knows that @TransactionalEventListener is built on top of the TransactionSynchronization interface. Internally, Spring registers a synchronization callback that holds the event and fires it at the chosen phase. Understanding this helps debug timing issues.
Senior engineers also know that AFTER_COMMIT listeners have at-most-once semantics: if the application crashes between commit and the listener execution, the event is lost. For exactly-once side effects, combine with the transactional outbox pattern -- write the event to an outbox table inside the main transaction, then relay it to Kafka asynchronously.
Another nuance is ordering. Multiple listeners for the same event and phase execute in undefined order unless you add @Order. In a banking system where audit must happen before notification, ordering matters.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Order(1)
public void auditFirst(TransferCompletedEvent event) {
auditService.log(event);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Order(2)
public void notifySecond(TransferCompletedEvent event) {
smsGateway.send(event.getFromAccountId(), "...");
}
What are common interview follow-up questions?
- What happens if an AFTER_COMMIT listener throws an exception? Does the transaction roll back?
- How do you guarantee exactly-once delivery when the app crashes after commit but before the listener fires?
- Can you use @TransactionalEventListener with reactive (WebFlux) transactions?
- How do you test a @TransactionalEventListener in a @SpringBootTest?
- What is the execution order when multiple listeners handle the same event at the same phase?
Visual Overview
┌───────────────────────────────────────────────────────┐
│ Transaction Timeline │
│ │
│ BEGIN ──▶ debit() ──▶ credit() ──▶ publishEvent() │
│ │ │ │
│ │ BEFORE_COMMIT phase │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ auditRepo.save(...) │ │ │
│ │ │ (still in same TX) │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │
│ COMMIT ◀────────────────────────────────── │
│ │ │
│ │ AFTER_COMMIT phase │
│ │ ┌─────────────────────────┐ │
│ │ │ smsGateway.send(...) │ │
│ │ │ kafkaTemplate.send(...) │ │
│ │ └─────────────────────────┘ │
│ │ │
│ │ AFTER_COMPLETION phase │
│ │ ┌─────────────────────────┐ │
│ │ │ cache.evict(...) │ │
│ │ └─────────────────────────┘ │
└───────────────────────────────────────────────────────┘
If TX rolls back:
BEFORE_COMMIT -- does NOT run
AFTER_COMMIT -- does NOT run
AFTER_ROLLBACK -- RUNS
AFTER_COMPLETION -- RUNS (with status = ROLLED_BACK)
Key Takeaway
The @TransactionalEventListener annotation lets an event handler run at a specific phase of the surrounding transaction instead of immediately when the event is published. The AFTER_COMMIT phase is the most important: it fires only after a successful commit, so external side effects like SMS and Kafka messages never lie to the customer. BEFORE_COMMIT fires while the transaction is still open, so work done there is atomic with the main operation. AFTER_ROLLBACK fires only on failure for logging and compensation.
The difference from a plain @EventListener is the whole point. A plain listener runs synchronously the instant publishEvent is called, before the transaction commits. In the National Bank of India transfer system, that meant Rajesh sometimes received a success SMS for a transfer that actually rolled back. Moving the SMS handler to AFTER_COMMIT eliminated false confirmations entirely, because the handler fires only when the money movement is truly durable.
The critical gotcha is fallbackExecution. By default, if the event is published when there is no active transaction, the handler is silently dropped. Setting fallbackExecution to true makes it run immediately, which is useful for notification listeners that should work regardless of transactional context. For AFTER_COMMIT handlers, always consider pairing with @Async to avoid blocking the request thread, and use REQUIRES_NEW if the handler needs to write to the database.
Remember one sentence: use AFTER_COMMIT for anything that talks to the outside world so you never lie to the customer.
---