Question 19 · Section 11

Which exceptions cause rollback by default?

Spring rolls back transactions not for all exceptions. There is a clear rule.

Language versions: English Russian Ukrainian

🟢 Junior Level

Spring rolls back transactions not for all exceptions. There is a clear rule.

Default rule

Spring automatically rolls back only for:

  • RuntimeException (NullPointerException, IllegalArgumentException, etc.)
  • Error (OutOfMemoryError, StackOverflowError, etc.)

These are Unchecked Exceptions — those that don’t need to be declared in throws.

Mechanism: TransactionInterceptor catches the exception and checks: if type = RuntimeException or Error → setRollbackOnly(). If type = Exception (but not RuntimeException) → commit, as if the method completed normally.

What does NOT trigger rollback

Checked Exceptions — subclasses of Exception (but not RuntimeException):

  • IOException
  • SQLException
  • Any custom extends Exception

If such exceptions are thrown from a @Transactional method — Spring will COMMIT.

Example

@Transactional
public void process() {
    throw new RuntimeException("fail");  // ROLLBACK ✅
}

@Transactional
public void process2() throws IOException {
    throw new IOException("fail");  // COMMIT ❌ — data will be saved!
}

Analogy

Think of an alarm system: RuntimeException = fire (drop everything and evacuate = rollback). Checked Exception = scheduled inspection (continue working = commit).

How to change behavior

@Transactional(rollbackFor = Exception.class)  // Rollback for ALL exceptions
public void process() throws IOException { ... }

🟡 Middle Level

Why did Spring choose this policy?

Architectural decision from EJB philosophy:

  • RuntimeException — unforeseen system error or bug. System state is undefined → must roll back.
  • Checked Exception — expected business situation (e.g., InsufficientFundsException). Developer should handle and possibly commit changes.

Behavior hasn’t changed since Spring 2.x and is the same in Spring Boot 3.x. But in Spring 6+ with Ahead-of-Time compilation there may be nuances with exception type resolution — test it.

Full classification

Exception type Example Rollback by default?
RuntimeException NullPointerException, IllegalArgumentException ✅ Yes
Error OutOfMemoryError, StackOverflowError ✅ Yes
Checked Exception IOException, SQLException ❌ No
Custom checked BusinessException extends Exception ❌ No
Spring DataAccessException DataIntegrityViolationException ✅ Yes (it’s RuntimeException)

// Why commit: BusinessException inherits from Exception (not RuntimeException). // Spring sees checked exception → treats as “normal completion” → commit. // Result: partially saved data, even though business logic “failed”.

How to configure rollback for Checked Exceptions

// For all Exceptions
@Transactional(rollbackFor = Exception.class)
public void businessMethod() throws MyCheckedException { ... }

// For specific exception
@Transactional(rollbackFor = IOException.class)
public void readFile() throws IOException { ... }

// For multiple exceptions
@Transactional(rollbackFor = {IOException.class, MyBusinessException.class})
public void process() throws IOException, MyBusinessException { ... }

// Exclude specific RuntimeException from rollback
@Transactional(noRollbackFor = MyCustomRuntimeException.class)
public void processWithExceptionHandling() { ... }

The Catch-Block Problem

If you catch an exception inside the method — Spring won’t know about it:

@Transactional
public void save() {
    try {
        repository.save(user);
        throw new RuntimeException();
    } catch (Exception e) {
        log.error("Error occurred");
        // TRANSACTION WILL BE COMMITTED! Spring doesn't see the exception.
    }
}

For the transaction to roll back, the exception must escape the proxy method boundary.

Common mistakes

Mistake What happens How to fix
Not knowing default policy COMMIT on checked exception rollbackFor = Exception.class
Catch and swallow Spring sees normal return → COMMIT Rethrow or setRollbackOnly()
Wrong exception hierarchy BusinessException extends Exception — no rollback Inherit from RuntimeException
AOP ordering Custom aspect catches exception before TransactionInterceptor Check aspect order
@Async method Exception doesn’t propagate to caller’s transaction Separate transaction context

When NOT to use rollback

Situation Why Alternative
Partial success in batch One failed item ≠ entire batch failed Handle, continue, commit rest
Business validation Expected situation, data valid until error noRollbackFor = ValidationException.class
Graceful degradation System should work in degraded mode Not rollback, but fallback logic

Approach comparison

Approach Pros Cons When to use
RuntimeException (default) Automatic rollback, minimal code Not for expected business errors Bugs, system errors
rollbackFor = Exception.class All exceptions = rollback May rollback what shouldn’t be When any error = invalid state
Programmatic setRollbackOnly() Conditional rollback Boilerplate, Spring coupling Complex business rules

🔴 Senior Level

Rollback Rule Resolution — Spring Internals

// RuleBasedTransactionAttribute.rollbackOn()
@Override
public boolean rollbackOn(Throwable ex) {
    // Check custom rollback rules first
    for (RollbackRuleAttribute rule : rollbackRules) {
        int depth = rule.getDepth(ex);
        if (depth >= 0) {
            return rule instanceof RollbackRuleAttribute;
        }
    }
    // Default: rollback on RuntimeException and Error
    return (ex instanceof RuntimeException || ex instanceof Error);
}

The getDepth() method traverses the exception hierarchy:

public int getDepth(Throwable ex) {
    return getDepth(ex.getClass(), 0);
}

private int getDepth(Class<?> exceptionClass, int depth) {
    if (exceptionClass.getName().contains(this.exceptionName)) {
        return depth;  // Match found
    }
    if (exceptionClass == Throwable.class) {
        return -1;  // No match, stop searching
    }
    return getDepth(exceptionClass.getSuperclass(), depth + 1);
}

String-Based Rollback Rules

@Transactional(rollbackForClassName = {"MyBusinessException"})
public void process() { ... }

Spring resolves class name at runtime:

// RollbackRuleAttribute constructor with String
public RollbackRuleAttribute(String exceptionPattern) {
    this.exceptionName = exceptionPattern;
    // NOT validated at startup — resolved lazily
}

Risk: Typo in class name → rule silently doesn’t fire → no rollback.

Combining rollbackFor and noRollbackFor

@Transactional(
    rollbackFor = Exception.class,
    noRollbackFor = {NotFoundException.class, ValidationException.class}
)
public void process() {
    // Exception → rollback
    // NotFoundException → NO rollback (commit despite exception)
    // ValidationException → NO rollback
    // RuntimeException → rollback (subclass of Exception)
}

Rule matching order:

  1. Most specific match wins (deepest in hierarchy)
  2. At same depth — first matching rule
  3. Default rules (RuntimeException, Error) checked last

Spring DataAccessException Hierarchy

Spring wraps all database exceptions in DataAccessException hierarchy:

DataAccessException (RuntimeException)
├── DataIntegrityViolationException    — FK violation, unique constraint
├── DuplicateKeyException              — Duplicate key
├── CannotAcquireLockException         — Lock timeout/deadlock
├── QueryTimeoutException              — Query timeout
├── CannotSerializeTransactionException — Serialization failure
└── ... many more

All of them are RuntimeException → trigger rollback by default. This is why exception translation matters:

@Repository  // Enables exception translation
public class UserRepositoryImpl {
    // SQLException → DataAccessException (RuntimeException) → rollback
}

Edge Cases (minimum 3)

  1. String typo in rollbackForClassName: @Transactional(rollbackForClassName = "BusinesException") — silently ignored. Spring doesn’t validate class name at startup. Exception thrown, but rollback doesn’t happen.

  2. Conflicting rules: rollbackFor = Exception.class + noRollbackFor = RuntimeException.class. Behavior depends on rule matching order — most specific match wins. RuntimeException deeper in hierarchy → noRollbackFor for it wins → no rollback for RuntimeException, rollback for checked Exception.

  3. AOP ordering: Custom exception handler aspect may catch exception before TransactionInterceptor. TransactionInterceptor sees normal return → COMMIT. Solution: @Order to control aspect ordering.

  4. @Async with checked exceptions: @Async method has separate transaction context. Checked exception doesn’t propagate to caller. Caller’s transaction doesn’t know about error → COMMIT. Future object holds exception — need to check future.get().

  5. Wrapped exceptions: CompletionException wraps original checked exception. Rollback rule searches for match on CompletionException (RuntimeException) → rollback. But original checked exception may not match the rule.

Catch-Block Problem — Deep Analysis

@Transactional
public void problematicMethod() {
    try {
        repository.save(entity);
        throw new DataAccessException("something went wrong") {};
    } catch (DataAccessException e) {
        log.error("Failed", e);
        // EXCEPTION CAUGHT — TransactionInterceptor never sees it
        // Transaction WILL BE COMMITTED
    }
}

Solutions:

// Solution 1: Rethrow
@Transactional
public void method1() {
    try {
        repository.save(entity);
        throw new RuntimeException("fail");
    } catch (RuntimeException e) {
        log.error("Failed", e);
        throw e;  // Interceptor will see it
    }
}

// Solution 2: setRollbackOnly
@Transactional
public void method2() {
    try {
        repository.save(entity);
        throw new IOException("fail");
    } catch (IOException e) {
        log.error("Will rollback", e);
        TransactionAspectSupport.currentTransactionStatus()
            .setRollbackOnly();
    }
}

// Solution 3: Wrap in RuntimeException
@Transactional
public void method3() {
    try {
        repository.save(entity);
        throw new IOException("fail");
    } catch (IOException e) {
        throw new RuntimeException("Wrapped", e);  // Trigger rollback
    }
}

Performance Numbers

Operation Time
Rollback rule resolution (cached) ~0.5 μs
setRollbackOnly() call ~1 μs
Exception type check (instanceof chain) ~0.1 μs
Actual DB rollback 1-5 ms (PostgreSQL O(1)), 5-50 ms (InnoDB O(N))

Memory Implications

  • Rollback rules stored in TransactionAttribute — one object per method
  • With many rules (rollbackForClassName with typos) — wasted memory on useless rules
  • Exception objects in stack — can be large (stack trace). Long stack traces = memory pressure with frequent rollbacks.

Thread Safety

Rollback rules are immutable — thread-safe. TransactionSynchronizationManager stores state in ThreadLocal — concurrent calls are isolated.

Production War Story

Order processing service used BusinessException extends Exception for “expected” validation errors. @Transactional without rollbackFor. On business rule violation, exception was thrown, but transaction COMMITed — half-created order remained in database. Client saw error, but order hung in “pending” state. Solution: rollbackFor = Exception.class on all write methods + integration tests.

Monitoring

Debug logging:

logging:
  level:
    org.springframework.transaction.interceptor: TRACE

Micrometer:

@Aspect
@Component
public class TransactionMetrics {
    private final MeterRegistry registry;

    @AfterThrowing(pointcut = "@annotation(Transactional)", throwing = "ex")
    public void onException(Throwable ex) {
        registry.counter("transaction.exception", "type", ex.getClass().getSimpleName())
            .increment();
    }
}

Actuator:

GET /actuator/metrics/transaction.exception

Highload Best Practices

  1. Unified policy: rollbackFor = Exception.class on all write methods — baseline. No exceptions.
  2. Minimize checked exceptions: Convert business exceptions to RuntimeException hierarchy. Less boilerplate, correct rollback by default.
  3. Never swallow exceptions: If caught — setRollbackOnly() or rethrow.
  4. Test rollback behavior: Integration tests should verify data actually rolled back.
  5. Monitor rollback rate: High % = problem in validation, not in transactions.
  6. Use noRollbackFor selectively: Only for exceptions that truly mean “this is not an error, continue”.

🎯 Interview Cheat Sheet

Must know:

  • Spring rolls back only for RuntimeException and Error — checked exceptions → commit
  • DataAccessException (all subclasses) — RuntimeException → rollback by default
  • @Transactional(rollbackFor = Exception.class) — rollback for ALL exceptions
  • If exception caught inside method — Spring doesn’t see → commit, need setRollbackOnly() or rethrow
  • rollbackForClassName (string-based) — risky: typo silently ignored, no validation at startup
  • noRollbackFor excludes specific exceptions from rollback (e.g., ValidationException)

Common follow-up questions:

  • Why did Spring choose this policy? — RuntimeException = bug (rollback), Checked = expected business situation
  • What happens with conflicting rollbackFor + noRollbackFor? — Most specific match wins (deepest in hierarchy)
  • How does rollback rule resolution work? — RuleBasedTransactionAttribute.rollbackOn() traverses exception hierarchy
  • Why doesn’t @Async propagate checked exception? — Separate transaction context, exception in Future

Red flags (DO NOT say):

  • “All exceptions cause rollback” — only RuntimeException and Error
  • “Caught exception = Spring will rollback” — Spring doesn’t see caught exceptions
  • “rollbackForClassName = reliable way” — typo silently ignored

Related topics:

  • [[18. What is rollback in transactions]]
  • [[20. How to configure rollback for checked exceptions]]
  • [[16. What is @Transactional annotation]]