Which exceptions cause rollback by default?
Spring rolls back transactions not for all exceptions. There is a clear rule.
🟢 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):
IOExceptionSQLException- 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:
- Most specific match wins (deepest in hierarchy)
- At same depth — first matching rule
- 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)
-
String typo in
rollbackForClassName:@Transactional(rollbackForClassName = "BusinesException")— silently ignored. Spring doesn’t validate class name at startup. Exception thrown, but rollback doesn’t happen. -
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. -
AOP ordering: Custom exception handler aspect may catch exception before
TransactionInterceptor. TransactionInterceptor sees normal return → COMMIT. Solution:@Orderto control aspect ordering. -
@Asyncwith checked exceptions:@Asyncmethod 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 checkfuture.get(). -
Wrapped exceptions:
CompletionExceptionwraps original checked exception. Rollback rule searches for match onCompletionException(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
- Unified policy:
rollbackFor = Exception.classon all write methods — baseline. No exceptions. - Minimize checked exceptions: Convert business exceptions to RuntimeException hierarchy. Less boilerplate, correct rollback by default.
- Never swallow exceptions: If caught —
setRollbackOnly()or rethrow. - Test rollback behavior: Integration tests should verify data actually rolled back.
- Monitor rollback rate: High % = problem in validation, not in transactions.
- Use
noRollbackForselectively: 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]]