Transactions: @Transactional, Propagation, and Isolation Levels
A transaction ensures that a group of operations either all succeed or all fail — no partial state. Spring’s @Transactional makes this simple to use, but the underlying mechanics matter when things go wrong.
How @Transactional Works
@Transactional is implemented via AOP (Aspect-Oriented Programming). Spring wraps your bean in a proxy:
Client calls orderService.create()
│
▼
Spring AOP proxy intercepts the call
│
▼
BEGIN TRANSACTION
│
▼
Your actual method body executes
(all DB operations share one connection and transaction)
│
├─ No exception → COMMIT
│
└─ RuntimeException thrown → ROLLBACK
│
▼
Client receives result (or exception)
This means @Transactional only works when:
- The method is called through the Spring proxy (not from within the same class)
- The method is public (Spring’s default proxy cannot intercept private/protected methods)
Basic Usage
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
@Transactional
public Order createOrder(CreateOrderRequest request) {
// All three operations run in one transaction
Order order = orderRepository.save(Order.from(request));
inventoryService.reserve(order.getItems()); // may throw
paymentService.authorize(order); // may throw
// If inventoryService or paymentService throws → ROLLBACK
// The order INSERT is also rolled back
return order;
}
}
Rollback Rules
By default, @Transactional rolls back on unchecked exceptions (subclasses of RuntimeException) and Errors. It does NOT roll back on checked exceptions.
@Transactional
public void processOrder(UUID id) throws InventoryException {
Order order = orderRepository.findById(id).orElseThrow();
order.confirm();
try {
inventoryService.reserve(order);
} catch (InventoryException e) {
// Checked exception — transaction is NOT rolled back by default
// order.confirm() remains committed!
throw e;
}
}
Fix with explicit rollback rules:
// Rollback on checked exception too
@Transactional(rollbackFor = InventoryException.class)
public void processOrder(UUID id) throws InventoryException { ... }
// Rollback on any exception
@Transactional(rollbackFor = Exception.class)
public void processOrder(UUID id) throws Exception { ... }
// Don't rollback on a specific runtime exception
@Transactional(noRollbackFor = OptimisticLockException.class)
public void processOrder(UUID id) { ... }
Propagation — What Happens When Transactions Are Nested
Propagation controls what happens when a @Transactional method calls another @Transactional method.
REQUIRED (default) — Join existing or create new
@Service
public class OrderService {
@Transactional // starts transaction T1
public void createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
emailService.sendConfirmation(order); // runs in T1 (joined)
}
}
@Service
public class EmailService {
@Transactional // joins T1 if exists, creates new if not
public void sendConfirmation(Order order) {
emailLogRepository.save(new EmailLog(order));
// This is part of T1 — if OrderService rolls back, this rolls back too
}
}
REQUIRES_NEW — Always create a new, independent transaction
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
auditRepository.save(event);
// Runs in its own transaction — always committed regardless of outer transaction
}
}
@Service
public class OrderService {
@Transactional
public void processOrder(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
try {
order.process();
} finally {
// Always audit, even if processing fails
auditService.logAuditEvent(AuditEvent.forOrder(order));
// REQUIRES_NEW: audit is committed even if the outer transaction rolls back
}
}
}
NOT_SUPPORTED — Run without a transaction
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendNotificationEmail(UUID orderId) {
// Suspends the outer transaction
// Runs without a transaction — no rollback if this fails
// Good for: long-running operations that shouldn't hold a DB connection
}
NEVER — Fail if a transaction exists
@Transactional(propagation = Propagation.NEVER)
public void exportReport() {
// Throws if called from within a transaction
// Good for: bulk operations that shouldn't be transactional
}
Other propagation types
| Propagation | Behavior |
|---|---|
REQUIRED | Join existing, or create new |
REQUIRES_NEW | Always create new, suspend existing |
SUPPORTS | Join existing if present, run without if not |
NOT_SUPPORTED | Suspend existing, run without transaction |
MANDATORY | Join existing, throw if none exists |
NEVER | Throw if a transaction exists |
NESTED | Create a savepoint in the existing transaction |
The Self-Invocation Trap
This is the most common @Transactional mistake:
@Service
public class OrderService {
@Transactional
public void createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
// BAD: calling another @Transactional method in the same class
// This goes directly to the method — bypasses the proxy!
sendConfirmation(order); // @Transactional annotation is IGNORED
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmation(Order order) {
// This runs in T1, not a new transaction — the annotation is ignored
}
}
Fix 1: Move to a separate service class (cleanest):
@Service
public class OrderConfirmationService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void send(Order order) { ... }
}
Fix 2: Self-inject the proxy (ugly, avoid):
@Service
public class OrderService {
@Autowired @Lazy
private OrderService self; // injected proxy, not 'this'
public void createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
self.sendConfirmation(order); // goes through proxy — annotation works
}
}
Isolation Levels
Isolation controls what concurrent transactions can see of each other’s changes.
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible |
READ_COMMITTED (typical default) | Prevented | Possible | Possible |
REPEATABLE_READ | Prevented | Prevented | Possible |
SERIALIZABLE | Prevented | Prevented | Prevented |
Dirty Read: Reading uncommitted data from another transaction (that might roll back)
Non-Repeatable Read: Re-reading the same row gives different results (updated by another transaction)
Phantom Read: Re-executing a query gives different rows (inserted/deleted by another transaction)
// Use REPEATABLE_READ to prevent re-read inconsistency
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processHighValueOrder(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
// other transaction updates the order here...
BigDecimal total = order.getTotalAmount(); // same value as the first read
}
// Use SERIALIZABLE for strict consistency (highest isolation, highest contention)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void allocateInventory(UUID productId, int quantity) {
// Other transactions wait — prevents phantom reads of inventory records
}
// READ_COMMITTED is the default for most apps (PostgreSQL default)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readOrderReport() { ... }
Recommendation: Leave isolation at the default DEFAULT (database’s default — usually READ_COMMITTED for PostgreSQL). Only raise isolation when you have a specific concurrency problem.
Read-Only Transactions
For queries, use readOnly = true:
@Service
@Transactional(readOnly = true) // default for the class
public class OrderQueryService {
public Order findById(UUID id) {
return orderRepository.findById(id).orElseThrow();
}
public Page<Order> findAll(Pageable pageable) {
return orderRepository.findAll(pageable);
}
}
Benefits of readOnly = true:
- Hibernate skips dirty checking — no snapshot comparison at transaction end
- Some databases route read-only transactions to read replicas
- Makes intent clear — the compiler won’t catch mutations, but it signals intent
Timeout
Fail a transaction that runs too long:
@Transactional(timeout = 30) // 30 seconds
public void processLargeExport() {
// throws TransactionTimedOutException after 30s
}
Practical Transaction Boundaries
Rule: Place @Transactional at the service layer, never at the repository or controller layer.
Controller — HTTP, no @Transactional
│ calls
▼
Service (@Transactional) — one transaction per use case
│ calls
▼
Repository — inherits the transaction from service
Why not at the repository? Repositories are too granular — a use case often spans multiple repositories. If each repository method runs in its own transaction, you lose atomicity.
Why not at the controller? Controllers deal with HTTP — too high-level. Transaction scope should be as small as possible.
Good pattern:
@RestController
public class OrderController { // no @Transactional
private final OrderService orderService;
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest req) {
Order order = orderService.createOrder(req); // transaction is inside here
return ResponseEntity.created(URI.create("/api/orders/" + order.id())).body(OrderResponse.from(order));
}
}
@Service
public class OrderService { // @Transactional here
@Transactional
public Order createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
inventoryService.reserve(order);
return order;
}
}
Programmatic Transactions
For fine-grained control or when AOP-based transactions don’t work:
@Service
@RequiredArgsConstructor
public class BatchOrderService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
public void importOrders(List<OrderData> data) {
for (OrderData item : data) {
try {
transactionTemplate.execute(status -> {
Order order = orderRepository.save(Order.from(item));
// ... other operations
return order;
});
} catch (Exception e) {
// Log and continue — each order in its own transaction
log.error("Failed to import order {}", item.getId(), e);
}
}
}
}
TransactionTemplate is useful when you need a transaction around code that can’t be in a @Transactional method (e.g., inside a lambda, or in a thread pool task).
What You’ve Learned
@Transactionaluses AOP proxying — only works on public methods called through the Spring proxy- Default rollback on
RuntimeException; userollbackForfor checked exceptions Propagation.REQUIRED(default) joins existing;REQUIRES_NEWcreates independent transaction- Self-invocation bypasses the proxy — move to a separate class to fix
- Isolation levels control concurrent transaction visibility — leave at DB default unless you have a specific problem
readOnly = trueskips dirty checking and enables DB routing to replicas- Place
@Transactionalat the service layer — one transaction per business use case
Next: Article 21 — Database Migrations with Flyway — version-controlled schema evolution without manual SQL.