skip to content

Under REQUIRED, an inner method throws and rolls back but the outer catches the exception and continues. What happens at commit, and why?

level: seniorimportance: must knowfreq 70%

answer

  1. Inner failure -> setRollbackOnly on shared tx
  2. Flag is sticky, one-way
  3. Outer commit -> UnexpectedRollbackException
  4. Catching doesn't save you
  5. Fix: REQUIRES_NEW / NESTED / let it propagate

basics

~10 s

The 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 s

With 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
java
@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

for a junior

Aware that swallowing an exception may not save the transaction.

for a middle

Explain the rollback-only marking and that the outer commit fails.

for a senior

Name UnexpectedRollbackException, the sticky flag, unchecked-vs-checked rule, and the REQUIRES_NEW/NESTED fixes.

for a principal

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.

context