Rollback Rules
Control which exceptions roll a transaction back with rollbackFor and noRollbackFor, and why checked exceptions commit by default.
@Transactional(rollbackFor = SomeCheckedException.class)
@Transactional(noRollbackFor = ExpectedBusinessException.class)
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)By default a Spring transaction rolls back when the method throws a RuntimeException or an Error, and commits when it throws a checked exception. This surprises people coming from other frameworks. The reasoning is that a checked exception is part of the method's declared contract, an expected business outcome rather than a failure, but in practice many checked exceptions do mean the work is incomplete.
rollbackFor and noRollbackFor
You change the rules per method or class:
@Transactional(rollbackFor = PaymentDeclinedException.class)
public Order pay(long orderId) throws PaymentDeclinedException { ... }
@Transactional(noRollbackFor = CouponExpiredException.class)
public void redeem(String code) { ... }rollbackFor adds exception types, including their subclasses, that cause a rollback. noRollbackFor lists unchecked exceptions that should still commit; the exception is rethrown to the caller after the commit. When both match, the closest match in the exception hierarchy wins.
Changing the default for the whole application
If your code uses checked exceptions for failures, add a configuration class with @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS). Every exception then rolls back unless a noRollbackFor says otherwise. Declaring your own @EnableTransactionManagement makes Spring Boot's transaction auto-configuration step back from that part, so set the other attributes there too if you need them.
Rolling back without throwing
Sometimes you want to undo the work and still return a result, for example a dry run of an import that reports every bad row. Inside a TransactionTemplate callback, call status.setRollbackOnly(). The method returns normally, and the transaction is rolled back instead of committed.
Mistakes that leave half-written data
- Catching an exception inside the transactional method and logging it. The proxy never sees the exception, so it commits whatever ran before the failure.
- Wrapping an
IOExceptionin nothing at all and declaringthrows IOExceptionon a@Transactionalmethod. The first rows are committed and the rest are missing. - Expecting a rollback rule to affect a joined transaction differently. Once an inner
REQUIREDcall has marked the shared transaction rollback-only, the outer method cannot change that.
A rollback only undoes database work done through the transaction manager. Emails sent, files written and HTTP calls made inside the method are not undone, which is the reason to run them after commit.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class PaymentDeclinedException extends Exception {
PaymentDeclinedException(String reason) {
super(reason);
}
}
interface PaymentGateway {
void charge(String customerRef, BigDecimal amount) throws PaymentDeclinedException;
}
class CustomerOrder {
private Long id;
private String customerRef;
private BigDecimal total;
private String status = "NEW";
protected CustomerOrder() {
}
void markPaid() {
this.status = "PAID";
}
}
interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {
}
public class PaymentService {
private final CustomerOrderRepository orders;
private final PaymentGateway gateway;
PaymentService(CustomerOrderRepository orders, PaymentGateway gateway) {
this.orders = orders;
this.gateway = gateway;
}
// Without rollbackFor, a declined payment would still commit the PAID status.
// Calling the gateway inside the transaction keeps the example short; in production
// charge first, outside any transaction, so a slow gateway does not hold a connection.
(rollbackFor = PaymentDeclinedException.class)
public void pay(long orderId, String customerRef, BigDecimal amount) throws PaymentDeclinedException {
CustomerOrder order = orders.findById(orderId).orElseThrow();
order.markPaid();
gateway.charge(customerRef, amount);
}
}
When to use it
- Roll back an order's PAID status when the payment gateway declines the card with a checked exception.
- Keep a failed-attempt counter for an expired coupon while still returning an error to the client.
- Validate a stock import against the real database constraints without keeping any of the changes.
More examples
Commit the attempt, still report the failure
<code>noRollbackFor</code> is for the case where the exception describes an outcome and the work done so far is still correct. Here the failed attempt is counted for fraud checks, and the controller still turns the exception into a 4xx response. Keep the list short and specific: a broad type such as <code>RuntimeException</code> here would also commit after real bugs.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class CouponExpiredException extends RuntimeException {
CouponExpiredException(String code) {
super("Coupon " + code + " has expired");
}
}
@Entity
class Coupon {
@Id
private String code;
private int failedAttempts;
private boolean expired;
protected Coupon() {
}
boolean isExpired() {
return expired;
}
void recordFailedAttempt() {
failedAttempts++;
}
}
interface CouponRepository extends JpaRepository<Coupon, String> {
}
@Service
public class CouponService {
private final CouponRepository coupons;
CouponService(CouponRepository coupons) {
this.coupons = coupons;
}
// The attempt counter is committed; the caller still receives the exception.
@Transactional(noRollbackFor = CouponExpiredException.class)
public void redeem(String code) {
Coupon coupon = coupons.findById(code).orElseThrow();
if (coupon.isExpired()) {
coupon.recordFailedAttempt();
throw new CouponExpiredException(code);
}
// apply the discount
}
}
Roll back on every exception, application wide
With this in place, checked exceptions roll back just like unchecked ones, which matches what most developers expect. It is a good choice for a codebase that wraps I/O in checked exceptions. Methods that deliberately want to commit on a particular exception can still say so with <code>noRollbackFor</code>.
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.RollbackOn;
@Configuration
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
public class TransactionConfig {
}
A dry run that always rolls back
The updates run for real, so the database applies its own constraints and the report is accurate, but <code>setRollbackOnly()</code> guarantees nothing is kept. On PostgreSQL a failed statement aborts the rest of the transaction, so each row runs inside its own savepoint and a rejected row is rolled back to it before the loop moves on. Savepoints need a transaction manager that supports them, which the JDBC and JPA ones do.
import java.util.ArrayList;
import java.util.List;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
record ImportRow(int line, String sku, int quantity) {
}
@Service
public class StockImport {
private final TransactionTemplate tx;
private final JdbcClient jdbc;
StockImport(PlatformTransactionManager txManager, JdbcClient jdbc) {
this.tx = new TransactionTemplate(txManager);
this.jdbc = jdbc;
}
public List<String> check(List<ImportRow> rows) {
return tx.execute(status -> {
List<String> problems = new ArrayList<>();
for (ImportRow row : rows) {
// A savepoint per row: on PostgreSQL a failed statement otherwise
// aborts every statement after it in the same transaction.
Object savepoint = status.createSavepoint();
try {
int updated = jdbc.sql("update stock set quantity = :q where sku = :sku")
.param("q", row.quantity())
.param("sku", row.sku())
.update();
status.releaseSavepoint(savepoint);
if (updated == 0) {
problems.add("line " + row.line() + ": unknown SKU " + row.sku());
}
} catch (DataIntegrityViolationException e) {
status.rollbackToSavepoint(savepoint);
problems.add("line " + row.line() + ": rejected by the database");
}
}
status.setRollbackOnly(); // report only, never keep the changes
return problems;
});
}
}
Discussion