← all posts
·2 min read

Why your Spring @Transactional silently does nothing

JavaSpring BootJPA

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.