@Transactional in Depth: Propagation, Isolation, and Rollback
What @Transactional Does
@Transactional tells Spring to wrap the annotated method in a database transaction. Spring opens the transaction before the method starts and commits (or rolls back) when it ends. The annotation works via an AOP proxy — understanding this proxy model is essential for avoiding the most common @Transactional bugs.
The AOP Proxy Model
Spring creates a proxy around your bean. When code outside the bean calls a @Transactional method, the call goes through the proxy, which manages the transaction:
External caller → Proxy (opens tx) → Your @Transactional method → Proxy (commits/rolls back)
Self-invocation bypasses the proxy. If a method in the same class calls another @Transactional method, it calls directly (not through the proxy), so the transaction management is skipped:
@Service
public class OrderService {
// Called from outside — proxy intercepts — transaction works ✓
@Transactional
public void placeOrder(OrderRequest request) {
// ...
sendConfirmation(request); // PROBLEM: direct call, no proxy involved
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmation(OrderRequest request) {
// This runs in the SAME transaction as placeOrder, not a new one
// The @Transactional annotation here is effectively ignored
}
}
Fix: inject the bean into itself (Spring Boot supports this) or extract to a separate @Service:
@Service
public class OrderService {
@Autowired
private OrderService self; // Spring injects the proxy
@Transactional
public void placeOrder(OrderRequest request) {
self.sendConfirmation(request); // goes through proxy ✓
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmation(OrderRequest request) { ... }
}
Propagation
Propagation defines what happens when a @Transactional method is called while a transaction is already active.
REQUIRED (default)
@Transactional(propagation = Propagation.REQUIRED)
public void doWork() { ... }
- If a transaction exists → join it
- If no transaction → create one
This is the right choice for 90% of cases. Two methods with REQUIRED that call each other share one transaction.
REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String message) { ... }
- Always start a new transaction
- Suspend the existing transaction if one is active
- The new transaction commits/rolls back independently
Use for audit logging or outbox events where you want the log committed even if the outer transaction rolls back.
@Service
@Transactional
public class OrderService {
private final AuditService auditService; // has REQUIRES_NEW
public void placeOrder(Order order) {
saveOrder(order);
auditService.log("order placed: " + order.getId()); // commits independently
if (someConditionFails()) {
throw new RuntimeException(); // outer tx rolls back, audit log survives
}
}
}
SUPPORTS
@Transactional(propagation = Propagation.SUPPORTS)
public Product findProduct(Long id) { ... }
- If a transaction exists → join it
- If no transaction → run without one
Use for read methods that can work with or without a transaction. The persistence context is active when called within a transaction (lazy loading works), but the method doesn’t demand one.
NOT_SUPPORTED
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendEmail(String to, String body) { ... }
- Suspend the current transaction (if any)
- Run without a transaction
Use for operations that must not run inside a transaction (slow I/O like email/HTTP calls that would hold a database connection open unnecessarily).
MANDATORY
@Transactional(propagation = Propagation.MANDATORY)
public void mustRunInTx() { ... }
- If a transaction exists → join it
- If no transaction → throw
IllegalTransactionStateException
Use to assert that callers must provide a transaction. Documents an architectural contract.
NEVER
@Transactional(propagation = Propagation.NEVER)
public void mustNotRunInTx() { ... }
- If a transaction exists → throw
IllegalTransactionStateException - If no transaction → proceed normally
Use to assert that the method must not be called within a transaction.
NESTED
@Transactional(propagation = Propagation.NESTED)
public void nestedOperation() { ... }
- If a transaction exists → create a savepoint; run as a nested transaction
- If no transaction → behaves like
REQUIRED
If the nested transaction rolls back, it rolls back only to the savepoint — the outer transaction can continue. Requires JDBC savepoint support (MySQL InnoDB supports this).
Propagation Quick Reference
| Propagation | Existing tx | No tx |
|---|---|---|
| REQUIRED | Join | Create |
| REQUIRES_NEW | Suspend + create new | Create |
| SUPPORTS | Join | Run without |
| NOT_SUPPORTED | Suspend | Run without |
| MANDATORY | Join | Exception |
| NEVER | Exception | Run without |
| NESTED | Savepoint | Create |
Isolation Levels
Isolation controls what data a transaction can see when other transactions are running concurrently. Higher isolation = fewer concurrency anomalies = more locking = lower throughput.
Concurrency Anomalies
| Anomaly | Description |
|---|---|
| Dirty read | Read uncommitted data from another transaction |
| Non-repeatable read | Same row read twice gives different values |
| Phantom read | Same query run twice returns different rows |
Isolation Levels
@Transactional(isolation = Isolation.READ_COMMITTED)
public void doWork() { ... }
| 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 |
| DEFAULT | Uses DB default |
MySQL InnoDB defaults to REPEATABLE_READ.
READ_COMMITTED
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReportData generateReport() {
// Each query sees the latest committed data
// Two reads of the same row may return different values
// Good for reporting where stale data is acceptable
}
The most common choice for OLTP applications. Each query snapshot is fresh. PostgreSQL default.
REPEATABLE_READ
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
// Reading the same row twice gives the same result
// Phantom rows (new rows inserted by other tx) are still possible (on some DBs)
}
Good for financial operations where consistency within the transaction is important. MySQL InnoDB default.
SERIALIZABLE
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalInventoryUpdate() {
// Complete isolation — as if transactions ran one by one
// Highest consistency, lowest throughput
}
Use only when correctness absolutely requires it. Causes significant lock contention.
Rollback Rules
By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) and commits on checked exceptions.
@Transactional
public void riskyOperation() throws IOException {
// RuntimeException → ROLLBACK ✓ (default)
// IOException (checked) → COMMIT (default — surprising!)
}
rollbackFor
@Transactional(rollbackFor = IOException.class)
public void riskyOperation() throws IOException {
// Now IOException also triggers rollback
}
@Transactional(rollbackFor = Exception.class)
public void paranoidOperation() throws Exception {
// Any exception triggers rollback
}
noRollbackFor
@Transactional(noRollbackFor = OptimisticLockException.class)
public void handleConcurrentUpdate() {
// OptimisticLockException (RuntimeException) is caught and handled
// Transaction commits rather than rolling back
}
Best Practice
Mark business exceptions that should trigger rollback:
@Transactional(rollbackFor = BusinessException.class)
public void placeOrder(OrderRequest request) {
if (!inventoryService.isAvailable(request.getProductId())) {
throw new InsufficientStockException("Out of stock"); // BusinessException subclass
}
// ...
}
readOnly = true
@Transactional(readOnly = true)
public List<Product> findActiveProducts() {
return productRepository.findByActiveTrue();
}
readOnly = true tells Spring and the database driver that this transaction will not modify data. Benefits:
- Hibernate skips dirty checking — it won’t scan entities for changes at flush time. For large reads, this is a meaningful performance saving.
- Database optimization — MySQL can route read-only transactions to a read replica (with appropriate routing config).
- Documentation — makes intent explicit.
Always use readOnly = true on service methods that only read data.
Class-level + method-level pattern:
@Service
@Transactional(readOnly = true) // default for all methods
public class ProductService {
public List<Product> findAll() { ... } // read-only ✓
public Product findById(Long id) { ... } // read-only ✓
@Transactional // override for writes (readOnly = false)
public Product save(Product product) { ... }
@Transactional
public void delete(Long id) { ... }
}
timeout
@Transactional(timeout = 30) // seconds
public void slowOperation() {
// Throws TransactionTimedOutException if not done in 30s
}
Use to prevent runaway transactions from holding database connections indefinitely.
Complete Service Example
@Service
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final AuditService auditService;
// Read-only — inherits class-level @Transactional(readOnly=true)
public Page<Order> findByCustomer(Long customerId, Pageable pageable) {
return orderRepository.findByCustomerId(customerId, pageable);
}
// Write — override with default @Transactional
@Transactional(rollbackFor = InsufficientStockException.class)
public Order placeOrder(OrderRequest request) {
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
if (product.getStockQuantity() < request.getQuantity()) {
throw new InsufficientStockException("Not enough stock");
}
product.setStockQuantity(product.getStockQuantity() - request.getQuantity());
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setProduct(product);
order.setQuantity(request.getQuantity());
order.setStatus(OrderStatus.PENDING);
Order saved = orderRepository.save(order);
// Audit in a separate transaction — commits even if outer tx rolls back
auditService.logOrderPlaced(saved.getId()); // REQUIRES_NEW
return saved;
}
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("Order not found"));
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped order");
}
order.setStatus(OrderStatus.CANCELLED);
// product stock restored via domain logic...
}
}
Summary
@Transactionaluses an AOP proxy — self-invocation bypasses it. Split into separate beans if propagation must apply.- Propagation:
REQUIRED(default) for most operations;REQUIRES_NEWfor audit/outbox;MANDATORYto document contract. - Isolation: use
READ_COMMITTEDfor general OLTP;REPEATABLE_READ(MySQL default) for financial operations;SERIALIZABLEonly when unavoidable. - Rollback: rolls back on
RuntimeExceptionby default; userollbackForto include checked exceptions. - readOnly = true: always use on read-only service methods — skips dirty checking, enables DB read replica routing.
- Class-level
@Transactional(readOnly = true)with method-level override for writes is the cleanest pattern.
Next: Article 23 covers transaction boundaries and common pitfalls — LazyInitializationException, the open-session-in-view anti-pattern, and transaction-less repository calls.