Why is @Transactional silently ignored when you put it on a private method?
answer
- Proxy wraps the bean, not the method
- private = not inherited, can't override
- Silent: no error, no warning
- Same root cause as final + self-invocation
- Fix: make public / extract bean
basics
~20 sSpring adds transactions with a proxy — a wrapper object around your bean. The proxy can only intercept public methods it can override. Private methods can't be overridden, so the annotation is skipped with no error.
solid answer
~40 s@Transactional is not magic on the method itself; Spring wraps the bean in a proxy that opens a transaction before your method and commits/rolls back after. A proxy is a subclass (CGLIB) or interface implementation (JDK dynamic proxy) that overrides your methods to insert this behavior. Private methods are not inherited and cannot be overridden, so the proxy has no way to wrap them — the annotation is silently ignored, no exception, no warning. The method just runs with no transaction (or joins whatever transaction the caller already started). The fix is to make the method public and, ideally, move it onto a different bean if it's called internally. This same limitation is why final methods and self-invocation also break @Transactional.
code
java · 16 lines@Service
public class OrderService {
// BROKEN: private -> proxy can't override it -> @Transactional ignored
@Transactional
private void saveOrder(Order o) {
repo.save(o);
throw new IllegalStateException("boom"); // NOT rolled back reliably
}
// FIXED: public and non-final, called through the proxy from another bean
@Transactional
public void placeOrder(Order o) {
repo.save(o);
}
}go deeper
Must know that @Transactional works via a proxy and only on public methods; private is silently ignored.
Should connect it to CGLIB subclassing and name final/self-invocation as siblings of the same limitation.
Should explain the silent-participation-in-outer-tx trap and the extract-to-bean fix.
Should discuss detection strategy, AspectJ weaving as the escape hatch, and why Spring chose public-only.
### What @Transactional actually does When you annotate a method with `@Transactional`, Spring does **not** rewrite that method. Instead, at startup Spring wraps your bean in a **proxy** — a stand-in object with the same type. Every other bean that autowires yours actually receives the proxy. When someone calls the method, the call hits the proxy first; the proxy asks the `PlatformTransactionManager` to begin a transaction, then delegates to your real method, then commits on success or rolls back on a runtime exception. ### Why a proxy can't touch private methods Spring builds this proxy in one of two ways: - **CGLIB** (the default since Spring Boot 2.x): generates a **subclass** of your class at runtime and **overrides** each method to add the transaction logic. - **JDK dynamic proxy**: implements your bean's **interfaces**. Both strategies rely on **method overriding / interface implementation**. In Java a `private` method: 1. is **not inherited** by subclasses, and 2. **cannot be overridden**. So the CGLIB subclass literally cannot see or wrap a private method, and it isn't part of any interface either. The transaction advice has no seam to attach to. Spring's `AnnotationTransactionAttributeSource` therefore never applies transaction metadata to it. ### Why it's *silent* (the dangerous part) Spring raises **no error and no warning** by default. The code compiles, the app starts, the method runs — it just runs **without** the transactional semantics you think it has. If the caller already has a transaction open, the private method quietly runs inside *that* one (because it's the same thread/connection), which can mask the bug until the day it's called with no surrounding transaction. ### Related failures with the same root cause - **`final` methods**: CGLIB can't override a final method → advice skipped. - **`protected` / package-private methods**: in proxy mode Spring intentionally restricts transactional advice to **public** methods only, so these are ignored too. - **Self-invocation**: calling `this.someTransactionalMethod()` from another method in the same class bypasses the proxy entirely (you're calling the raw object, not the proxy), so even a public @Transactional method won't start a transaction. ### How to fix - Make the method **public** and **non-final**. - If it's an internal helper, **extract it to a separate bean** and call it through that bean's proxy. - Or switch to **AspectJ load-time/compile-time weaving** (`@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)`), which weaves the advice directly into the bytecode and can handle non-public and self-invocation cases — at the cost of extra build/runtime setup. ### Detection Enable `DEBUG` logging on `org.springframework.transaction.interceptor` — you'll see a transaction created only for methods that were actually advised. There's no compile-time check in plain Spring, which is exactly why this is a classic interview trap.
- Does Spring log a warning when it skips @Transactional on a private method?No. By default it is completely silent — no exception, no log. The method just runs without the transaction. You have to enable DEBUG logging on the transaction interceptor or write a test to catch it.
- If a private @Transactional method is called from a public @Transactional method, does anything roll back?The private method's own annotation is ignored, but because it runs on the same thread inside the caller's active transaction, its DB work participates in that outer transaction and will roll back with it. That accidental behavior is what hides the bug.