Why your Spring @Transactional silently does nothing
One of the most confusing bugs in a Spring application is a @Transactional method that just… does not start a transaction. There is no exception, no warning in the logs — a partial write commits when it should have rolled back. Almost every case comes down to one fact: @Transactional is implemented with AOP proxies.
How @Transactional actually works
When Spring sees @Transactional, it wraps your bean in a proxy. The proxy opens a transaction before your method runs and commits or rolls back after it returns. Your calling code holds a reference to the proxy, not to your object directly — and that indirection is where every failure mode comes from. If a call never passes through the proxy, no transaction is ever started.
Failure 1: self-invocation
If method A calls method B on the same object (this.b()), the call never leaves the instance, so it never passes through the proxy. B's @Transactional is silently ignored. This is the single most common cause.
@Service
public class BadService {
@Transactional
public void outer() { inner(); } // self-invocation: proxy bypassed
@Transactional
public void inner() { /* NOT transactional when called from outer() */ }
}The fixes: move B into a separate bean (so the call goes through that bean's proxy), self-inject the proxy, or switch to AspectJ compile-time weaving, which does not use proxies at all.
Failure 2: private and final methods
The default CGLIB proxy works by subclassing your bean and overriding methods. A private method cannot be overridden, and neither can a final one — so @Transactional on either is silently ignored for exactly the same reason as self-invocation. Keep transactional methods public and non-final.
Failure 3: checked exceptions do not roll back
By default Spring rolls back only on unchecked exceptions (RuntimeException and Error). If your method throws a checked exception, the transaction commits — including any partial work. A huge number of real data-integrity bugs are exactly this.
// Commits! Checked exceptions do NOT roll back by default.
@Transactional
public void a() throws IOException {
repo.save(x);
throw new IOException(); // x is committed
}
// Correct
@Transactional(rollbackFor = Exception.class)
public void b() throws IOException {
repo.save(x);
throw new IOException(); // now rolls back
}And a related trap: if you catch an exception inside a transactional method, Spring never sees it and the transaction commits. Swallowing exceptions and committing partial work is a bug that hides in plain sight.
How to debug it
- ▹Is the method called from within the same class? That is self-invocation — refactor it out.
- ▹Is the method public and non-final? The proxy cannot intercept anything else.
- ▹Are you throwing a checked exception you expect to roll back? Add rollbackFor.
- ▹Are you catching the exception inside the method? Then it commits — rethrow or mark the transaction rollback-only.
Once you internalise 'it is a proxy', all three failures become obvious variations of the same rule: the transaction only exists on calls that actually pass through the proxy.