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
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
}
}
@TransactionalPropagation Types
Propagation controls what happens when a transactional method is called from within another transactional method.
| Propagation | Behaviour | Use case |
|---|---|---|
REQUIRED | Join existing transaction, or create one if none exists | Standard service methods |
REQUIRES_NEW | Always create a new transaction; suspend any existing one | Audit logging: must commit independently of outer tx |
NESTED | Create a nested transaction (savepoint). Roll back to savepoint on failure without rolling back outer tx. | Partial rollback scenarios |
SUPPORTS | Join existing tx if present; run non-transactionally if not | Read-only methods that tolerate no transaction |
NOT_SUPPORTED | Suspend existing tx; run non-transactionally | Operations that must not run in a transaction |
MANDATORY | Must be called within an existing tx; throws if no tx | Internal methods that should always be called transactionally |
NEVER | Must not be called within a tx; throws if tx exists | Operations 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
Isolation levels trade off consistency against concurrency. Higher isolation prevents more anomalies but reduces throughput.
| Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible |
READ_COMMITTED | Prevented | Possible | Possible |
REPEATABLE_READ | Prevented | Prevented | Possible |
SERIALIZABLE | Prevented | Prevented | Prevented |
// 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);
}
READ_COMMITTEDREAD_COMMITTEDREPEATABLE_READRollback Rules
By default, @TransactionalRuntimeExceptionError
// 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
}
@TransactionalTransactionAspectSupport.currentTransactionStatus().setRollbackOnly()Common Pitfalls
1. Self-invocation (the proxy bypass problem)
When a method within the same class calls another @Transactional
@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@Transactional