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);
    }
}