Transactions with @Transactional

Spring Boot · Practical · 14 min

Without correct transaction boundaries, partial updates corrupt data under failures.

Annotate a service method with @Transactional and Spring starts a transaction before the method and commits after it returns. If it throws, the transaction is rolled back. Everything the method does through repositories (and anything it calls in the same thread) shares that transaction and its database connection.

Rules that matter:

  • By default only unchecked exceptions (RuntimeException, Error) trigger rollback. A checked exception commits unless you add rollbackFor = Exception.class.
  • @Transactional(readOnly = true) for query methods lets Hibernate skip dirty checking and lets the driver route to replicas.
  • Propagation decides what happens when a transactional method calls another: the default REQUIRED joins the existing transaction; REQUIRES_NEW suspends it and starts an independent one, useful for audit logs that must persist even if the outer work fails.
  • Inside a transaction, loaded entities are managed: changing a field is written automatically at commit (dirty checking), no save call needed.

Transactions are implemented with proxies: the annotation only works when the method is called through the Spring bean from another bean. A call from one method to another @Transactional method in the same class bypasses the proxy, so the annotation on the inner method is ignored. Also, annotated methods should be public (Spring 6 also supports protected and package-private methods with class-based proxies).

Put transaction boundaries on service methods representing one use case, not on controllers or repositories.

Example

@Service
class TransferService {
    private final AccountRepository accounts;
    private final AuditService audit;
    TransferService(AccountRepository accounts, AuditService audit) {
        this.accounts = accounts; this.audit = audit;
    }

    @Transactional
    public void transfer(long from, long to, BigDecimal amount) {
        Account a = accounts.findById(from).orElseThrow();
        Account b = accounts.findById(to).orElseThrow();
        audit.record("transfer " + amount);      // REQUIRES_NEW: survives rollback
        a.withdraw(amount);                      // dirty checking: no save() needed
        b.deposit(amount);
        if (a.getBalance().signum() < 0)
            throw new IllegalStateException("insufficient funds");  // rolls back both
    }
}

@Service
class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String event) { /* insert audit row */ }
}

Remember

  • @Transactional commits on return and rolls back on unchecked exceptions
  • Checked exceptions commit unless rollbackFor says otherwise
  • REQUIRED joins, REQUIRES_NEW starts an independent transaction
  • Self-invocation bypasses the proxy and the annotation

Watch out for

  • Calling a @Transactional method from another method in the same class and expecting a new transaction.

Check yourself

  1. A @Transactional method throws a checked IOException. What happens by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.spring.io/spring-framework/reference/data-access/transaction/declarative.html