@Transactional Pitfalls: 7 Ways Your Spring Transaction Silently Doesn't Work
Self-invocation, checked exceptions, private methods, swallowed exceptions and more — the common reasons @Transactional does nothing or doesn't roll back, and how to fix each one.
@Transactional looks like magic: annotate a method, get a transaction. But it works through a proxy, and it has a few rules that aren't obvious. Break one, and you get no error — just data that doesn't roll back when it should. Here are the seven most common pitfalls.
First: how @Transactional works
Spring wraps your bean in a proxy (a CGLIB subclass by default in Spring Boot). When another bean calls a @Transactional method, the call goes through the proxy, which:
- opens (or joins) a transaction,
- calls your method,
- commits if it returns normally, or rolls back if it throws a rolling-back exception.
Almost every pitfall comes from something that bypasses the proxy or changes how the exception reaches it.
Pitfall 1: Calling the method from the same class (self-invocation)
@Service
public class OrderService {
public void placeOrder(Order o) {
saveOrder(o); // this.saveOrder() — goes around the proxy
}
@Transactional
public void saveOrder(Order o) { ... }
}
this.saveOrder() calls the real object directly, so no transaction starts. The same applies to @Async, @Cacheable and @Retryable.
Fix: put @Transactional on the method called from outside (placeOrder), or move saveOrder to another bean and inject it.
Pitfall 2: Checked exceptions don't roll back
By default, Spring rolls back only on unchecked exceptions (RuntimeException, Error). A checked exception, such as IOException or your own PaymentException extends Exception, commits the transaction.
Fix:
@Transactional(rollbackFor = Exception.class)
public void pay(Order o) throws PaymentException { ... }
Pitfall 3: Catching the exception yourself
@Transactional
public void transfer(...) {
try {
debit(...);
credit(...); // throws
} catch (Exception e) {
log.error("failed", e); // exception swallowed → the proxy sees a normal return → COMMIT
}
}
The debit is committed without the credit. Fix: rethrow the exception, or mark the transaction for rollback: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
Pitfall 4: Non-public methods (and older Spring versions)
Until Spring 6.0, @Transactional on private, protected or package-private methods was ignored. Spring 6 supports protected and package-private methods on class-based (CGLIB) proxies, but private methods still can't be proxied. Keep transactional methods public to stay safe across versions and proxy types.
Pitfall 5: The bean isn't a Spring bean
If you create the object with new OrderService(), there's no proxy, so no transaction. The same happens with objects built by hand inside factories. Let Spring create and inject it.
Pitfall 6: The wrong propagation
REQUIRED (the default) joins an existing transaction. If an inner @Transactional method throws, the whole outer transaction is marked rollback-only, even if the outer method catches the exception. The outer commit then fails with UnexpectedRollbackException.
- Use
REQUIRES_NEWfor work that must commit independently, such as audit logs. It suspends the outer transaction and uses a separate connection, so watch your connection pool. - Use
NESTED(savepoints, JDBC only) to roll back just the inner part.
Pitfall 7: Long transactions around remote calls
@Transactional
public void checkout(Cart c) {
orderRepo.save(order);
paymentClient.charge(card); // 5-second HTTP call while holding a DB connection and locks
emailClient.send(receipt);
}
The transaction holds a database connection (and possibly row locks) for the whole remote call. Under load, the connection pool runs dry, and the email is sent even if the commit later fails.
Fix: keep transactions short and database-only. Do remote calls outside the transaction, and publish side effects after commit with @TransactionalEventListener(phase = AFTER_COMMIT), or with the outbox pattern for reliability.
Quick checklist
- Is the method called through the proxy (from another bean)?
- Is it public?
- Do checked exceptions need
rollbackFor? - Are exceptions caught and swallowed anywhere?
- Is the propagation right for nested calls?
- Is the transaction short, with no slow remote calls inside it?
To see what actually happens, enable transaction logging:
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
Follow-up questions this topic invites — and their answers
Q: Does @Transactional(readOnly = true) do anything?
A: Yes. It hints to the JPA provider to skip dirty checking and flushing (Hibernate sets the session to read-only), and some drivers and routing data sources use it to send the query to replicas. It doesn't prevent writes at the database level by itself.
Q: Should @Transactional go on the controller, the service or the repository? A: On the service layer, where a business operation spans several repository calls. Spring Data repository methods are already transactional individually.
Q: How do you test that a rollback happens?
A: With an integration test (@SpringBootTest + Testcontainers) that triggers the failure and then asserts the database state. Unit tests with mocks can't show proxy behaviour.
Q: Why does UnexpectedRollbackException appear?
A: An inner method (same transaction) marked it rollback-only, and the outer method still tried to commit. Let the exception propagate, or use REQUIRES_NEW / NESTED intentionally.
More in the @Transactional deep dive and our Spring interview questions.
Related Posts
Resilient Microservices: Timeouts, Retries and Circuit Breakers Done Right
One slow dependency can take down a whole system. How timeouts, retries with backoff, circuit breakers and bulkheads work together — with Resilience4j examples for Spring Boot.
Dependency Injection Explained (and Why Spring Gets It Right)
DI is one of the most misunderstood patterns in software. Here's a clear explanation — from the problem it solves to how Spring's IoC container works under the hood.
What's New in Spring Boot 3
Spring Boot 3 ships with Java 17 baseline, native AOT compilation, and major security upgrades. Here's everything you need to know before migrating.