When designing transaction boundaries, how do you decide between REQUIRES_NEW, NESTED, and letting exceptions propagate to avoid UnexpectedRollbackException — and what are the correctness trade-offs?
answer
- boundary must match unit of atomicity
- propagate = one fate; don't swallow
- REQUIRES_NEW = independent commit, 2 connections, no isolation coherence
- NESTED = savepoint, atomic commit, not JPA
- surface root cause not UnexpectedRollbackException
basics
~20 sChoose by atomicity needs: propagate the exception when the whole unit must be all-or-nothing; use REQUIRES_NEW when a sub-operation must commit or fail independently; use NESTED for per-item savepoint rollback within one atomic outer commit. Each has isolation and resource trade-offs.
solid answer
~50 sUnexpectedRollbackException is really a symptom of a mismatched transaction boundary. The design question is: what is the unit of atomicity? If the caller's work must succeed or fail together with the callee, let the exception propagate and roll everything back — don't swallow. If a sub-operation (audit log, outbox write, independent side effect) must persist regardless of the caller's fate, use REQUIRES_NEW, which suspends the outer transaction and runs an independent physical transaction with its own commit; the cost is a second connection held during suspension and loss of read-your-writes/isolation between the two. If you want one atomic commit but tolerate individual failures within it, use NESTED savepoints — but only with a savepoint-capable DataSourceTransactionManager, not JPA. The default REQUIRED is right when everything should share one fate. Anti-pattern: catching inner exceptions to 'recover' while staying in the same physical transaction.
code
java · 21 lines@Service
class PaymentService {
private final AuditService audit;
PaymentService(AuditService audit) { this.audit = audit; }
@Transactional // REQUIRED: charge + ledger share one fate
public void charge(Payment p) {
ledger.debit(p); // if this fails, everything rolls back
audit.recordAttempt(p); // must persist even if charge later fails
gateway.capture(p); // may throw -> whole REQUIRED tx rolls back cleanly
}
}
@Service
class AuditService {
// Independent side effect: survives a rollback of the caller's tx
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(Payment p) { auditRepo.save(new AuditRow(p)); }
}
// charge() does NOT catch gateway failures -> caller sees the real cause,
// not UnexpectedRollbackException; audit row still committed independently.go deeper
Out of scope; know the exception exists.
Know REQUIRES_NEW isolates a sub-operation; NESTED uses savepoints.
Compare the three options and their proxy/DB constraints in code.
Derive the boundary from the atomicity requirement, reason about isolation coherence, connection/pool and deadlock risk, root-cause observability, and distributed-consistency limits.
## Reframe: the exception is a boundary smell `UnexpectedRollbackException` almost always means the code's control flow (catch-and-continue) contradicts the declared transaction boundary (one shared physical transaction). The principal-level answer isn't a code trick — it's choosing the transaction boundary to match the **unit of atomicity** the business requires. ## Decision framework **1. All-or-nothing unit → propagate, share one REQUIRED transaction.** If the outer operation is meaningless when a sub-step fails (e.g. place order requires inventory reservation), the correct behavior is to roll the whole thing back. Then *don't catch* the inner exception — let it propagate out of the outer boundary so Spring performs a single clean rollback and the caller sees the real cause, not `UnexpectedRollbackException`. Swallowing here is a correctness bug. **2. Independent side effect that must persist → REQUIRES_NEW.** Audit trails, outbox/event records, 'best-effort' logging, or a counter that must advance even if the main work rolls back. `Propagation.REQUIRES_NEW`: - **Suspends** the current transaction (its connection stays open, uncommitted) and starts a new independent physical transaction on a **separate connection**. - Commits/rolls back on its own; failure does **not** mark the outer transaction rollback-only. - Trade-offs: (a) two connections held simultaneously → pool pressure and deadlock risk if the inner tx needs a row the outer tx locked (self-deadlock across the two connections); (b) **no isolation coherence** — the inner tx cannot see the outer's uncommitted writes (unless read-uncommitted), and if the outer later rolls back you've committed a side effect referencing data that no longer exists (dangling reference / consistency hazard). Use for genuinely independent effects only. **3. One atomic commit, per-item tolerance → NESTED.** `Propagation.NESTED` runs within the outer transaction using a **JDBC savepoint**. A sub-failure rolls back to the savepoint; the outer transaction survives and everything commits together at the end. Semantics differ from REQUIRES_NEW: with NESTED there's still **one** commit (atomic batch), whereas REQUIRES_NEW yields **independent** commits. Constraints: requires `DataSourceTransactionManager` (or another manager where `isNestedTransactionAllowed()` is true) and a savepoint-capable driver; **JPA's `JpaTransactionManager` does not support NESTED**. Also, savepoint semantics with an ORM's write-behind flush can be subtle (must flush at savepoint boundaries). **4. Tune rollback rules deliberately.** `@Transactional(rollbackFor=..., noRollbackFor=...)` changes which exceptions mark rollback-only. Using `noRollbackFor` on an expected, recoverable exception can let a REQUIRED participant fail without poisoning the transaction — a valid tool when the failure is truly non-fatal. Document intent; silent rules confuse future readers. **5. globalRollbackOnParticipationFailure.** On `AbstractPlatformTransactionManager`, default `true`: a participant failure marks the whole tx rollback-only (producing the exception). Setting it `false` allows a participant to fail without dooming the shared transaction, but you lose the safety guarantee that a detected failure prevents commit. Rarely appropriate; prefer explicit boundaries over flipping this global. ## Observability and API contract At scale, decide what the caller/API should observe. Prefer surfacing the **original** failure (the inventory error), not the generic `UnexpectedRollbackException`, which hides root cause. That argues for either propagating cleanly or isolating with REQUIRES_NEW so the outer path never commits a poisoned transaction. Add metrics/log correlation so poisoned-transaction incidents are visible. ## Distributed / multi-datasource caveat REQUIRES_NEW gives independent *local* commits, not distributed atomicity. If two independent commits must be consistent, you need an outbox/saga pattern, not just propagation tuning — a common principal-level clarification. ## Summary table - REQUIRED (default): shared fate, one commit. Right for cohesive units. Don't swallow. - REQUIRES_NEW: independent commit, separate connection, no isolation coherence. Right for independent side effects. - NESTED: savepoint within one atomic commit; not for JPA. Right for per-item tolerance in an atomic batch. - rollbackFor/noRollbackFor: adjust which exceptions poison the tx. The throughline: pick the boundary from the atomicity requirement first; the propagation setting follows.
- A downstream sub-operation uses REQUIRES_NEW and commits, but the outer transaction then rolls back. What consistency hazard results?A dangling/independent commit: the side effect is persisted referencing data the outer transaction never committed (or later rolled back). You can get audit/outbox rows pointing at non-existent entities. Design the independent commit to be self-consistent or use an outbox/saga.
- Why might propagating the exception be a better fix than isolating with REQUIRES_NEW?If the operation is truly all-or-nothing, propagation gives a single clean rollback and surfaces the real root cause to the caller. REQUIRES_NEW would wrongly persist a sub-step of a failed operation, violating atomicity.
- How does read-your-writes visibility differ under REQUIRES_NEW?The new transaction runs on a separate connection and cannot see the outer transaction's uncommitted writes (under standard isolation). If the inner logic needs the outer's pending data, REQUIRES_NEW is wrong — it will read stale/absent rows.
saying these in an interview costs you the question
- Reaching for REQUIRES_NEW everywhere to 'silence' the exception, ignoring atomicity semantics.
- Using NESTED with the JPA transaction manager.
- Assuming REQUIRES_NEW gives distributed atomicity across independent commits.
- Letting callers see UnexpectedRollbackException instead of the underlying root cause.
- Flipping globalRollbackOnParticipationFailure to false as a general fix.