skip to content

Design a reliable 'perform this background action only if the transaction commits' mechanism. Discuss the async/transaction boundary, ordering, failure modes, and delivery guarantees.

level: principalimportance: should knowfreq 25%

answer

  1. Async-in-tx = independent commit, broken
  2. Level 1: AFTER_COMMIT (+@Async, REQUIRES_NEW) = best-effort
  3. Crash-after-commit gap loses the action
  4. Level 2: outbox row in same tx + dispatcher
  5. At-least-once => idempotent consumers

basics

~20 s

Publish a domain event inside the transaction; handle it with @TransactionalEventListener(AFTER_COMMIT) so it runs only on commit. For at-least-once reliability across crashes, write an outbox row in the same transaction and have a separate dispatcher deliver it after commit.

solid answer

~50 s

The naive approach — call an `@Async` method inside the transaction — is broken: the async work runs on another thread in its own transaction and commits regardless of the outer rollback, and may race the uncommitted data. First correct level: publish an in-process event and consume it with `@TransactionalEventListener(phase = AFTER_COMMIT)`, which fires only after a successful commit; add `@Async` to move the work off the commit thread and `REQUIRES_NEW` if it writes. But this is **best-effort**: if the JVM crashes between commit and dispatch, or the async handler throws, the action is silently lost — there's no retry and the event never enters a durable store. For real guarantees use the **transactional outbox**: in the same transaction insert an `outbox` row alongside the business change (atomic), then a separate poller/CDC dispatcher reads unsent rows after commit and publishes with retries, giving **at-least-once** delivery. Consumers must be idempotent to tolerate duplicates.

code

java · 23 lines
java
// Transactional outbox: intent committed atomically with the business change
@Service
public class OrderService {
    @Transactional
    public void placeOrder(OrderReq req) {
        Order o = orderRepo.save(new Order(req));
        // same transaction: either both commit or neither
        outboxRepo.save(OutboxMessage.of("OrderPlaced", o.getId()));
    }
}

// Separate dispatcher runs after commit, retries, marks SENT (at-least-once)
@Component
public class OutboxDispatcher {
    @Scheduled(fixedDelay = 1000)
    @Transactional
    public void flush() {
        for (OutboxMessage m : outboxRepo.findTop100ByStatus(NEW)) {
            publisher.publish(m);   // idempotent consumer required
            m.markSent();
        }
    }
}

go deeper

for a junior

Out of depth; may only suggest 'do it after commit'.

for a middle

Knows the AFTER_COMMIT event pattern but not the durability limits.

for a senior

Explains best-effort vs. crash gaps and reaches for the outbox for reliability.

for a principal

Articulates atomicity/ordering/delivery trade-offs, at-least-once + idempotency, and why dual-write-to-broker is unsafe.

## Why the obvious approach fails Calling `@Async` inside a `@Transactional` method crosses a thread boundary, so the async work loses the caller's thread-bound transaction and commits independently — it survives an outer rollback and can read uncommitted/absent data. So step one is decoupling 'when it runs' from the caller's thread. ## Level 1 — In-process, best-effort: @TransactionalEventListener ```java applicationEventPublisher.publishEvent(new OrderPlaced(id)); @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) void on(OrderPlaced e) { /* re-load by id, side effect */ } ``` - **Ordering:** `AFTER_COMMIT` guarantees the listener runs only after the DB commit succeeds; a rollback → `AFTER_ROLLBACK`/nothing, so no orphan side effect. - **Threading:** `@Async` moves it off the committing thread; without `@Async` it runs synchronously post-commit and can delay the caller. - **Writes after commit** need `REQUIRES_NEW` (the original transaction is already completing). - **Failure modes (why it's only best-effort):** 1. Crash *after* commit but *before* the listener runs → lost. 2. `@Async` handoff means the exception is in another thread → no caller impact, and by default no retry. 3. If `@Async` isn't present and the listener throws in `AFTER_COMMIT`, it doesn't roll back the (already committed) business transaction — you get a committed change with a failed side effect. ## Level 2 — Durable, at-least-once: Transactional Outbox Write the *intent* to an `outbox` table **in the same transaction** as the business data: ```java @Transactional public void placeOrder(...) { orderRepo.save(order); outboxRepo.save(new OutboxMessage("OrderPlaced", order.getId(), NEW)); } ``` Because the outbox insert commits atomically with the business row, either both persist or neither does — solving the 'commit but action lost' gap. A **separate dispatcher** (scheduled poller, or CDC/Debezium tailing the WAL) reads `NEW` rows *after* commit, performs/publishes the action, marks them `SENT`, and **retries** on failure. - **Delivery guarantee:** **at-least-once** (a crash after action but before marking `SENT` replays it). - **Requirement:** **idempotent** consumers / dedup keys, since duplicates are possible. - Exactly-once end-to-end is generally impractical; aim for at-least-once + idempotency (effectively-once). ## Choosing - Same-transaction, must-be-atomic, no async needed → just do it **synchronously** in the transaction. - Fire-and-forget in-process side effect where occasional loss is tolerable → **AFTER_COMMIT (+@Async)**. - Must-not-lose, cross-service, or messaging → **outbox** (+ idempotent consumers). Avoid dual-write-to-broker-inside-tx (the broker send isn't transactional with the DB). ## Related pitfalls to preempt - Self-invocation: calling the `@Async`/`@TransactionalEventListener` method on `this` bypasses the proxy — must go through an injected bean. - Don't put the broker publish directly inside the transaction expecting rollback to un-send it — network sends aren't transactional; that's exactly what the outbox solves. - `@Async` exceptions vanish unless you use `AsyncUncaughtExceptionHandler` (void) or inspect the returned `CompletableFuture`.

  • Why not publish directly to Kafka/RabbitMQ inside the @Transactional method and rely on rollback?
    The broker send isn't enlisted in the DB transaction — you can't 'un-send' a message if the DB rolls back, and you can lose a message if the DB commits but the send fails. The outbox makes the intent atomic with the DB write and dispatches separately, which is why it exists.
  • The outbox gives at-least-once. How do you avoid double side effects?
    Make consumers idempotent: dedupe on a stable message/business key, use INSERT ... ON CONFLICT / unique constraints, or track processed message ids. You can't cheaply get exactly-once, so design for effectively-once via idempotency.
  • Does @TransactionalEventListener(AFTER_COMMIT) survive a JVM crash right after commit?
    No. The event lives only in memory; if the process dies between commit and the listener firing, the side effect is lost. That durability gap is precisely why you escalate to the outbox for must-not-lose actions.

saying these in an interview costs you the question

  • Calling @Async inside the transaction and thinking rollback will cover it
  • Publishing to a message broker inside the transaction expecting transactional rollback of the send
  • Claiming @TransactionalEventListener gives durable/at-least-once delivery
  • Assuming exactly-once is easily achievable instead of at-least-once + idempotency
  • Forgetting AFTER_COMMIT writes need REQUIRES_NEW

context