@Transactional: The Pitfalls Nobody Warns You About
@Transactional is a proxy, not a keyword — and that one fact explains self-invocation, silent commits on checked exceptions, and most production data bugs.
@Transactional looks like a keyword, but it is a runtime trick: Spring wraps your bean in a proxy and the annotation is just metadata that proxy reads. Almost every "the transaction didn't roll back" bug traces back to forgetting that — the rules live in the proxy, not in your method body.
1. How it actually works: AOP proxies
When Spring sees @Transactional on a bean, it doesn't inject transaction code into your class. It hands callers a proxy — a dynamic JDK proxy (interface-based) or a CGLIB subclass (class-based, the default in Spring Boot). The proxy intercepts the call, opens or joins a transaction via the PlatformTransactionManager, invokes your real method, and then commits or rolls back.
caller --> proxy --> [ begin tx ] --> your method --> [ commit / rollback ]
The crisp interview answer: transactional behavior only exists on calls that pass through the proxy. Every gotcha below is a corollary of that one sentence. A bean only gets a proxy when it is a Spring-managed bean — new MyService() is never transactional, no matter how many annotations it has.
2. Self-invocation: the bug everyone hits once
If a method calls another method on the same instance via this, the call never leaves the object, so it never crosses the proxy. The annotation on the inner method is silently ignored.
@Service
public class OrderService {
public void placeOrders(List<Order> orders) {
for (Order o : orders) {
// BUG: plain `this.saveOrder(o)` — bypasses the proxy.
// No new transaction is opened here.
saveOrder(o);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrder(Order o) {
repository.save(o); // runs in placeOrders' tx context (or none)
}
}
You expected each order to commit in its own transaction. Instead they all share whatever context placeOrders ran in — frequently no transaction at all, so each save auto-commits and your REQUIRES_NEW guarantee evaporates. No exception, no warning. The annotation was real; the call path wasn't.
The fix: route the inner call through the proxy. The cleanest option is to move the transactional method into a separate bean so the call is a genuine cross-bean invocation:
@Service
public class OrderService {
private final OrderWriter writer; // a different bean -> a different proxy
public void placeOrders(List<Order> orders) {
for (Order o : orders) {
writer.saveOrder(o); // crosses the proxy -> REQUIRES_NEW honored
}
}
}
@Service
public class OrderWriter {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrder(Order o) {
repository.save(o);
}
}
If you can't split the class, inject the bean into itself and call through the reference (so the call hits the proxy), or grab the current proxy explicitly. Both work; both are uglier than just separating the responsibilities:
// Option A: self-injection (Spring resolves this to the proxy, not `this`)
@Autowired private OrderService self;
public void placeOrders(...) { self.saveOrder(o); }
// Option B: AopContext (requires exposeProxy = true on @EnableAspectJAutoProxy)
((OrderService) AopContext.currentProxy()).saveOrder(o);
Prefer the separate-bean refactor. Self-injection and AopContext are escape hatches that hide the real design smell — a single bean wearing two transactional hats.
3. private, final, and non-public methods are invisible
Same root cause, different surface. The proxy can only advise methods it can intercept:
- private methods — never advised in the default proxy mode. Spring silently ignores the annotation (there is no startup error). Full AspectJ load-time or compile-time weaving can advise non-public and private methods, because weaving rewrites the bytecode rather than wrapping the object — but that is opt-in and not how Spring Boot works out of the box.
- final methods / final classes — CGLIB works by subclassing and overriding. A
finalmethod (or class) can't be overridden, so it can't be advised. With Spring Boot's default CGLIB proxying this is a silent no-op: the bean starts, but the annotation does nothing (Spring may log an INFO/WARN about the unproxyable method). - protected / package-private — ignored in the default proxy mode just like private; only AspectJ weaving picks them up. Treat them as unsupported.
Rule: annotate public methods, called from another bean. Anything else is a coin flip you'll lose in production.
4. Rollback rules: checked exceptions do NOT roll back
This is the one that quietly corrupts data. By default Spring rolls back only on RuntimeException and Error (and their subclasses). A checked exception commits the transaction on its way out.
@Transactional
public void transfer(...) throws InsufficientFundsException {
debit(from); // already written...
if (balance < 0)
throw new InsufficientFundsException(); // checked -> COMMITS the debit
}
The debit is now permanent, the credit never happened, and money has vanished. The reason is historical: this default mirrors EJB's container-managed transaction semantics, where unchecked (system) exceptions trigger rollback and checked (application/"business") exceptions are treated as recoverable and committed unless the bean says otherwise. Spring kept the default for familiarity — but it surprises everyone exactly once.
Be explicit. Either declare the checked exceptions that should roll back, or roll back on everything:
@Transactional(rollbackFor = InsufficientFundsException.class)
// or, the blunt-but-safe default many teams adopt:
@Transactional(rollbackFor = Exception.class)
And know the inverse trap: if you catch the exception inside the transactional method and swallow it, nothing propagates to the proxy, so the transaction commits regardless of rollbackFor. The proxy can only react to exceptions actually thrown out of the method. If you catch-and-handle but still want a rollback, call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
5. Propagation: REQUIRED vs REQUIRES_NEW vs NESTED
Propagation decides what happens when a transactional method is called while a transaction is already in progress. The three that matter:
- REQUIRED (the default) — join the existing transaction if there is one, otherwise start a new one. One physical transaction; one commit; one rollback. If an inner
REQUIREDcall fails and you catch the exception in the outer method, the whole thing is still doomed — the shared transaction is marked rollback-only, and the outer commit throwsUnexpectedRollbackException. - REQUIRES_NEW — always suspend the current transaction and start a brand-new, independent one. It commits or rolls back on its own. Use it for things that must persist even if the caller fails, like writing an audit record or an outbox entry. Cost: while the inner transaction runs you hold two database connections at once (outer suspended but still owning its connection, inner active) — a real risk of pool exhaustion under load.
- NESTED — one physical transaction, but a JDBC
SAVEPOINTis created. The inner block can roll back to the savepoint without killing the outer transaction. This requires a transaction manager with savepoint support —DataSourceTransactionManagerover a JDBC driver that supports savepoints. With JPA,JpaTransactionManagerdoes not support NESTED and throwsNestedTransactionNotSupportedException, so in a typical Hibernate/JPA app NESTED is effectively unavailable. Useful (when supported) for "try this sub-step, undo just it on failure."
The distinction interviewers probe: REQUIRES_NEW gives you a separate transaction (independent commit, separate connection); NESTED gives you a partial rollback point inside the same transaction. They are not interchangeable.
6. Isolation: set at the start, and it varies by database
Isolation is the other axis — READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE — and it's set via @Transactional(isolation = ...). Two caveats. First, the default is Isolation.DEFAULT, which means "whatever the database is configured for" (PostgreSQL: read-committed; MySQL/InnoDB: repeatable-read) — so the same code behaves differently across engines. Second, raising isolation on a method that joins an existing transaction has no effect: isolation is fixed when the physical transaction begins, and a participating REQUIRED call can't change it midstream. By default Spring silently ignores the mismatch; only with validateExistingTransaction=true does it throw instead.
7. The anti-pattern: a transaction open across a network call
A database transaction holds a connection from the pool and, depending on isolation and the statements run, can hold locks for its entire lifetime. So the single worst thing you can do is keep one open while you wait on the network:
@Transactional
public void enrichAndSave(Long id) {
Order o = repo.findById(id).orElseThrow();
// BAD: a slow REST/RPC call runs INSIDE the transaction.
var data = paymentClient.fetch(o.getRef()); // 200ms? 30s on a hang?
o.setStatus(data.status());
// connection held + any locks held for the entire round-trip
}
The transaction stays open for the duration of the remote call. A slow or hung downstream service now pins a pooled connection and whatever locks it holds; enough concurrent requests and the pool is exhausted, throughput collapses, and unrelated queries time out. You've coupled your database's health to a third party's latency.
The fix is to keep transactions short and shaped like database work only: do the I/O outside the transactional boundary, then open a brief transaction purely to persist the result. (Note the same self-invocation caveat applies — the transactional helpers below must live on a bean reached through the proxy, e.g. a separate bean, for their boundaries to take effect.)
public void enrichAndSave(Long id) {
Order o = readOrder(id); // tx 1: quick read (or none)
var data = paymentClient.fetch(o.getRef()); // network call: NO tx held
persistStatus(id, data.status()); // tx 2: quick write
}
@Transactional
public void persistStatus(Long id, Status s) { /* ... */ }
The same logic condemns Open-Session-In-View (on by default in Spring Boot), which keeps the persistence context — and lazy-loading capability — alive through view rendering. It's convenient and it hides where your transaction boundaries really are; disable it (spring.jpa.open-in-view=false) and make the boundaries explicit.
Rules of thumb
- It's a proxy. Transactional behavior applies only to
publicmethods called from another bean. Self-invocation,private, andfinalall bypass it silently in the default proxy mode (only AspectJ weaving reaches non-public methods). - Checked exceptions commit by default. Set
rollbackForexplicitly, and never swallow an exception you wanted to trigger a rollback — usesetRollbackOnly()if you must catch it. - REQUIRES_NEW = a separate transaction and connection; NESTED = a savepoint in the same one (and NESTED isn't supported by JpaTransactionManager). Pick deliberately, and remember REQUIRES_NEW doubles your connection footprint while the inner tx runs.
- Isolation is fixed at transaction start. The default delegates to the database, so it varies by engine; a joining call can't raise it.
- Never hold a transaction across network I/O. Keep transactions short and database-only; do remote calls outside the boundary and turn off open-in-view.