skip to content

Why do database writes performed inside an AFTER_COMMIT listener often get silently lost, and how do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. tx already committed at AFTER_COMMIT
  2. no active tx to flush new writes
  3. silent loss, no exception
  4. fix = REQUIRES_NEW separate bean
  5. atomic instead? use BEFORE_COMMIT

basics

~10 s

The original transaction is already committed when AFTER_COMMIT runs, so there's no active writable transaction. Persistence there isn't flushed/committed. Fix it by starting a new transaction with @Transactional(propagation = REQUIRES_NEW).

solid answer

~40 s

AFTER_COMMIT fires after the publishing transaction has committed, during the afterCompletion callback. At that point the original transaction is finished — the persistence context is bound but effectively read-only/closing, and there is no active transaction that will flush and commit new writes. So JPA saves you issue there are frequently not persisted: Spring won't start a new commit for you, and the changes are discarded when the context closes. The fix is to make the handler run in its own transaction, typically by putting the DB work in a separate bean method annotated @Transactional(propagation = REQUIRES_NEW), or annotating the listener method itself so a fresh transaction wraps it. This is exactly why AFTER_COMMIT suits external side effects (messaging, email) rather than same-aggregate persistence — for that, BEFORE_COMMIT keeps writes atomic with the original commit.

code

java · 18 lines
java
@Component
class PostCommitAudit {

    private final AuditWriter writer;

    @TransactionalEventListener // AFTER_COMMIT
    public void onOrder(OrderPlacedEvent e) {
        writer.write(e); // delegate to a bean with its own tx
    }
}

@Service
class AuditWriter {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void write(OrderPlacedEvent e) {
        auditRepo.save(new AuditRecord(e.orderId())); // committed in a fresh tx
    }
}

go deeper

for a junior

Aware that AFTER_COMMIT runs after the transaction ended.

for a middle

Knows saves may be lost and that REQUIRES_NEW is the fix.

for a senior

Explains the afterCompletion mechanics and proxy self-invocation caveat.

for a principal

Weighs AFTER_COMMIT+REQUIRES_NEW vs BEFORE_COMMIT vs outbox pattern for durability and consistency.

## The problem With `@TransactionalEventListener` at the default `AFTER_COMMIT` phase, the handler runs from within Spring's `TransactionSynchronization.afterCompletion(STATUS_COMMITTED)` callback. By contract, **the transaction has already committed and is completing**. Two consequences: 1. **No active transaction to commit new work.** If your handler calls a JPA repository `save()`, Hibernate may attach the entity to the still-bound persistence context, but there is **no ongoing transaction that will flush and commit** it. When the synchronization/context tears down, the changes are dropped — a **silent** data loss (no exception). 2. **Cannot roll back the original transaction.** The commit already happened; throwing here just gets logged. ## Why Spring behaves this way after-completion callbacks are meant for **reacting** to the outcome, not extending the transaction. Spring intentionally does not open a new transaction for you. ## Fixes ### 1. REQUIRES_NEW on a separate bean Move the persistence into another Spring bean method annotated `@Transactional(propagation = Propagation.REQUIRES_NEW)`. Because propagation is proxy-based, the call must cross a bean boundary (self-invocation won't trigger the proxy). ```java @TransactionalEventListener public void onCommitted(OrderEvent e) { followUpService.record(e); // separate bean } @Service class FollowUpService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(OrderEvent e) { auditRepository.save(new Audit(e)); // now committed in its own tx } } ``` ### 2. Annotate the listener method itself You can put `@Transactional(propagation = REQUIRES_NEW)` on the listener method, but be careful: it must be a proxied invocation and you must understand that this opens/commits a brand-new transaction after the first one already committed (so it's not atomic with the original). ### 3. Use BEFORE_COMMIT instead If the extra persistence must be **atomic** with the original commit (all-or-nothing), don't use AFTER_COMMIT at all — use `phase = BEFORE_COMMIT`, where you're still inside the original transaction and writes join the same commit. ## Rule of thumb - Need durability of side effect **only if** data was saved, and side effect is **external** (email, MQ) → AFTER_COMMIT. - Need side effect persisted to the **same DB** and atomic → BEFORE_COMMIT. - Need side effect persisted to the DB but **independently** of the original outcome timing → AFTER_COMMIT + REQUIRES_NEW. ## Related gotcha Combining AFTER_COMMIT with async messaging is the basis of the transactional outbox pattern; but note AFTER_COMMIT itself gives no retry/durability guarantee if the process crashes between commit and side effect — the outbox table pattern (write to DB in the same tx, drain later) closes that gap.

  • Why doesn't self-invoking a @Transactional(REQUIRES_NEW) method in the same bean work?
    Spring's declarative transactions are proxy-based; a self-invocation bypasses the proxy, so no new transaction is started. The call must go through another bean (or the injected self-proxy).
  • If atomicity with the original write is required, which phase should you use instead?
    BEFORE_COMMIT — the listener runs inside the original transaction so its writes commit or roll back together with it.

saying these in an interview costs you the question

  • Assuming a repository save in AFTER_COMMIT just works
  • Expecting an exception when the write is lost (it's silent)
  • Using self-invocation for REQUIRES_NEW and expecting a new tx

context