skip to content

Explain how @TransactionalEventListener with TransactionPhase.AFTER_COMMIT works, and the gotchas around it (default phase, no active transaction, doing DB writes in the listener).

level: middleimportance: must knowfreq 65%

answer

  1. AFTER_COMMIT is the default phase
  2. no tx ⇒ dropped unless fallbackExecution=true
  3. listener write needs REQUIRES_NEW
  4. @Async to go off-thread
  5. @ApplicationModuleListener bundles all three

basics

~20 s

You 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 s

Inside 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 lines
java
public 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

for a junior

Can state that a listener runs after commit; may not know the phases or the no-transaction drop.

for a middle

Knows the default phase, the silent-drop gotcha, and REQUIRES_NEW for writes — the sweet spot for this question.

for a senior

Adds @Async trade-offs, exception-after-commit semantics, and maps to @ApplicationModuleListener.

for a principal

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.

context