Contents
- What is a Transaction
- @Transactional Basics
- Propagation Types
- Isolation Levels
- Rollback Rules
- Common Pitfalls
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.
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.
| Propagation | Behaviour | Use case |
| REQUIRED (default) | 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 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);
}
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.