Home / Spring Boot / Data and Transactions / Transactions with @Transactional

Transactions with @Transactional

A transaction groups database operations so that they all succeed or all fail together. Spring makes this easy with @Transactional, but you must understand how the proxy works to avoid silent mistakes.

Basic example: money transfer

@Service
public class TransferService {

    private final AccountRepository accounts;

    public TransferService(AccountRepository accounts) { this.accounts = accounts; }

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accounts.findById(fromId).orElseThrow();
        Account to = accounts.findById(toId).orElseThrow();
        from.withdraw(amount);                      // throws if balance is too low
        to.deposit(amount);
        // commit happens when the method ends normally.
        // A RuntimeException rolls BOTH changes back.
    }
}

Spring wraps the service bean in a proxy. Before your method it opens a transaction, and after it commits (or rolls back on exception). Entity changes made inside the transaction are saved automatically at commit (dirty checking), even without calling save().

Propagation: what if a transactional method calls another one?

PropagationBehavior
REQUIRED (default)Join the current transaction, or create one if none exists
REQUIRES_NEWAlways start a new transaction and suspend the current one
SUPPORTSUse a transaction if one exists, otherwise run without
NOT_SUPPORTEDRun without a transaction (suspend the current one)
MANDATORYMust run inside a transaction, otherwise throw an exception
NEVERMust run without a transaction, otherwise throw an exception
NESTEDNested transaction with a savepoint
@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)   // saved even if the caller rolls back
    public void log(String message) { /* save audit row */ }
}

Isolation levels

IsolationDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented

The default is the database default (READ_COMMITTED in PostgreSQL and Oracle, REPEATABLE_READ in MySQL InnoDB). Higher isolation gives more safety but less concurrency.

Rollback rules

Common pitfalls

@Service
public class OrderService {

    public void placeOrder(Order o) {
        saveOrder(o);                     // PITFALL 1: self-invocation, no proxy, NO transaction!
    }

    @Transactional
    public void saveOrder(Order o) { /* ... */ }

    @Transactional
    public void wrong() {
        try {
            riskyDatabaseCall();
        } catch (Exception e) {
            // PITFALL 2: swallowed exception. The transaction still commits
            // (or is marked rollback-only by an inner call and fails at commit).
        }
    }

    @Transactional
    private void hidden() { }             // PITFALL 3: private methods are not proxied
}

Programmatic transaction

@Service
public class ReportService {
    private final TransactionTemplate tx;
    public ReportService(PlatformTransactionManager manager) { this.tx = new TransactionTemplate(manager); }

    public void run() {
        tx.executeWithoutResult(status -> {
            // database work
            // status.setRollbackOnly();   // roll back manually
        });
    }
}

Internal workflow of a @Transactional call

caller ─► proxy of TransferService.transfer()
   TransactionInterceptor.invokeWithinTransaction
     1. TransactionAttributeSource: read @Transactional (propagation, isolation, readOnly, rollbackFor, timeout) from method or class
     2. choose the PlatformTransactionManager  (JpaTransactionManager for JPA, DataSourceTransactionManager for JDBC)
     3. tm.getTransaction(definition)
          ├─ existing transaction bound to this thread?  yes ─► apply PROPAGATION rules (join, suspend, create new)
          └─ no ─► create: get a JDBC Connection from the pool, setAutoCommit(false), set isolation / read-only,
                   create the EntityManager, and BIND both to the thread with TransactionSynchronizationManager (ThreadLocal maps)
     4. invoke your method
          └─ every repository / JdbcTemplate call asks TransactionSynchronizationManager for the bound Connection / EntityManager
             ─► ALL calls in the method use the SAME connection = the same database transaction
     5a. normal return  ─► tm.commit(): flush the persistence context (INSERT/UPDATE statements), COMMIT, run afterCommit callbacks, release the connection
     5b. exception      ─► rollbackOn(ex)? (RuntimeException/Error or rollbackFor) yes ─► ROLLBACK, else COMMIT
     6. unbind resources, return the connection to the Hikari pool

The transaction is tied to the THREAD. New threads (@Async, parallel streams, CompletableFuture) do not share it. Each of them gets its own connection and its own transaction, or none.

The rollback-only trap

@Service
class OrderService {
    private final PaymentService payments;      // has @Transactional too (propagation REQUIRED)
    OrderService(PaymentService payments) { this.payments = payments; }

    @Transactional
    public void place(Order order) {
        try {
            payments.charge(order);             // throws RuntimeException: the inner proxy marks the SHARED transaction rollback-only
        } catch (RuntimeException e) {
            log.warn("payment failed, continuing", e);      // you swallowed it, but the transaction is already doomed
        }
        saveOrder(order);
    }   // commit fails with UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
}
// Fixes: let the exception propagate, OR give charge() propagation = REQUIRES_NEW (own transaction that can fail alone),
//        OR do not use a shared transaction for that step.

Run code after commit (emails, messages)

@Transactional
public void register(String email) {
    userRepository.save(new AppUser(email));
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            mailSender.sendWelcome(email);      // only if the transaction really committed
        }
    });
}
// Simpler and testable: publish an event and use @TransactionalEventListener(phase = AFTER_COMMIT)

Isolation demo: lost update and how to avoid it

Two transactions, balance = 100, both do:  read balance ─► balance - 30 ─► write

 T1: read 100                      T2: read 100
 T1: write 70   COMMIT             T2: write 70   COMMIT      ◄── result 70, should be 40  (LOST UPDATE)

Solutions:
 1. Atomic SQL:       update account set balance = balance - 30 where id = ? and balance >= 30       (no read-modify-write in Java)
 2. Optimistic lock:  @Version column ─► the second commit fails with ObjectOptimisticLockingFailureException ─► retry
 3. Pessimistic lock: select ... for update ─► T2 waits until T1 commits
 4. SERIALIZABLE isolation (last resort: retries on serialization failures needed)
// Option 1 in Spring Data
@Modifying
@Query("update Account a set a.balance = a.balance - :amount where a.id = :id and a.balance >= :amount")
int withdraw(@Param("id") Long id, @Param("amount") BigDecimal amount);      // returns 0 if the balance was too low

Transactions and testing

Distributed transactions

A database transaction covers one database. Saving to your database AND sending a Kafka message is two systems. Do not rely on XA/2PC for this. Use the transactional outbox pattern: write the event to an outbox table in the same database transaction, and let a poller or Debezium publish it. Consumers must be idempotent.

Common mistake: Calling a @Transactional method from another method of the same class. The proxy is bypassed, so no transaction is started. Move it to a separate bean.

Interview questions

What is the default rollback behavior?

Rollback for RuntimeException and Error. Checked exceptions commit unless you set rollbackFor.

What is the difference between REQUIRED and REQUIRES_NEW?

REQUIRED joins the existing transaction. REQUIRES_NEW suspends it and creates a separate one that commits or rolls back independently.

Why does @Transactional not work on private methods?

The proxy can only intercept calls that come from outside through public (or protected) methods.

Why does @Transactional not roll back on a checked exception?

By default only RuntimeException and Error trigger rollback. Use rollbackFor = Exception.class, or throw unchecked exceptions.

What is UnexpectedRollbackException?

The transaction was marked rollback-only by an inner call or a caught exception, and the outer method tried to commit. Let the exception propagate or isolate the inner step with REQUIRES_NEW.