Under REQUIRED, an inner method throws and rolls back but the outer catches the exception and continues. What happens at commit, and why?
answer
- Inner failure -> setRollbackOnly on shared tx
- Flag is sticky, one-way
- Outer commit -> UnexpectedRollbackException
- Catching doesn't save you
- Fix: REQUIRES_NEW / NESTED / let it propagate
basics
~10 sThe inner failure marks the shared transaction rollback-only. Even though the outer swallowed the exception, when it tries to commit Spring refuses and throws UnexpectedRollbackException — the whole transaction rolls back.
solid answer
~40 sWith REQUIRED there is one physical transaction shared by outer and inner. When the inner @Transactional method's participation fails (a rollback-triggering exception propagates out of it), Spring can't roll back just that part — there's a single Connection. Instead it sets the transaction's rollbackOnly flag (globalRollbackOnParticipationFailure, on by default). Control returns to the outer method; even if it catches the exception and proceeds as if nothing happened, the flag is sticky. When the outer transaction reaches its commit point, Spring's transaction manager sees rollbackOnly=true, actually rolls back, and signals the discrepancy by throwing UnexpectedRollbackException ('Transaction rolled back because it has been marked as rollback-only'). So catching the inner exception does NOT save the outer work — everything rolls back and the caller gets a surprise exception at the boundary.
code
java · 25 lines@Service
public class ReportService {
private final MetricsService metrics;
@Transactional // REQUIRED: one shared physical transaction
public void generate() {
saveReport();
try {
metrics.record(); // REQUIRED + throws RuntimeException -> marks tx rollback-only
} catch (RuntimeException swallowed) {
// Looks handled... but the shared tx is already doomed.
log.warn("metrics failed, continuing", swallowed);
}
// On return, Spring tries to commit, sees rollbackOnly=true, rolls back,
// and throws org.springframework.transaction.UnexpectedRollbackException.
}
}
// Fix: isolate the failing call so its rollback stays local
@Service
class MetricsService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record() { /* own tx + own Connection */ }
}go deeper
Aware that swallowing an exception may not save the transaction.
Explain the rollback-only marking and that the outer commit fails.
Name UnexpectedRollbackException, the sticky flag, unchecked-vs-checked rule, and the REQUIRES_NEW/NESTED fixes.
Discuss globalRollbackOnParticipationFailure, when to isolate participants, and designing failure domains so partial-failure semantics are explicit.
## The scenario ``` outer() @Transactional(REQUIRED) // begins the physical tx try { inner(); } // inner @Transactional(REQUIRED) throws RuntimeException catch (Exception e) { /* swallow, keep going */ } ... more work ... // outer reaches commit ``` Many developers expect: 'I caught the exception, so the outer commits fine.' The actual result is an `org.springframework.transaction.UnexpectedRollbackException` at the outer boundary, and **nothing commits**. ## Why — the single shared transaction REQUIRED means `inner()` **joined** `outer()`'s physical transaction; there's one Connection. When `inner()`'s scope exits with a rollback-triggering exception, Spring cannot partially roll back — there are no savepoints (that's what NESTED would add). The only way to honor 'this participant failed' on a shared transaction is to **mark the whole transaction rollback-only**. Mechanically, `AbstractPlatformTransactionManager` handles the inner participant's failed completion. With the default `globalRollbackOnParticipationFailure = true`, it calls `setRollbackOnly()` on the underlying transaction object (which sets a flag; for JTA it also propagates to the resource). The flag lives on the **physical** transaction, so it's visible to every scope sharing it. ## Why the outer still fails after catching The rollbackOnly flag is **sticky and one-way** — once set it can't be cleared. When `outer()` returns normally and Spring tries to commit, `processCommit()` checks `shouldCommitOnGlobalRollbackOnly()`; seeing rollbackOnly, it performs a **rollback** instead of a commit and then throws `UnexpectedRollbackException` to tell the caller: 'you thought you were committing, but the transaction was already doomed.' Silently swallowing would hide data loss, so Spring makes it loud. ## What actually triggers the rollback marking Same rules as any rollback: by default Spring rolls back on **unchecked exceptions** (`RuntimeException`, `Error`) and **not** on checked exceptions. So if `inner()` throws a checked exception (and no `rollbackFor` override), the transaction is **not** marked rollback-only and the outer can genuinely continue and commit. The problem case is the common one: inner throws an unchecked exception. ## Terms - **rollbackOnly flag**: a boolean on the transaction meaning 'this can only ever roll back now.' - **UnexpectedRollbackException**: thrown when a commit was requested but the transaction had been marked rollback-only. - **globalRollbackOnParticipationFailure**: `AbstractPlatformTransactionManager` property (default true) that decides whether a participant's failure dooms the shared transaction. ## Fixes / alternatives 1. **Let it propagate**: don't catch, and the outer rolls back cleanly with the original exception — usually the correct design. 2. **Isolate the failure**: annotate `inner()` with `@Transactional(propagation = Propagation.REQUIRES_NEW)` so it runs in its own physical transaction on a separate Connection; its rollback doesn't touch the outer, and catching in the outer is then valid. 3. **NESTED** (JDBC savepoints): `inner()` gets a savepoint; its failure rolls back to the savepoint only, leaving the outer intact (requires a savepoint-capable manager like `DataSourceTransactionManager`). 4. **Flip the switch (rarely)**: set `globalRollbackOnParticipationFailure=false` so a participant failure doesn't force global rollback — advanced and easy to misuse. ## Interview signal Strong candidates name `UnexpectedRollbackException` explicitly, explain the sticky rollbackOnly flag, note the unchecked-vs-checked rollback rule, and reach for REQUIRES_NEW/NESTED as the correct isolation tools rather than 'just catch the exception.'
- How do you make it safe to catch the inner failure and still commit the outer work?Give the inner method its own physical transaction with REQUIRES_NEW (separate Connection), or use NESTED (savepoint) so the inner rollback stays local and the outer stays committable.
- Does this happen if the inner method throws a checked exception?Not by default. Spring only rolls back (and marks rollback-only) on unchecked exceptions/Errors unless you add rollbackFor for the checked type.
- Which exact exception does the outer commit throw, and from where?org.springframework.transaction.UnexpectedRollbackException, thrown by the PlatformTransactionManager's commit when it finds the transaction marked rollback-only.
saying these in an interview costs you the question
- 'If the outer catches the exception, the transaction commits fine.'
- Thinking Spring rolls back only the inner method's statements under REQUIRED (that needs NESTED savepoints).
- Not knowing UnexpectedRollbackException by name.
- Believing you can clear the rollbackOnly flag once set.