Explain how @TransactionalEventListener with TransactionPhase.AFTER_COMMIT works, and the gotchas around it (default phase, no active transaction, doing DB writes in the listener).
answer
- AFTER_COMMIT is the default phase
- no tx ⇒ dropped unless fallbackExecution=true
- listener write needs REQUIRES_NEW
- @Async to go off-thread
- @ApplicationModuleListener bundles all three
basics
~20 sYou publish an event inside the transaction; a method annotated @TransactionalEventListener runs it after the transaction commits (AFTER_COMMIT is the default phase). Gotchas: if there is no active transaction the event is dropped unless fallbackExecution=true, and DB writes in the listener need a new transaction.
solid answer
~50 sInside a @Transactional method you call ApplicationEventPublisher.publishEvent(...). A listener annotated @TransactionalEventListener registers a transaction synchronization instead of firing immediately, and Spring invokes it in the chosen phase — AFTER_COMMIT by default (others: BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION). Key gotchas: (1) it requires an active transaction bound to the publisher's thread; with no transaction the event is silently discarded unless you set fallbackExecution = true. (2) By AFTER_COMMIT the transaction is already committed and being cleaned up, so a plain JPA write in the listener won't be flushed in a useful transaction — annotate the listener with @Transactional(propagation = REQUIRES_NEW) to persist. (3) It runs synchronously on the same thread by default; add @Async to offload. (4) Exceptions thrown after commit cannot roll anything back — the data stays committed. Spring Modulith's @ApplicationModuleListener bundles AFTER_COMMIT + @Async + REQUIRES_NEW.
code
java · 25 linespublic record OrderPlaced(Long orderId) {}
@Service
class OrderService {
private final OrderRepository repo;
private final ApplicationEventPublisher publisher;
OrderService(OrderRepository r, ApplicationEventPublisher p) { repo = r; publisher = p; }
@Transactional
public void place(Order o) {
repo.save(o);
publisher.publishEvent(new OrderPlaced(o.getId())); // queued, not fired yet
}
}
@Component
class OrderNotifier {
// phase defaults to AFTER_COMMIT; REQUIRES_NEW so the audit write persists
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onPlaced(OrderPlaced e) {
emailService.sendConfirmation(e.orderId());
auditRepo.save(new AuditEntry(e.orderId(), "CONFIRMATION_SENT"));
}
}go deeper
Can state that a listener runs after commit; may not know the phases or the no-transaction drop.
Knows the default phase, the silent-drop gotcha, and REQUIRES_NEW for writes — the sweet spot for this question.
Adds @Async trade-offs, exception-after-commit semantics, and maps to @ApplicationModuleListener.
Connects the synchronous-vs-async choice and the exception gap to overall delivery guarantees and outbox design.
## What it is `@TransactionalEventListener` is a specialization of `@EventListener` that ties the listener's invocation to the **lifecycle of the current transaction** rather than to the moment `publishEvent` is called. Normal `@EventListener` runs **synchronously and immediately** when the event is published — still inside the transaction. `@TransactionalEventListener` instead **registers a `TransactionSynchronization`** and defers the callback to a chosen transaction **phase**. ## The phases (`TransactionPhase`) - `BEFORE_COMMIT` — just before the commit is issued (data not yet durable). - `AFTER_COMMIT` — **the default** — after a successful commit; data is durable. This is the phase for deferred side effects. - `AFTER_ROLLBACK` — after a rollback. - `AFTER_COMPLETION` — after commit *or* rollback (you inspect the status). So `@TransactionalEventListener` with no `phase` argument already means AFTER_COMMIT. ## How publishing works ```java @Transactional public void place(Order o) { repo.save(o); publisher.publishEvent(new OrderPlaced(o.getId())); } ``` When `publishEvent` runs, Spring sees a transactional listener exists and an active transaction, so it **queues** the callback via `TransactionSynchronizationManager` rather than invoking it. On successful commit, the `afterCommit` synchronization fires and the listener runs. ## Gotcha 1 — no active transaction ⇒ event dropped If `publishEvent` is called with **no** transaction bound to the thread, a transactional listener will **not** run at all by default — the event is silently discarded. To make it run anyway (executing immediately, with no transactional semantics), set: ```java @TransactionalEventListener(fallbackExecution = true) ``` This silent-drop behavior surprises people who publish from a non-transactional path. ## Gotcha 2 — DB writes in an AFTER_COMMIT listener By AFTER_COMMIT the original transaction is **already committed and winding down**. If your listener does `repo.save(...)` on JPA, there is no active transaction to flush it into a sane unit of work. You must open a **new** transaction: ```java @TransactionalEventListener @Transactional(propagation = Propagation.REQUIRES_NEW) public void onPlaced(OrderPlaced e) { auditRepo.save(...); } ``` ## Gotcha 3 — synchronous by default The listener runs on the **same thread** that committed, **blocking** the caller until it finishes. For slow I/O (email, HTTP) add `@Async` (requires `@EnableAsync`) so it runs on a separate thread — but then you lose the caller's thread-locals and any exception is invisible to the caller. ## Gotcha 4 — exceptions can't undo the commit An exception in an AFTER_COMMIT listener happens **after** the data is durable. It will **not** roll back the transaction (it's gone). At best it can be caught/logged/retried. This is exactly the delivery gap that motivates the outbox pattern. ## Spring Modulith shortcut `@ApplicationModuleListener` is a meta-annotation combining `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async` + `@Transactional(propagation = REQUIRES_NEW)` — the canonical 'do this side effect reliably after commit, off-thread, in its own transaction' bundle. ## When to use vs plain @EventListener Use `@TransactionalEventListener` AFTER_COMMIT when the handler must only act on **committed** data. Use plain `@EventListener` when the work must be **atomic with** the publishing transaction (rolls back together).
- You publish an event from a method that has no @Transactional and the AFTER_COMMIT listener never runs. Why, and how do you fix it?A transactional listener needs an active transaction to bind its synchronization to. With none, the event is silently dropped. Fix by ensuring the publisher runs in a transaction, or set fallbackExecution = true to run it immediately outside any transaction.
- Why might a repository.save() inside an AFTER_COMMIT listener appear to do nothing?The original transaction is already committed, so there's no active transaction/unit of work to flush into. Annotate the listener with @Transactional(propagation = REQUIRES_NEW) to run the write in a fresh transaction.
saying these in an interview costs you the question
- Thinking you must pass phase = AFTER_COMMIT explicitly — it's already the default.
- Assuming an event published without a transaction still triggers the transactional listener.
- Expecting JPA writes in the listener to persist without a new transaction.
- Believing an exception in the listener rolls back the committed data.