Spring: @Transactional & Transaction Management

Spring's @Transactional annotation is the standard way to declare transactional boundaries in Spring applications. Spring creates a proxy around the annotated bean and manages transaction begin, commit, and rollback automatically, removing the need for manual transaction handling code.

Contents

What is a Transaction

A transaction is a unit of work that either fully succeeds (commit) or fully fails (rollback). Transactions are characterised by the ACID properties:

  • Atomicity: all operations succeed or all are rolled back.
  • Consistency: the database moves from one valid state to another.
  • Isolation: concurrent transactions do not interfere with each other.
  • Durability: committed data survives system failures.

@Transactional Basics

Annotate a service method (or class) with @Transactional. Spring wraps the method in a proxy that begins a transaction before the call and commits (or rolls back on exception) after it.

Java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepo;
    private final InventoryRepository inventoryRepo;

    public OrderService(OrderRepository orderRepo, InventoryRepository inventoryRepo) {
        this.orderRepo = orderRepo;
        this.inventoryRepo = inventoryRepo;
    }

    @Transactional  // begin transaction before, commit after (rollback on RuntimeException)
    public Order placeOrder(OrderRequest request) {
        Order order = orderRepo.save(new Order(request));
        inventoryRepo.decrementStock(request.getProductId(), request.getQuantity());
        return order;
        // if decrementStock throws, the order.save is also rolled back
    }
}
Place @Transactional on service layer methods, not on repository methods. Spring Data JPA repositories already manage their own transactions per method.

Propagation Types

Propagation controls what happens when a transactional method is called from within another transactional method.

PropagationBehaviourUse case
REQUIRED (default)Join existing transaction, or create one if none existsStandard service methods
REQUIRES_NEWAlways create a new transaction; suspend any existing oneAudit logging: must commit independently of outer tx
NESTEDCreate a nested transaction (savepoint). Roll back to savepoint on failure without rolling back outer tx.Partial rollback scenarios
SUPPORTSJoin existing tx if present; run non-transactionally if notRead-only methods that tolerate no transaction
NOT_SUPPORTEDSuspend existing tx; run non-transactionallyOperations that must not run in a transaction
MANDATORYMust be called within an existing tx; throws if no txInternal methods that should always be called transactionally
NEVERMust not be called within a tx; throws if tx existsOperations that are incompatible with transactions
Java
@Service
public class AuditService {

    private final AuditLogRepository auditRepo;

    public AuditService(AuditLogRepository auditRepo) {
        this.auditRepo = auditRepo;
    }

    // Always runs in its own transaction — commits even if the caller rolls back
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String action, String userId) {
        auditRepo.save(new AuditLog(action, userId, Instant.now()));
    }
}

Isolation Levels

Isolation levels trade off consistency against concurrency. Higher isolation prevents more anomalies but reduces throughput.

LevelDirty ReadNon-repeatable ReadPhantom Read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented
Java
// Set isolation on a specific method
@Transactional(isolation = Isolation.REPEATABLE_READ)
public ReportData generateReport(Long reportId) {
    // multiple reads of the same data will return consistent results
    return reportRepository.fetchData(reportId);
}
Most databases default to READ_COMMITTED. PostgreSQL uses READ_COMMITTED by default; MySQL's InnoDB uses REPEATABLE_READ.

Rollback Rules

By default, @Transactional rolls back on unchecked exceptions (RuntimeException and Error) and does not roll back on checked exceptions.

Java
// Rollback on a specific checked exception
@Transactional(rollbackFor = IOException.class)
public void processFile(String path) throws IOException {
    // IOException will now trigger rollback
}

// Do NOT rollback on a specific runtime exception
@Transactional(noRollbackFor = OptimisticLockingFailureException.class)
public void updateWithRetry(Entity entity) {
    entityRepository.save(entity);
}

// Custom rollback on multiple exceptions
@Transactional(rollbackFor = {BusinessException.class, DataIntegrityViolationException.class})
public void complexOperation() {
    // rolls back on either exception
}
If you catch an exception inside a @Transactional method and do not re-throw it, Spring will not see it and will commit the transaction. Re-throw or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() to force rollback.

Common Pitfalls

1. Self-invocation (the proxy bypass problem)

When a method within the same class calls another @Transactional method, the call bypasses the Spring proxy: the transaction annotation on the inner method is ignored.

Java
@Service
public class UserService {

    // WRONG — calling internalSave() from within the same class
    // bypasses the proxy, so @Transactional on internalSave() is ignored
    public void createUser(User user) {
        this.internalSave(user); // NOT wrapped in a transaction
    }

    @Transactional
    public void internalSave(User user) {
        userRepo.save(user);
    }
}

// FIX — inject the service into itself (self-injection via @Lazy),
// or move the transactional logic to a separate class
@Service
public class UserService {

    @Autowired
    @Lazy
    private UserService self; // self-reference through proxy

    public void createUser(User user) {
        self.internalSave(user); // now goes through the proxy
    }

    @Transactional
    public void internalSave(User user) {
        userRepo.save(user);
    }
}

2. Checked exceptions do not trigger rollback by default

Java
// BUG — SQLException is checked; transaction commits even on failure
@Transactional
public void riskyOperation() throws SQLException {
    repo.save(entity);
    throw new SQLException("Something went wrong"); // NO rollback!
}

// FIX
@Transactional(rollbackFor = SQLException.class)
public void riskyOperation() throws SQLException {
    repo.save(entity);
    throw new SQLException("Something went wrong"); // rollback triggered
}

3. @Transactional on private methods: Spring AOP proxies do not intercept private methods. Always annotate public methods.

4. Lazy loading outside transaction: accessing a lazy-loaded collection after the transaction closes causes LazyInitializationException. Use @Transactional on the calling method or initialise collections inside the transaction.