@TransactionalEventListener and Post-Commit Event Processing
@TransactionalEventListener and Post-Commit Event Processing
@TransactionalEventListener binds event handling to the transaction lifecycle. Instead of firing immediately when publishEvent() is called, the listener defers execution until the transaction reaches a specified phase: before commit, after commit, after rollback, or after completion.
The primary use case: you want to trigger a side effect only when the database change is confirmed. Sending a notification for an order that rolled back is worse than sending no notification at all.
The Annotation
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}
The phase attribute defaults to AFTER_COMMIT. The annotation is a specialization of @EventListener. It supports the same features: event type matching by parameter, condition expressions, @Order for ordering among transactional listeners in the same phase.
The Mechanism: TransactionalApplicationListenerMethodAdapter
When EventListenerMethodProcessor encounters a method annotated with @TransactionalEventListener, it creates a TransactionalApplicationListenerMethodAdapter instead of the standard ApplicationListenerMethodAdapter. This adapter extends the base adapter with transaction awareness.
Registration as TransactionSynchronization
When the multicaster dispatches an event to a TransactionalApplicationListenerMethodAdapter, the adapter does not invoke the method. Instead, it checks whether a transaction is active using TransactionSynchronizationManager.isSynchronizationActive().
If a transaction is active, the adapter creates a TransactionSynchronization and registers it with TransactionSynchronizationManager.registerSynchronization(). This is the same mechanism covered in CH19. The synchronization callback holds a reference to the event and the listener method.
multicastEvent(OrderCreatedEvent)
-> TransactionalApplicationListenerMethodAdapter.onApplicationEvent(event)
-> TransactionSynchronizationManager.isSynchronizationActive()? YES
-> create TransactionSynchronization holding (event, method)
-> TransactionSynchronizationManager.registerSynchronization(sync)
-> return (method NOT invoked yet)
The method invocation is deferred. The publisher thread continues. The transaction proceeds to commit or rollback.
Phase Dispatch
The TransactionSynchronization interface provides callbacks for each transaction phase:
public interface TransactionSynchronization {
default void beforeCommit(boolean readOnly) {}
default void beforeCompletion() {}
default void afterCommit() {}
default void afterCompletion(int status) {}
}
The TransactionPhase enum maps to these callbacks:
| TransactionPhase | Callback invoked |
|---|---|
BEFORE_COMMIT | beforeCommit() |
AFTER_COMMIT | afterCommit() |
AFTER_ROLLBACK | afterCompletion(STATUS_ROLLED_BACK) |
AFTER_COMPLETION | afterCompletion(status) for any outcome |
When the transaction manager processes the commit, it invokes the synchronization callbacks in order. The registered synchronization for the transactional event listener invokes the actual method at the appropriate phase.
For AFTER_COMMIT:
TransactionManager.commit()
-> execute SQL COMMIT
-> TransactionSynchronizationUtils.invokeAfterCommit(synchronizations)
-> for each synchronization: synchronization.afterCommit()
-> invoke @TransactionalEventListener method
The critical point: the SQL commit has already succeeded when afterCommit() fires. The database change is durable. If your listener throws, the transaction is not rolled back. The data is already committed.
The Debuggable Demonstration
SaaS backend scenario: order creation triggers audit (synchronous, within transaction), then notification (after commit).
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher publisher;
@Transactional
public Order createOrder(String tenantId, CreateOrderRequest request) {
Order order = orderRepository.save(new Order(tenantId, request.items()));
log.info("Publishing OrderCreatedEvent (inside transaction)");
publisher.publishEvent(new OrderCreatedEvent(tenantId, order.getId(), order.getTotal()));
log.info("Returning from createOrder (transaction will commit after this)");
return order;
}
}
@Component
public class AuditListener {
@EventListener
@Order(1)
public void auditOrder(OrderCreatedEvent event) {
log.info("AUDIT: recording order {} (synchronous, inside transaction)", event.orderId());
auditRepository.record(event.tenantId(), "ORDER_CREATED", event.orderId());
}
}
@Component
public class NotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyOrder(OrderCreatedEvent event) {
log.info("NOTIFY: sending confirmation for order {} (after commit)", event.orderId());
notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}
}
The log output, in order:
Publishing OrderCreatedEvent (inside transaction)
AUDIT: recording order ORD-001 (synchronous, inside transaction)
Returning from createOrder (transaction will commit after this)
NOTIFY: sending confirmation for order ORD-001 (after commit)
The audit runs during publishEvent(). The notification runs after the @Transactional proxy commits. If the audit listener throws, the transaction rolls back, and the notification never fires because afterCommit() is never called.
Using AFTER_ROLLBACK
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onOrderFailed(OrderCreatedEvent event) {
log.warn("Order {} failed, notifying support", event.orderId());
alertService.notifySupport(event.tenantId(), event.orderId());
}
This fires only when the transaction rolls back. If the commit succeeds, this listener is skipped. Use AFTER_COMPLETION if you want to run regardless of outcome.
Using BEFORE_COMMIT
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void validateBeforeCommit(OrderCreatedEvent event) {
if (fraudDetector.isSuspicious(event.tenantId(), event.orderId())) {
throw new FraudDetectedException(event.orderId());
}
}
BEFORE_COMMIT runs before the SQL commit. An exception here causes the transaction to roll back. Use this for validation that must participate in the transaction outcome.
The Failure Mode
BROKEN: @TransactionalEventListener Without an Active Transaction
@Service
public class OrderImportService {
private final ApplicationEventPublisher publisher;
// BROKEN: no @Transactional annotation
public void importOrders(String tenantId, List<ImportedOrder> orders) {
for (ImportedOrder imported : orders) {
Order order = convertAndSave(tenantId, imported);
publisher.publishEvent(new OrderCreatedEvent(tenantId, order.getId(), order.getTotal()));
// NotificationListener.notifyOrder() NEVER FIRES
}
}
}
TransactionalApplicationListenerMethodAdapter.onApplicationEvent() checks TransactionSynchronizationManager.isSynchronizationActive(). It returns false. No transaction is active. The adapter checks this.annotation.fallbackExecution(). The default is false. The adapter returns without invoking the method and without logging a warning.
The event is silently dropped. No error in the logs. No exception. The notification is simply never sent. In a multi-tenant SaaS backend, this means some tenants receive order confirmations and others do not, depending on which code path created the order.
BROKEN: Modifying Database After Commit
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateOrderStatus(OrderCreatedEvent event) {
// BROKEN: no transaction is active in AFTER_COMMIT phase
orderRepository.updateStatus(event.orderId(), "CONFIRMED");
// This either fails (if JPA requires a transaction) or runs without
// transactional guarantees (if using plain JDBC)
}
In the AFTER_COMMIT phase, the original transaction has completed. There is no active transaction. If your listener performs database writes, those writes are not part of any transaction. With JPA, TransactionRequiredException is thrown. With plain JDBC or JdbcTemplate, the write proceeds without transactional guarantees.
If you need database writes in an after-commit listener, start a new transaction:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void updateOrderStatus(OrderCreatedEvent event) {
transactionTemplate.executeWithoutResult(status -> {
orderRepository.updateStatus(event.orderId(), "CONFIRMED");
});
}
Or use @Transactional(propagation = Propagation.REQUIRES_NEW) on a separate bean method called from the listener.
The Correct Pattern
CORRECT: fallbackExecution = true
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true
)
public void notifyOrder(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}
With fallbackExecution = true, the adapter behaves differently when no transaction is active: it invokes the method immediately, synchronously, on the publisher’s thread. The event is not lost.
The logic:
- Transaction active: register synchronization, fire after commit.
- No transaction active and
fallbackExecution = true: invoke immediately. - No transaction active and
fallbackExecution = false(default): discard silently.
For non-critical side effects like notifications, fallbackExecution = true is the safe default. You trade “always after commit” for “always executes.” In most SaaS backends, sending a notification when there was no transaction is better than never sending it.
CORRECT: @Async + @TransactionalEventListener
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notifyOrderAsync(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}
This is the production pattern for post-commit side effects that should not block the caller:
- The event is published inside a
@Transactionalmethod. - The adapter registers a
TransactionSynchronization. - The transaction commits.
- The
afterCommit()callback fires. - Because of
@Async, the actual method invocation is submitted to aTaskExecutor. - The commit callback returns immediately.
- The notification runs on a pooled thread.
The caller does not wait for the notification. The notification only fires after the commit. Failures in the notification do not affect the caller or the transaction.
Ensure you have @EnableAsync and a configured ThreadPoolTaskExecutor. Without a configured executor, each async listener creates a new thread.
CORRECT: Per-Phase Listeners for Complete Coverage
@Component
public class OrderLifecycleListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.tenantId(), event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onRollback(OrderCreatedEvent event) {
log.warn("Order {} creation rolled back for tenant {}", event.orderId(), event.tenantId());
alertService.notifyRollback(event.tenantId(), event.orderId());
}
}
Separate listeners for commit and rollback give you complete coverage of transaction outcomes. The AFTER_COMMIT handler sends the confirmation. The AFTER_ROLLBACK handler alerts the operations team. Neither fires during the other’s phase.
Debugging Tips
Set a breakpoint in TransactionalApplicationListenerMethodAdapter.onApplicationEvent(). Check the TransactionSynchronizationManager.isSynchronizationActive() return value. If it returns false and fallbackExecution is false, the method silently skips. This is the first thing to check when a transactional listener does not fire.
Enable TRACE logging for org.springframework.transaction.support.TransactionSynchronizationManager to see synchronization registration and callback invocation in the logs.
To verify the phase timing, add logging before and after the publishEvent() call and in the listener. The AFTER_COMMIT listener log should appear after the “returning from service method” log, confirming that the proxy committed the transaction between the two.