Why does an inner @Transactional(REQUIRED) method that catches nothing still poison the whole transaction when it throws?
answer
- REQUIRED = shared physical tx
- only the starter may commit/rollback
- participant can only setRollbackOnly
- flag lives on the tx, survives the catch
- globalRollbackOnParticipationFailure=true
basics
~10 sBecause with propagation REQUIRED the inner method joins the outer method's single physical transaction. A participating method can't roll back just its own part, so Spring marks the whole shared transaction rollback-only.
solid answer
~40 sPropagation REQUIRED means the inner method doesn't open a new transaction; it participates in the outer physical transaction. A database transaction is all-or-nothing — you can't commit half of it. So when the inner method throws a rollback-worthy exception, the transaction interceptor can't isolate and roll back only the inner work. Instead it flags the entire shared transaction rollback-only via setRollbackOnly() and re-throws. From then on the transaction is doomed: any later commit attempt by the outer owner triggers UnexpectedRollbackException. The rollback-only flag is Spring's way of remembering 'a participant failed' across method boundaries so the eventual commit is vetoed even if the outer code recovered. This is why swallowing the inner exception doesn't help.
code
java · 13 lines// Same physical tx: outer began it, inner participates.
@Transactional
public void outer() {
repo.save(a);
inner(); // participates in outer's tx
}
@Transactional // REQUIRED -> participates, no new physical tx, no savepoint
public void inner() {
throw new RuntimeException("boom"); // interceptor calls setRollbackOnly() on the shared tx
}
// Even if a caller of outer() swallowed a propagated exception,
// the rollback-only flag remains and commit -> UnexpectedRollbackException.go deeper
Grasp that REQUIRED means shared transaction, so an inner failure affects everything.
Explain new vs participating transaction and why only the starter can physically commit/rollback.
Reference globalRollbackOnParticipationFailure and contrast REQUIRED/REQUIRES_NEW/NESTED precisely.
Reason about savepoint availability, driver support, and when to change the transaction manager default.
## The core idea: one physical transaction, many logical participants Under default propagation `Propagation.REQUIRED`, nested `@Transactional` calls **share a single physical database transaction**. Spring models this internally with the concept of a *new* transaction (the one that actually began it) vs a *participating* (existing) transaction (a method that merely joined). Only the method that **began** the physical transaction is allowed to physically `commit()` or `rollback()` it. ## Why the inner method can't just roll back its own work A JDBC/database transaction has no partial rollback except via **savepoints**. Plain REQUIRED participation does **not** create a savepoint (that's what `Propagation.NESTED` does). So there is no boundary at which the inner method's changes can be undone independently. Given that, when the inner method throws a rollback-worthy exception, Spring's `TransactionInterceptor` → `AbstractPlatformTransactionManager` logic does the only safe thing for a participant: it calls `setRollbackOnly()` on the shared transaction object, marking it globally doomed, and re-throws the original exception. ## The rollback-only flag as cross-boundary memory The flag lives on the transaction, not on any one method. It persists even after the inner interceptor returns control to the outer method. That's the whole point: it lets Spring enforce 'once any participant demanded rollback, the physical commit must not happen' — regardless of what the outer code does afterward. Even if the outer method catches the exception and cleans up, the flag remains set, and the eventual real commit is vetoed → `UnexpectedRollbackException`. ## Default rollback rules matter The flag is only set for exceptions Spring deems rollback-worthy: by default `RuntimeException` and `Error`. Checked exceptions default to commit. You can tune this with `@Transactional(rollbackFor = ...)` / `noRollbackFor = ...`. So an inner method throwing a checked exception it declares — without `rollbackFor` — would *not* poison the transaction under defaults. ## `globalRollbackOnParticipationFailure` The transaction manager has a property `globalRollbackOnParticipationFailure` (default `true`). When true, a failed participant marks the whole transaction rollback-only — exactly the behavior described. If set to `false`, a participant failure does not necessarily doom the whole transaction, changing this behavior. It's rarely toggled but worth knowing it's configurable on `AbstractPlatformTransactionManager`. ## Contrast with the alternatives - `REQUIRES_NEW`: inner method **suspends** the outer transaction and runs its own independent physical transaction. Its rollback is local; the outer transaction is untouched and can still commit. - `NESTED`: inner method runs within a **savepoint**; its failure can roll back to the savepoint without dooming the outer transaction (JDBC savepoint support required). ## Practical takeaway Under REQUIRED, exceptions are contagious across the shared transaction. Design accordingly: propagate the exception, or isolate the failing unit with REQUIRES_NEW/NESTED, or move the transaction boundary.
- What property on the transaction manager controls whether a participant failure dooms the whole transaction?globalRollbackOnParticipationFailure on AbstractPlatformTransactionManager, default true. True means a failed participant marks the shared transaction rollback-only.
- How does NESTED differ from REQUIRED here?NESTED uses a JDBC savepoint, so the inner method's changes can roll back to that savepoint without marking the outer transaction rollback-only, avoiding the UnexpectedRollbackException.
saying these in an interview costs you the question
- Claiming each @Transactional method always gets its own transaction.
- Saying REQUIRED creates a savepoint for partial rollback (that's NESTED).
- Thinking the flag is per-method rather than on the shared transaction.