@Transactional is a proxy: self-invocation and other traps
The annotation does nothing by itself — a proxy around the bean starts and ends the transaction, so a call that skips the proxy skips the transaction.
After this lesson you can
- Describe what the transactional proxy does before and after your method
- Explain why a self-invoked @Transactional method runs without a transaction
- List the fixes, and the method kinds a proxy cannot intercept
@Transactional is metadata. Nothing in the annotated class begins or
commits anything. What does the work is a proxy: when Spring creates
a bean that has transactional methods, it hands other beans a proxy
object instead of the bean itself. For each call to a transactional
method the proxy:
- asks the
PlatformTransactionManagerfor a transaction (joining an existing one or starting a new one, depending on propagation), which binds a database connection to the current thread; - calls the real method on the target bean;
- commits if the method returns, or rolls back if it throws an exception that the rollback rules cover (next lesson).
Spring Boot creates class-based (CGLIB subclass) proxies by default; without Boot, a bean implementing an interface may get a JDK dynamic proxy instead. Either way, the rule is the same: only calls that go through the proxy are intercepted.
Self-invocation
@Service
public class OrderService {
public void placeOrder(Order o) { // not transactional
validate(o);
this.save(o); // a plain Java call: no proxy involved
}
@Transactional
public void save(Order o) { ... } // runs WITHOUT a transaction here
}
A controller calls placeOrder on the proxy. The proxy sees no
annotation on placeOrder and simply delegates to the target. Inside
the target, this.save(o) is an ordinary method call on the target
object itself; the proxy never sees it, so no transaction starts. The
example builds a real JDK dynamic proxy with a home-made @Tx
annotation and shows exactly this:
import java.lang.annotation.*;import java.lang.reflect.*;import java.util.*; public class Solution { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Tx {} static final List<String> log = new ArrayList<>(); static boolean txActive = false; // stands in for the thread-bound transaction public interface OrderService { void placeOrder(); void save(); } static final class OrderServiceImpl implements OrderService { public void placeOrder() { log.add("placeOrder runs, transaction active=" + txActive); this.save(); // self-invocation } @Tx public void save() { log.add("save runs, transaction active=" + txActive); } } static OrderService proxy(OrderService target) { InvocationHandler h = (p, method, args) -> { Method impl = target.getClass().getMethod(method.getName(), method.getParameterTypes()); if (!impl.isAnnotationPresent(Tx.class)) return method.invoke(target, args); log.add("proxy: begin"); txActive = true; try { Object result = method.invoke(target, args); log.add("proxy: commit"); return result; } finally { txActive = false; } }; return (OrderService) Proxy.newProxyInstance( OrderService.class.getClassLoader(), new Class<?>[]{OrderService.class}, h); } public static List<String> run() { OrderService service = proxy(new OrderServiceImpl()); // what other beans receive log.add("--- controller calls placeOrder()"); service.placeOrder(); log.add("--- controller calls save() directly"); service.save(); return log; }}The same applies to @Cacheable, @Async, @PreAuthorize and
@Retryable: anything implemented by a proxy is skipped by
self-invocation.
Fixes
- Move the method to another bean and inject it. The call then goes through that bean's proxy. This is usually also the better design.
- Put the annotation on the outer method (
placeOrder) when the whole operation should be one transaction anyway. - Use a
TransactionTemplatefor a programmatic transaction around exactly the code that needs it. - Inject the bean's own proxy (
@Lazyself-injection) — it works, but it is a workaround that hides the structure.
What else a proxy cannot see
A class-based proxy is a subclass, so it cannot override private or
final methods: annotating them has no effect. Since Spring
Framework 6.0, @Transactional on a protected or package-private
method also takes effect with a class-based proxy (before 6.0 only
public methods counted); an interface-based JDK proxy still sees only
the public interface methods, so public remains the safe convention. And
because the transaction is bound to the thread, work handed to another
thread — an @Async method, a CompletableFuture, a parallel stream —
does not run in the caller's transaction.
Common mistakes
- Annotating a helper method and calling it from the same class, as in the example: no transaction, and no error to tell you.
Exercise · Pro
Which calls run in a transaction
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade