You add @Transactional, expect a rollback… and the data still commits.
Most of the time, Spring transactions are working exactly as designed — it’s just that your code never actually enters a transactional boundary. The reason is simple:
Spring usually applies transactions via AOP proxies.
If your call bypasses the proxy, the transaction advice never runs.
Here are 7 common gotchas I see in real projects, plus practical fixes.
0) Quick mental model: where transactions actually start
With proxy-based AOP, a transaction starts when:
- A Spring-managed bean method is called
- and that call goes through the proxy
- and the method has transactional metadata (
@Transactional)
So the key question when debugging is always:
“Did this method call go through the Spring proxy?”
1) Self-invocation: calling your own method bypasses the proxy
This is the #1 reason people say “transaction didn’t work”.
@Service
public class OrderService {
public void placeOrder() {
saveOrder(); // self-invocation -> no proxy -> @Transactional not applied
}
@Transactional
public void saveOrder() {
// write to DB
throw new RuntimeException("boom"); // you expect rollback
}
}
What happens?
placeOrder() calls saveOrder() directly on this. No proxy involved. No transaction.
✅ Fix options (pick one):
Fix A (recommended): move transactional method to another bean
@Service
public class OrderWriter {
@Transactional
public void saveOrder() { ... }
}
@Service
public class OrderService {
private final OrderWriter writer;
public OrderService(OrderWriter writer) { this.writer = writer; }
public void placeOrder() {
writer.saveOrder(); // goes through proxy
}
}
Fix B: use TransactionTemplate for programmatic transactions
@Service
public class OrderService {
private final TransactionTemplate tx;
public OrderService(PlatformTransactionManager tm) {
this.tx = new TransactionTemplate(tm);
}
public void placeOrder() {
tx.executeWithoutResult(status -> {
// write to DB
if (somethingBad()) throw new RuntimeException("boom");
});
}
}
2) Method visibility: non-public methods often won’t be transactional
By default (proxy-based AOP), Spring typically only applies @Transactional to public methods.
@Service
public class BillingService {
@Transactional
void charge() { // package-private: often ignored
...
}
}
✅ Fix: make the transactional boundary public, or switch to AspectJ weaving (advanced).
@Transactional
public void charge() { ... }
3) final classes/methods: proxies can’t override them
If Spring uses CGLIB class-based proxies, final prevents overriding.
@Service
public final class PaymentService {
@Transactional
public void pay() { ... } // cannot be proxied if class/method is final
}
✅ Fix:
- remove
final, or - use interface-based proxies (JDK proxy) and call through the interface, but final methods still can’t be advised.
In practice: avoid final on Spring service classes/methods that need AOP.
4) Checked exceptions don’t rollback by default
By default, Spring rolls back on:
RuntimeExceptionError
…but not on checked exceptions.
@Transactional
public void ship() throws Exception {
// write DB
throw new Exception("checked"); // by default: transaction may COMMIT
}
✅ Fix: configure rollback rules explicitly.
@Transactional(rollbackFor = Exception.class)
public void ship() throws Exception {
...
}
Or throw a runtime exception intentionally (be consistent in your codebase).
5) Catching exceptions (and swallowing them) commits the transaction
This one is sneaky.
@Transactional
public void updateProfile() {
try {
// write to DB
riskyOperation();
} catch (Exception e) {
// log and swallow
}
// method ends normally -> transaction commits
}
✅ Fix options:
Fix A: rethrow
catch (Exception e) {
throw e;
}
Fix B: mark rollback-only
catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
Use rollback-only sparingly — rethrowing is often clearer.
6) @PostConstruct / constructor calls: proxy isn’t ready yet
If you call transactional methods during bean initialization, you can bypass the proxy lifecycle.
@Service
public class WarmUpService {
@PostConstruct
public void init() {
warmUp(); // often not transactional the way you expect
}
@Transactional
public void warmUp() { ... }
}
✅ Fix: run warmup after context is fully ready:
ApplicationReadyEventCommandLineRunner- scheduling after startup
@Component
public class WarmUpRunner implements ApplicationRunner {
private final WarmUpService svc;
public WarmUpRunner(WarmUpService svc) { this.svc = svc; }
@Override
public void run(ApplicationArguments args) {
svc.warmUp(); // goes through proxy
}
}
7) Async / new threads: transactions are thread-bound
A transaction is bound to the current thread. If you start a new thread, it won’t share the same transaction context.
@Transactional
public void process() {
// write in tx
CompletableFuture.runAsync(() -> {
// NOT in the same tx
// writes here are separate
});
}
✅ Fix patterns:
- Make the async method start its own transaction
- Or use messaging / outbox pattern for consistency
- Or redesign: keep DB transaction small, push slow work out of it
A quick debugging checklist
When you suspect “transaction didn’t work”, check these in order:
- Is the method
public? - Is it called from another bean (through proxy), not
this.? - Any
finalclass/method involved? - Did you throw a runtime exception? Or a checked exception?
- Did you catch and swallow exceptions?
- Was it invoked during initialization (
@PostConstruct)? - Any async/thread boundaries?
Also helpful: enable transaction logs (for Spring Boot):
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
You should see logs like “Creating new transaction” / “Rolling back” / “Committing”.
Recommended rule of thumb
If you want fewer surprises:
- Put transaction boundaries on public service methods
- Keep them short
- Avoid calling transactional methods inside the same class
- Don’t mix DB transactions with slow network calls
- Use
TransactionTemplateif you need explicit control
Top comments (0)