Explain @Transactional — propagation, isolation, and common pitfalls (like calling a @Transactional method from within the same class).
Quick Answer
@Transactional wraps a method's execution in a database transaction — committing on normal completion, rolling back on an unchecked exception (checked exceptions don't trigger rollback by default). Propagation controls how a transactional method behaves when called from within an existing transaction (REQUIRED joins it, the default; REQUIRES_NEW suspends it and starts a fresh one); isolation controls how concurrent transactions see each other's uncommitted/committed changes. Because @Transactional is implemented via an AOP proxy, calling a @Transactional method on 'this' from another method in the same class bypasses the proxy entirely, silently skipping the transaction.
Detailed Answer
@Transactional wraps a method's execution in a database transaction: if the method completes normally, the transaction commits; if it throws an unchecked exception (a RuntimeException or Error), the transaction rolls back by default (checked exceptions do not trigger a rollback unless explicitly configured via rollbackFor).
@Service
class OrderService {
@Transactional
void placeOrder(Order order) {
orderRepository.save(order);
inventoryService.reserveStock(order); // if this throws, the save() above is rolled back too
}
}
Propagation controls how a @Transactional method behaves when it's called from a context that's already inside a transaction:
REQUIRED(default): join the existing transaction if one exists, otherwise start a new one.REQUIRES_NEW: always suspend any existing transaction and start a completely independent new one — useful when a sub-operation (e.g., writing an audit log entry) must commit regardless of whether the outer transaction eventually rolls back.NESTED: runs within a savepoint of the outer transaction (database-dependent support), allowing partial rollback to that savepoint without rolling back the entire outer transaction.- Others (
SUPPORTS,MANDATORY,NOT_SUPPORTED,NEVER) handle rarer cases of "run with or without a transaction" / "must already be in one" / "must not be in one."
Isolation controls what a transaction can see of other concurrent transactions' changes — READ_COMMITTED (common default), REPEATABLE_READ, SERIALIZABLE (strongest, least concurrent), READ_UNCOMMITTED (weakest) — trading consistency guarantees against concurrency/throughput, and ultimately constrained by what the underlying database actually supports for each level.
The classic self-invocation pitfall: @Transactional (like most Spring annotation-driven behavior) is implemented via an AOP proxy wrapping the bean. Calling a @Transactional method on this, from another method in the same class, bypasses the proxy entirely — the call goes directly to the real object, so the transactional advice never runs:
@Service
class OrderService {
void processOrder(Order order) {
saveOrder(order); // calls the REAL object directly, not through the proxy — no transaction runs!
}
@Transactional
void saveOrder(Order order) { ... }
}
Fix: move the @Transactional method to a separate bean (a common, clean solution), or inject a self-reference proxy (@Lazy self-injection, or AopContext.currentProxy() with exposeProxy = true) if restructuring genuinely isn't feasible — though extracting the logic into a separate collaborator bean is almost always the cleaner fix.