Your @Transactional service saves an entity, kicks off an @Async task that inserts an audit row, then throws and rolls back. Is the audit row rolled back? Walk through what happens.
answer
- Outer rollback = only its own thread's tx
- Async commits transaction B independently
- Race: async may read uncommitted/absent row
- Fix: @TransactionalEventListener AFTER_COMMIT
- Pass IDs, re-fetch; or do it synchronously
basics
~20 sNo, the audit row is not rolled back. The async task ran on another thread in its own transaction and committed independently, so the outer rollback (which only affects the caller's thread/transaction) leaves it in the database.
solid answer
~50 sThe audit row survives. The outer method's `@Transactional` covers only the work done on its own thread; its rollback undoes the entity save. The `@Async` audit task ran on a `TaskExecutor` pool thread with no inherited transaction, so it opened and committed its own independent transaction. That commit likely happens *before* the outer method even throws, and even if not, the two transactions are unrelated — the outer rollback has no reach into the async one. Worse, there's a race: the async thread might try to read the not-yet-committed entity and find nothing, or hit `LazyInitializationException` if handed the entity. The fix is to stop relying on the outer transaction: publish a domain event and consume it with `@TransactionalEventListener(phase = AFTER_COMMIT)`, so the async work only runs if and after the outer transaction commits, and pass an ID it re-loads itself.
code
java · 23 lines// PROBLEM
@Transactional
public void placeOrder(OrderReq req) {
Order o = orderRepo.save(new Order(req)); // tx A (uncommitted)
auditService.recordAudit(o.getId()); // @Async -> tx B commits alone
validate(o); // may throw -> A rolls back, B already committed => orphan audit
}
// FIX: run the async work only after commit, pass an id, re-load it
@Transactional
public void placeOrder(OrderReq req) {
Order o = orderRepo.save(new Order(req));
events.publishEvent(new OrderPlaced(o.getId()));
validate(o);
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced e) {
Order o = orderRepo.findById(e.orderId()).orElseThrow(); // committed & visible
auditRepo.save(new AuditEntry(o));
}go deeper
Recognizes the audit row survives the rollback.
Explains independent tx B plus the visibility race, and names the AFTER_COMMIT fix.
Knows AFTER_COMMIT writes need REQUIRES_NEW and when to choose synchronous/outbox instead.
Weighs atomicity vs. async and reaches for the outbox pattern for guaranteed follow-up.
## Step-by-step 1. `placeOrder()` (outer, `@Transactional`) opens **transaction A**, binds connection A to the current thread, saves the order (uncommitted, inside A). 2. It calls the `@Async` `recordAudit()`. Spring's async proxy immediately **returns** and submits the task to a `TaskExecutor`. The audit code now runs on **pool thread T2**. 3. T2 has an empty `TransactionSynchronizationManager` → no transaction A. Because `recordAudit()` is `@Transactional` (`REQUIRED`), T2 opens **transaction B** with its own connection, inserts the audit row, and commits B. 4. Back on the caller's thread, the outer method throws → transaction A rolls back → the order save is undone. 5. **Result:** order gone, audit row present. The two are inconsistent. ## Two distinct failure modes to call out - **Independent commit / no shared rollback** (the main point): async work isn't governed by the outer transaction. - **Visibility race**: the async task starts running *concurrently* while transaction A is still open. If the audit logic tries to `findById` the order using the ID, it may see nothing (A hasn't committed) or read a stale value. Passing the managed entity across the boundary can trigger `LazyInitializationException` because A's persistence context is on the caller's thread. ## Correct patterns ### 1. Fire only after commit Publish an event inside the transaction and handle it after commit: ```java applicationEventPublisher.publishEvent(new OrderPlaced(order.getId())); @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async public void onOrderPlaced(OrderPlaced e) { /* re-load by id, do async work */ } ``` The listener runs only if transaction A **commits**; if it rolls back, `AFTER_COMMIT` never fires — no orphan audit row. Making the listener `@Async` moves the actual work off the commit thread. ### 2. Pass IDs, re-fetch in the async transaction Never hand a managed entity to the async thread; pass the primary key and let the async method load it in its own transaction (which now sees the committed data). ### 3. If you genuinely need atomicity Don't make it async at all — do the audit synchronously within transaction A, or use a transactional outbox so the follow-up is committed atomically with the business data and dispatched later. ## Gotcha: AFTER_COMMIT + writes A plain `@TransactionalEventListener(AFTER_COMMIT)` handler runs after commit but the original transaction is finishing; DB writes there need a new transaction (`@Transactional(propagation = REQUIRES_NEW)` or, as above, `@Async` + its own `@Transactional`). Otherwise the writes may silently not be committed.
- You switched to @TransactionalEventListener(AFTER_COMMIT) but your DB write inside it isn't persisted. Why?After AFTER_COMMIT the original transaction is already completing, so there's no active transaction to write in. You need a new one — @Transactional(propagation = REQUIRES_NEW) (or run the listener @Async so its own @Transactional opens a fresh transaction).
- The interviewer wants the audit to be truly atomic with the order. What do you do?Don't make it async — perform the audit insert synchronously inside the same transaction, or use a transactional outbox: write an outbox row in the same transaction and have a separate dispatcher pick it up after commit for the async processing.
saying these in an interview costs you the question
- Saying the outer rollback will clean up the audit row
- Reading the entity by id in the async task without realizing it may not be committed yet
- Passing a managed JPA entity to the async thread
- Adding @Transactional to an AFTER_COMMIT handler without REQUIRES_NEW and expecting writes to persist