Back to Rollback Rules & Pitfalls
Q2Question 2 of 5

What is UnexpectedRollbackException?

Key Concepts
  • REQUIRED propagation means inner and outer methods share one physical transaction
  • When the inner method throws RuntimeException, the proxy marks the shared physical TX as rollback-only
  • The rollback-only flag cannot be cleared once set -- the transaction is doomed
  • If the outer method catches the exception and tries to commit, Spring throws UnexpectedRollbackException
  • The distinction is logical vs physical scope: each method has a logical TX, but they share one physical TX
  • Fix: use REQUIRES_NEW on the inner method so it runs in its own physical transaction
  • Alternative fix: do not make the inner method @Transactional if it does not need its own TX behavior

Definition

UnexpectedRollbackException occurs when an inner REQUIRED method marks the shared physical transaction as rollback-only, and the outer method catches the exception and tries to commit -- the commit fails because the transaction is already doomed.
  • Logical transaction -- each @Transactional method has its own logical transaction scope. It thinks it has its own transaction, but with REQUIRED propagation, it shares the physical one.
  • Physical transaction -- the actual database transaction backed by a single JDBC connection. With REQUIRED, all methods in the call chain share one physical transaction.
  • Rollback-only flag -- when the inner method throws a RuntimeException, the proxy calls setRollbackOnly() on the shared physical transaction. This flag cannot be cleared.
  • UnexpectedRollbackException -- when the outer method catches the exception and its proxy tries to commit, Spring sees the rollback-only flag, rolls back, and throws this exception to signal that the requested commit was refused.
  • Fix with REQUIRES_NEW -- give the inner method its own physical transaction so its failure does not poison the outer one.

At the National Bank of India, two officers share one vault session (physical transaction). The junior officer (inner method) makes a mistake and stamps the vault log "CANCEL ALL" (rollback-only). The senior officer (outer method) catches the mistake and tries to continue, but when they try to close the vault (commit), the vault refuses because the log says "CANCEL ALL." The senior is surprised: the error was caught, but the vault session was already poisoned.

Understanding the Concepts

What is UnexpectedRollbackException?

Spring distinguishes between a logical transaction and the physical transaction underneath it. With REQUIRED propagation (the default), an inner @Transactional method does not start a new physical transaction. It joins the outer one. Each method has its own logical scope, but they share a single physical transaction and a single database connection.

When the inner method throws a RuntimeException, the proxy around it marks the shared physical transaction as rollback-only by calling setRollbackOnly(). That flag cannot be cleared. Even if the outer method catches the exception and thinks it has recovered, the physical transaction is doomed.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class BatchTransferService {

    private static final Logger LOG = Logger.getLogger(
        BatchTransferService.class.getName());
    private final AccountRepository accountRepository;
    private final ValidationService validationService;

    public BatchTransferService(AccountRepository accountRepository,
                                 ValidationService validationService) {
        this.accountRepository = accountRepository;
        this.validationService = validationService;
    }

    @Transactional // outer: starts physical TX-1
    public void transferWithValidation(String fromId, String toId,
                                        BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        try {
            // inner: joins TX-1 (REQUIRED)
            validationService.validate(fromId, toId, amount);
        } catch (Exception e) {
            LOG.warning("Validation failed, continuing: " + e.getMessage());
            // Outer thinks it recovered, but TX-1 is marked rollback-only!
        }
        // Proxy tries to COMMIT TX-1
        // -> sees rollback-only flag
        // -> ROLLBACK
        // -> throws UnexpectedRollbackException
    }
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class ValidationService {

    @Transactional // REQUIRED: joins the caller's TX
    public void validate(String fromId, String toId, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            // RuntimeException -> proxy marks TX as rollback-only
            throw new ComplianceViolationException(
                "Transfer exceeds Rs.10,00,000 limit");
        }
    }
}
TEXT
Execution flow:
  1. transferWithValidation() starts physical TX-1
  2. debit SAV2001, credit CUR3001 (in TX-1)
  3. validationService.validate() joins TX-1
  4. validate() throws ComplianceViolationException
  5. Proxy around validate() catches exception
     -> calls TX-1.setRollbackOnly()
     -> re-throws exception to outer method
  6. Outer method catches exception in try-catch
  7. Outer method returns normally
  8. Proxy around outer method tries to COMMIT TX-1
  9. Spring sees rollback-only flag -> ROLLBACK
  10. Spring throws UnexpectedRollbackException

  Result: debit and credit both undone. Transfer failed.
  The outer method thought it recovered, but the TX was doomed.

Why can the rollback-only flag not be cleared?

The rollback-only flag is set on the physical transaction, which is shared between all REQUIRED participants. Once set, it means "something went irreparably wrong in this transaction, and the only safe action is to roll everything back."

The Spring framework intentionally does not allow clearing this flag because partial recovery in a shared transaction is dangerous. The inner method may have left the persistence context in an inconsistent state. Allowing the outer method to commit would commit that inconsistency.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class DebugTransferService {

    private static final Logger LOG = Logger.getLogger(
        DebugTransferService.class.getName());
    private final AccountRepository accountRepository;
    private final ValidationService validationService;

    public DebugTransferService(AccountRepository accountRepository,
                                 ValidationService validationService) {
        this.accountRepository = accountRepository;
        this.validationService = validationService;
    }

    @Transactional
    public void transferWithDebug(String fromId, String toId,
                                   BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));

        try {
            validationService.validate(fromId, toId, amount);
        } catch (Exception e) {
            // Check the rollback-only flag
            boolean isRollbackOnly = TransactionSynchronizationManager
                .isCurrentTransactionReadOnly();
            LOG.warning("Exception caught. Rollback-only? "
                + "Check TransactionStatus for the real flag.");
            // The flag is on TransactionStatus, not easily queryable
            // But the proxy WILL check it at commit time
        }
    }
}
TEXT
The rollback-only flag lifecycle:
  1. Inner proxy catches RuntimeException
  2. Inner proxy calls transactionStatus.setRollbackOnly()
  3. Flag is set on the shared TransactionStatus object
  4. Flag CANNOT be cleared by any code
  5. At commit time, outer proxy checks the flag
  6. Flag is true -> rollback instead of commit
  7. UnexpectedRollbackException thrown

How do you fix it with REQUIRES_NEW?

The primary fix is to give the inner method its own physical transaction by using REQUIRES_NEW propagation. Now the inner method runs in a separate physical transaction. If it fails, only the inner transaction rolls back. The outer transaction is unaffected.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class FixedValidationService {

    private static final Logger LOG = Logger.getLogger(
        FixedValidationService.class.getName());

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void validate(String fromId, String toId, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            throw new ComplianceViolationException(
                "Transfer exceeds Rs.10,00,000 limit");
        }
    }
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Service
public class FixedBatchTransferService {

    private static final Logger LOG = Logger.getLogger(
        FixedBatchTransferService.class.getName());
    private final AccountRepository accountRepository;
    private final FixedValidationService validationService;

    public FixedBatchTransferService(AccountRepository accountRepository,
                                      FixedValidationService validationService) {
        this.accountRepository = accountRepository;
        this.validationService = validationService;
    }

    @Transactional // outer: physical TX-1
    public void transferWithValidation(String fromId, String toId,
                                        BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));

        try {
            // REQUIRES_NEW: TX-1 suspended, new TX-2 created
            validationService.validate(fromId, toId, amount);
        } catch (ComplianceViolationException e) {
            LOG.warning("Validation failed: " + e.getMessage());
            // TX-2 rolled back (its own TX)
            // TX-1 is NOT poisoned -- outer continues safely
        }
        // Proxy commits TX-1 successfully
    }
}
TEXT
With REQUIRES_NEW fix:
  1. transferWithValidation() starts TX-1
  2. debit and credit in TX-1
  3. validate() -> TX-1 SUSPENDED, TX-2 created
  4. validate() throws exception
  5. TX-2 rolls back (only the validation TX)
  6. TX-1 RESUMED
  7. Outer catches exception, continues
  8. Proxy commits TX-1 -> SUCCESS
  9. Debit and credit are committed

  TX-1 is not affected by TX-2's failure.

What is the alternative fix without REQUIRES_NEW?

A simpler fix is to not make the validator transactional at all. If the validator is a plain method that throws an exception, the outer method can catch it without any transaction infrastructure involvement. No proxy marks rollback-only because there is no inner proxy.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class PlainValidationService {

    // No @Transactional -- just a plain method
    public void validate(String fromId, String toId, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            throw new ComplianceViolationException(
                "Transfer exceeds Rs.10,00,000 limit");
        }
    }
}
TEXT
With plain (non-transactional) validator:
  1. transferWithValidation() starts TX-1
  2. debit and credit in TX-1
  3. plainValidationService.validate() throws exception
     -> no proxy, no setRollbackOnly()
  4. Outer catches exception, continues
  5. Proxy commits TX-1 -> SUCCESS

  This is the simplest fix when the validator does not need
  its own transaction behavior (no DB writes of its own).

What happens in a batch processing loop?

The UnexpectedRollbackException is especially dangerous in batch processing. If you process three payments in one transaction and one fails, the rollback-only flag poisons the entire batch. All three payments are lost, including the two valid ones.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.logging.Logger;

@Service
public class BatchPaymentProcessor {

    private static final Logger LOG = Logger.getLogger(
        BatchPaymentProcessor.class.getName());
    private final SinglePaymentService paymentService;

    public BatchPaymentProcessor(SinglePaymentService paymentService) {
        this.paymentService = paymentService;
    }

    // WRONG: one failure poisons the entire batch
    @Transactional
    public void processBatchWrong(List<PaymentRequest> requests) {
        for (PaymentRequest req : requests) {
            try {
                paymentService.process(req); // REQUIRED, joins batch TX
            } catch (Exception e) {
                LOG.warning("Payment failed: " + e.getMessage());
                // TX is now rollback-only! All payments will be lost.
            }
        }
    }

    // CORRECT: each payment in its own TX
    public void processBatchCorrect(List<PaymentRequest> requests) {
        for (PaymentRequest req : requests) {
            try {
                paymentService.processIndependent(req); // REQUIRES_NEW
            } catch (Exception e) {
                LOG.warning("Payment failed: " + e.getMessage());
                // Only the failed payment's TX rolled back
                // Other payments unaffected
            }
        }
    }
}

@Service
class SinglePaymentService {

    private final AccountRepository accountRepository;

    SinglePaymentService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional // REQUIRED: joins caller's TX
    public void process(PaymentRequest req) {
        var account = accountRepository.findByIdForUpdate(req.accountId())
                .orElseThrow();
        account.setBalance(account.getBalance().subtract(req.amount()));
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processIndependent(PaymentRequest req) {
        var account = accountRepository.findByIdForUpdate(req.accountId())
                .orElseThrow();
        account.setBalance(account.getBalance().subtract(req.amount()));
    }
}
TEXT
WRONG (REQUIRED in batch):
  Payment 1: SAV2001 debit 10000 -> OK
  Payment 2: SAV2002 debit 50000 -> FAILED (insufficient funds)
     -> TX marked rollback-only
  Payment 3: SAV2003 debit 20000 -> OK (but TX already doomed)
  COMMIT -> UnexpectedRollbackException
  All 3 payments lost, including the valid ones.

CORRECT (REQUIRES_NEW per payment):
  Payment 1: TX-1 debit SAV2001 -> COMMIT (success)
  Payment 2: TX-2 debit SAV2002 -> ROLLBACK (insufficient funds)
  Payment 3: TX-3 debit SAV2003 -> COMMIT (success)
  Payments 1 and 3 committed. Payment 2 failed independently.

How do you detect UnexpectedRollbackException in logs?

The exception message is distinctive: "Transaction silently rolled back because it has been marked as rollback-only." If you see this in your logs, it means an inner REQUIRED method threw an exception that the outer method caught, but the physical transaction was already doomed.

Enable DEBUG logging for org.springframework.transaction to see the transaction lifecycle:

YAML
# application.yml
logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa: DEBUG
TEXT
DEBUG o.s.t.i.TransactionInterceptor - Getting transaction for [validate]
DEBUG o.s.t.i.TransactionInterceptor - Completing transaction for [validate]
  after exception: ComplianceViolationException
DEBUG o.s.t.i.TransactionInterceptor - Participating transaction failed
  - marking existing transaction as rollback-only
DEBUG o.s.t.i.TransactionInterceptor - Getting transaction for [transferWithValidation]
DEBUG o.s.t.i.TransactionInterceptor - Completing transaction for [transferWithValidation]
ERROR: UnexpectedRollbackException: Transaction silently rolled back
  because it has been marked as rollback-only

The key log line is: "marking existing transaction as rollback-only"
This tells you exactly which inner method poisoned the TX.

What should a senior engineer know?

  • Logging the exception is not enough -- even if you log the exception in the catch block, the transaction is still doomed. The log message may give a false sense of recovery.
  • globalRollbackOnParticipationFailure -- Spring has a property AbstractPlatformTransactionManager.setGlobalRollbackOnParticipationFailure(false) that can disable the automatic rollback-only marking. But this is dangerous because it allows partial commits in a shared transaction.
  • The JPA persistence context -- when the inner method throws, the Hibernate persistence context may contain dirty entities in an inconsistent state. Even if you could clear the rollback-only flag, committing this state could corrupt data.
  • Testing for UnexpectedRollbackException -- write integration tests that specifically test the catch-and-continue pattern. Assert that the expected UnexpectedRollbackException is thrown when the inner REQUIRED method fails.
JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.UnexpectedRollbackException;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
class UnexpectedRollbackTest {

    @Autowired
    private BatchTransferService batchTransferService;

    @Test
    void catchingInnerExceptionStillCausesRollback() {
        assertThatThrownBy(() ->
            batchTransferService.transferWithValidation(
                "SAV2001", "CUR3001", new BigDecimal("2000000"))
        ).isInstanceOf(UnexpectedRollbackException.class);
        // Even though the outer method caught the exception,
        // the commit fails because the TX was marked rollback-only
    }
}
TEXT
Test output:
  org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only
  Test passes -- confirms the rollback-only behavior.

What are common interview follow-up questions?

  • Why does the outer method not know about the rollback-only flag? Because the flag is set on the physical TransactionStatus, which the outer method's catch block does not check. The outer proxy only checks the flag at commit time.
  • Can you query the rollback-only flag? Yes, via TransactionAspectSupport.currentTransactionStatus().isRollbackOnly(). But clearing it is not supported.
  • What if the inner method catches its own exception? Then the inner proxy sees a normal return and does NOT set rollback-only. The inner method must let the exception propagate past its proxy for the flag to be set.
  • Is this behavior specific to Spring? The concept of rollback-only is from the JTA specification. Spring's behavior mirrors JTA's UserTransaction.setRollbackOnly().

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│  REQUIRED: shared physical TX                             │
│                                                           │
│  Outer Method (TX-1)          Inner Method (joins TX-1)  │
│  ──────────────────          ──────────────────────────   │
│  BEGIN TX-1                                               │
│  debit SAV2001                                            │
│  credit CUR3001               validate()                 │
│          │                        │                       │
│          │               throws RuntimeException          │
│          │                        │                       │
│          │               proxy calls setRollbackOnly()    │
│          │                        │                       │
│     catch(e) { ... }      exception re-thrown             │
│     (thinks it recovered)                                 │
│          │                                                │
│     return normally                                       │
│          │                                                │
│     proxy tries COMMIT                                    │
│          │                                                │
│     sees rollback-only flag                               │
│          │                                                │
│     ROLLBACK TX-1                                         │
│          │                                                │
│     throws UnexpectedRollbackException                    │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  REQUIRES_NEW: separate physical TXs                      │
│                                                           │
│  Outer Method (TX-1)          Inner Method (TX-2)        │
│  ──────────────────          ────────────────────         │
│  BEGIN TX-1                                               │
│  debit SAV2001                                            │
│  credit CUR3001               BEGIN TX-2                 │
│          │                    validate()                  │
│     TX-1 SUSPENDED            throws RuntimeException     │
│          │                    ROLLBACK TX-2               │
│     TX-1 RESUMED                                          │
│     catch(e) { ... }                                      │
│          │                                                │
│     return normally                                       │
│          │                                                │
│     proxy COMMITS TX-1 -> SUCCESS                         │
│     (TX-1 was never poisoned)                             │
└──────────────────────────────────────────────────────────┘

Key Takeaway

UnexpectedRollbackException occurs when an inner @Transactional method with REQUIRED propagation throws a RuntimeException. The proxy around the inner method marks the shared physical transaction as rollback-only, and this flag cannot be cleared. Even if the outer method catches the exception and tries to continue, the commit fails because the physical transaction is doomed. The distinction is between logical and physical transaction scope: both methods logically think they have their own transaction, but REQUIRED means they share one physical transaction, and the inner failure contaminates it.

In Rajesh's batch payment service at the National Bank of India, processing three payments in one transaction with a REQUIRED inner validator means one invalid payment poisons the entire batch. All three payments are lost, including the valid ones. The fix is using REQUIRES_NEW on the validator so each validation runs in its own physical transaction. If validation fails, only the inner transaction rolls back, and the outer transaction is unaffected.

The simpler alternative is to not make the validator @Transactional at all. If the validator is a plain method that throws an exception, no proxy is involved, and no rollback-only flag is set. The outer method can catch the exception and continue without any transaction infrastructure interference.

Remember one sentence: REQUIRED means one physical transaction for all participants, so an inner failure poisons the whole chain -- use REQUIRES_NEW or remove @Transactional from the inner method to isolate failures.

---

Share
Use ← → arrow keys to navigate