skip to content

Private/Final Method Limits

In proxy mode @Transactional only works on public, non-final methods, because a CGLIB subclass cannot override private, final or (effectively) protected ones — and it fails silently. A short question with a big production consequence.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

Why is @Transactional silently ignored when you put it on a private method?

level: juniorimportance: must knowfreq 70%

answer

  1. Proxy wraps the bean, not the method
  2. private = not inherited, can't override
  3. Silent: no error, no warning
  4. Same root cause as final + self-invocation
  5. Fix: make public / extract bean

basics

~20 s

Spring 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
java
@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

for a junior

Must know that @Transactional works via a proxy and only on public methods; private is silently ignored.

for a middle

Should connect it to CGLIB subclassing and name final/self-invocation as siblings of the same limitation.

for a senior

Should explain the silent-participation-in-outer-tx trap and the extract-to-bean fix.

for a principal

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.

context

open as a page

Why does @Transactional fail on final methods, and how does this bite Kotlin developers specifically?

level: middleimportance: should knowfreq 55%

basics

~20 s

CGLIB proxies work by creating a subclass that overrides your methods. A final method can't be overridden, so the transaction advice never attaches. In Kotlin, classes and methods are final by default, so this happens unless you open them.

open as a page

Between JDK dynamic proxies and CGLIB, which method-visibility and finality constraints apply to @Transactional, and how do the two strategies differ?

level: middleimportance: should knowfreq 35%

basics

~20 s

Both only advise public methods in proxy mode. JDK proxies need an interface and advise only public interface methods; CGLIB subclasses the class and can't override private, final, or static methods. Either way, private/final/self-invoked calls are skipped.

open as a page

CGLIB can technically override protected and package-private methods, so why does Spring still ignore @Transactional on them in proxy mode?

level: seniorimportance: should knowfreq 40%

basics

~10 s

It's a deliberate Spring policy, not a pure technical limit. In proxy mode Spring's AnnotationTransactionAttributeSource only reads transactional metadata from public methods, so protected/package-private annotations are ignored even though CGLIB could override them.

open as a page

As a principal engineer, how would you prevent and detect the whole class of 'silently non-transactional method' bugs across a large codebase?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Combine prevention and detection: keep @Transactional on public non-final methods only, enforce it with static analysis/lint rules, add integration tests that assert rollback, and turn on transaction-interceptor DEBUG logging or startup validation to catch silently-skipped advice.

open as a page