Rollback Rules
Default: Unchecked Exceptions Roll Back
By default Spring rolls back on RuntimeException and Error, but commits on checked exceptions (those extending Exception but not RuntimeException). This surprises many developers.
Example
This is the rule Spring's RuleBasedTransactionAttribute applies, written out in plain Java:
import java.io.IOException;
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Throwable> errors = List.of(
new IllegalStateException("out of stock"),
new PaymentFailedException("card declined"),
new IOException("invoice PDF failed"),
new OutOfMemoryError("heap"));
System.out.println("@Transactional (default rules):");
for (Throwable t : errors) print(t, defaultRule(t));
System.out.println("@Transactional(rollbackFor = Exception.class):");
for (Throwable t : errors) print(t, defaultRule(t) || t instanceof Exception);
System.out.println("@Transactional(noRollbackFor = IllegalStateException.class):");
for (Throwable t : errors) print(t, defaultRule(t) && !(t instanceof IllegalStateException));
}
static boolean defaultRule(Throwable t) {
return t instanceof RuntimeException || t instanceof Error;
}
static void print(Throwable t, boolean rollback) {
System.out.printf(" %-24s -> %s%n", t.getClass().getSimpleName(), rollback ? "ROLLBACK" : "COMMIT");
}
}
class PaymentFailedException extends Exception { // checked
PaymentFailedException(String m) { super(m); }
}Output
@Transactional (default rules): IllegalStateException -> ROLLBACK PaymentFailedException -> COMMIT IOException -> COMMIT OutOfMemoryError -> ROLLBACK @Transactional(rollbackFor = Exception.class): IllegalStateException -> ROLLBACK PaymentFailedException -> ROLLBACK IOException -> ROLLBACK OutOfMemoryError -> ROLLBACK @Transactional(noRollbackFor = IllegalStateException.class): IllegalStateException -> COMMIT PaymentFailedException -> COMMIT IOException -> COMMIT OutOfMemoryError -> ROLLBACK
Choosing Rules in Spring
@Transactional(rollbackFor = PaymentFailedException.class)
public Order checkout(CheckoutRequest req) throws PaymentFailedException { ... }
// or for every checked exception
@Transactional(rollbackFor = Exception.class)
public void importProducts(Path csv) throws IOException { ... }Tip: Many teams avoid the issue entirely by using unchecked exceptions for business errors (OutOfStockException extends RuntimeException), which also map nicely to HTTP errors with @ControllerAdvice.
Swallowed Exceptions Do Not Roll Back
Note: If you catch an exception inside the @Transactional method and do not rethrow it, the proxy never sees it and commits. To roll back without throwing, call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
Exercise: importProducts throws IOException after saving 300 of 1,000 rows. What is in the database with plain @Transactional, and how do you make it all-or-nothing?
Show answer
// Plain @Transactional: IOException is checked -> the transaction COMMITS,
// so the first 300 products stay in the table.
@Transactional(rollbackFor = IOException.class) // now all 300 inserts are rolled back
public void importProducts(Path csv) throws IOException {
try (var lines = Files.lines(csv)) {
lines.skip(1).map(ProductCsv::parse).forEach(productRepository::save);
}
}