💳 Section 11 · Question #19

Which Exceptions Trigger Rollback by Default

In Spring Framework, a transaction does not automatically roll back for every thrown exception. There is a strict architectural divide:


🟢 Junior Level

In Spring Framework, a transaction does not automatically roll back for every thrown exception. There is a strict architectural divide:

The Default Rule in 30 Seconds

Spring automatically triggers a ROLLBACK strictly upon encountering Unchecked Exceptions:

  1. java.lang.RuntimeException and any of its subclasses (NullPointerException, IllegalArgumentException, IllegalStateException, DataAccessException, etc.).
  2. java.lang.Error and any of its subclasses (OutOfMemoryError, StackOverflowError, etc.).

All Checked Exceptions (direct subclasses of java.lang.Exception, such as IOException, SQLException, ClassNotFoundException, or custom business exceptions declared as extends Exception) DO NOT trigger a rollback by default — Spring executes a COMMIT!

Exception escapes @Transactional method:
                 │
                 ▼
       Is the thrown object an
       instance of RuntimeException or Error?
              /          \
            YES          NO (Checked Exception)
            /              \
     [ ROLLBACK ]      [ COMMIT ]
  (Mutations undone) (Data saved permanently!)

Practical Demonstration of Default Behavior

@Service
public class DefaultRollbackService {

    private final AccountRepository accountRepository;

    public DefaultRollbackService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // SCENARIO 1: RuntimeException -> Automatic ROLLBACK
    @Transactional
    public void debitWithRuntimeException(Long id, BigDecimal amount) {
        Account account = accountRepository.findById(id).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        accountRepository.save(account);

        // Unchecked exception: Spring catches this and executes connection.rollback()!
        throw new IllegalStateException("Unexpected technical failure");
    }

    // SCENARIO 2: Checked Exception -> Silent COMMIT (PRODUCTION HAZARD)
    @Transactional
    public void debitWithCheckedException(Long id, BigDecimal amount) throws IOException {
        Account account = accountRepository.findById(id).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        accountRepository.save(account);

        // Checked exception: Spring considers this normal business flow and COMMITS!
        // The money debit is permanently committed to the database despite the error!
        throw new IOException("Failed to write invoice file to network disk");
    }
}

🟡 Middle Level

Historical Philosophy: Why Spring Designed It This Way

This behavioral model was established by Rod Johnson in Spring 1.0, inherited from the EJB (Enterprise Java Beans) specification:

  • RuntimeException represents an unforeseen technical crash or programming flaw (code bug, null pointer, database driver crash). The application state is compromised and cannot continue safely $\rightarrow$ an unconditional ROLLBACK is required.
  • Checked Exception was designed into Java as an anticipated alternative business outcome (e.g., InsufficientFundsException or UserAlreadyExistsException). The compiler forces the calling code to handle the outcome, and Spring assumes the application may intend to preserve preceding state mutations (such as incrementing failed-attempt audit counters) $\rightarrow$ a COMMIT is executed.

[!NOTE] In modern Java engineering, checked exceptions are widely considered obsolete, and modern libraries rely almost exclusively on unchecked exceptions. However, Spring’s @Transactional defaults remain unchanged to guarantee strict backwards compatibility.

Exception Classification and Default Spring Response

Exception Class Java Classification Default Spring Action Database Outcome
NullPointerException RuntimeException ❌ ROLLBACK Modifications completely reverted
IllegalArgumentException RuntimeException ❌ ROLLBACK Modifications completely reverted
DataIntegrityViolationException RuntimeException ❌ ROLLBACK Modifications completely reverted
OutOfMemoryError Error ❌ ROLLBACK Reverted (if JVM remains responsive)
IOException Checked Exception ⚠️ COMMIT Data permanently saved to DB!
SQLException Checked Exception ⚠️ See below Translated to RuntimeException
CustomException extends Exception Checked Exception ⚠️ COMMIT Data permanently saved to DB!

Why Database Errors (SQLException) Still Trigger Rollbacks

Because raw JDBC java.sql.SQLException is a checked exception, why do database constraint violations (duplicate keys, deadlocks) still trigger transaction rollbacks?

  • Spring registers a Persistence Exception Translation mechanism (PersistenceExceptionTranslationPostProcessor and @Repository).
  • Spring intercepts raw SQLException instances and translates them into its unified org.springframework.dao.DataAccessException hierarchy (DuplicateKeyException, CannotAcquireLockException).
  • Because DataAccessException extends RuntimeException, it triggers Spring’s standard default rollback policy.

🔴 Senior Level

Kernel Internals: RuleBasedTransactionAttribute

The evaluation of whether an exception requires rollback is encapsulated inside RuleBasedTransactionAttribute.rollbackOn():

// Spring Framework source logic: org.springframework.transaction.interceptor.RuleBasedTransactionAttribute
@Override
public boolean rollbackOn(Throwable ex) {
    RollbackRuleAttribute winner = null;
    int deepest = Integer.MAX_VALUE;

    // 1. Search for the most specific matching custom rule:
    if (this.rollbackRules != null) {
        for (RollbackRuleAttribute rule : this.rollbackRules) {
            int depth = rule.getDepth(ex);
            if (depth >= 0 && depth < deepest) {
                deepest = depth;
                winner = rule;
            }
        }
    }

    // If a custom rule matched, its decision takes precedence:
    if (winner != null) {
        return !(winner instanceof NoRollbackRuleAttribute);
    }

    // 2. FALLBACK DEFAULT RULE (if no custom rules matched):
    return (ex instanceof RuntimeException || ex instanceof Error);
}

The Inheritance Distance Algorithm (getDepth) for Conflicting Rules

When an annotation declares conflicting rollbackFor and noRollbackFor rules, Spring calculates class inheritance distance:

@Transactional(
    rollbackFor = Exception.class,         // Depth for NullPointerException = 2 (NPE -> RuntimeException -> Exception)
    noRollbackFor = RuntimeException.class // Depth for NullPointerException = 1 (NPE -> RuntimeException)
)
public void processOrder() {
    throw new NullPointerException();
}

Outcome: Method getDepth() returns depth 1 for the noRollbackFor rule and depth 2 for rollbackFor. The rule with the smallest inheritance distance (closest ancestor) wins. The transaction commits!

The Silent Hazard of String-Based Rules (rollbackForClassName)

Spring allows configuring exception rules using string class names:

@Transactional(rollbackForClassName = "Com.MyCompany.BusinesException") // TYPO!
  • Spring does not validate string class names at compilation time or application startup (it uses lazy pattern matching against the exception’s FQCN).
  • If a developer introduces a typographical error, the rule will never match (depth == -1).
  • When the checked exception is thrown at runtime, Spring silently falls back to its default policy and commits the transaction!
  • Senior Best Practice: Always use type-safe class literals: rollbackFor = BusinessException.class.

4 Tricky Questions

1. What happens if a checked exception (IOException) is wrapped inside a CompletionException or UndeclaredThrowableException?

Answer: The transaction rolls back (ROLLBACK), even if rollbackFor was omitted! Spring AOP proxies inspect the top-level exception that physically escapes the method invocation. Both CompletionException (from CompletableFuture) and UndeclaredThrowableException (from dynamic proxy reflections) inherit directly from RuntimeException. Because the top-level exception satisfies ex instanceof RuntimeException == true, Spring unconditionally triggers a transaction rollback.


2. How is a rule conflict resolved if a method specifies rollbackFor = Exception.class and noRollbackFor = IllegalArgumentException.class, and throws a NumberFormatException?

Answer: The transaction COMMITS! The RuleBasedTransactionAttribute algorithm calculates the inheritance depth to the nearest ancestor:

  • NumberFormatException directly extends IllegalArgumentException (distance = 1 in noRollbackFor).
  • NumberFormatException extends IllegalArgumentException $\rightarrow$ RuntimeException $\rightarrow$ Exception (distance = 3 in rollbackFor). Spring selects the rule with the minimal depth (depth = 1). Because the winning rule is a NoRollbackRuleAttribute, rollback is suppressed and Spring commits the transaction.

3. What happens if a service method catches a RuntimeException in a try-catch block, logs the error, and returns normally?

Answer: A Silent Commit occurs! Spring’s AOP proxy intercepts exceptions only when they propagate across the method boundary. If the exception is caught and swallowed internally:

  1. TransactionInterceptor never learns that a failure occurred.
  2. The proxy concludes that the business method executed successfully.
  3. The interceptor invokes connection.commit(). Any database modifications performed prior to the error are permanently saved, potentially violating domain invariants. To trigger a rollback without re-throwing, you must invoke TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().

4. Why is OutOfMemoryError included in the default rollback condition, and is a physical database rollback guaranteed when it occurs?

Answer: Error is included in the default check (ex instanceof Error) because it signals catastrophic JVM instability where committing unverified partial writes is dangerous. However, a physical database rollback is NOT guaranteed:

  • When OutOfMemoryError occurs, the JVM heap is severely exhausted.
  • Spring’s attempt to execute connection.rollback() requires allocating new objects in the heap (JDBC driver state, network socket buffers). If memory is completely depleted, the rollback invocation itself throws a secondary OutOfMemoryError.
  • In this scenario, the TCP connection between the application and the database server abruptly terminates. Data integrity is ultimately preserved by the database engine itself: the database server detects the dropped connection and automatically aborts and rolls back the orphaned transaction via its WAL/Undo logs.

🎯 Interview Cheat Sheet

  • Default Rollback Policy: Spring triggers ROLLBACK on RuntimeException and Error.
  • Default Commit Policy: Spring triggers COMMIT on checked exceptions (extends Exception).
  • All-Exception Rollback: Always configure @Transactional(rollbackFor = Exception.class) on production write services.
  • Rule Conflict Resolution: When multiple rules match, Spring chooses the rule with the smallest inheritance distance (getDepth()).
  • Spring Data Exception Translation: Low-level checked SQLException is automatically translated into unchecked DataAccessException, ensuring automatic rollback.
  • Swallowed Exceptions: Catching and ignoring a RuntimeException leads to a silent COMMIT.
  • String Rule Danger: Avoid rollbackForClassName; typographical errors fail silently and cause unexpected commits.