Contents

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

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.

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 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
@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 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
// 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.

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

// 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.

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.

@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

// 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.