skip to content

Why doesn't Spring AOP advice fire on final, private, or static methods when using CGLIB proxies?

level: middleimportance: must knowfreq 65%

answer

  1. override is the only hook
  2. final method: can't override
  3. private: not inherited, static dispatch
  4. static: no instance dispatch
  5. silent skip — no error

basics

~10 s

CGLIB works by subclassing your class and overriding methods. Java doesn't let a subclass override final, private, or static methods, so those methods can't be intercepted and their advice is silently skipped.

solid answer

~50 s

A CGLIB proxy is a runtime-generated subclass that adds advice by overriding each method to route through an interceptor. Overriding is the only hook, so anything that can't be overridden can't be advised: a final method can't be overridden, a private method isn't inherited/visible to the subclass, and a static method belongs to the class rather than an instance so there's no virtual dispatch to intercept. Additionally, a final class can't be subclassed at all, so it can't be proxied by CGLIB — you'd get a proxy-creation failure. The dangerous part is that missed advice on final/private/static methods is silent: no error, the code just runs without the transaction, cache, or aspect. The fix is to make advised methods public (or at least non-final, non-static) and to avoid marking classes final if they need proxying.

code

java · 19 lines
java
@Service
public class ReportService {

    // WON'T be advised: final can't be overridden by the CGLIB subclass
    @Transactional
    public final void generate() { /* runs WITHOUT a transaction, silently */ }

    // WON'T be advised: private isn't overridable
    @Cacheable("x")
    private String helper() { return "..."; }  // cache never applied

    // WON'T be advised: static has no instance dispatch
    @Async
    public static void kickOff() { /* runs synchronously */ }

    // OK: public, non-final, non-static → overridable → advised
    @Transactional
    public void save() { /* wrapped in a transaction */ }
}

go deeper

for a junior

Know the three keywords (final/private/static) plus final class break CGLIB proxying.

for a middle

Explain WHY each fails via overriding/dispatch and that the skip is silent.

for a senior

Connect to Kotlin all-open, AspectJ alternatives, and debugging 'advice not firing'.

for a principal

Reason about API-design conventions (keep advised methods public/non-final) and weaving-strategy tradeoffs at the codebase level.

**The mechanism that requires overriding.** CGLIB adds behavior by generating `class YourType$$SpringCGLIB$$0 extends YourType`. For each method it wants to advise, it emits an override that calls a `MethodInterceptor` (the advice chain) and then, if appropriate, `super.method(...)`. Interception is therefore fundamentally tied to Java method **overriding** and **virtual dispatch**. If a method can't be overridden, there's no place to insert the interceptor. **Case-by-case:** - **`final` method** — the Java language forbids a subclass from overriding it. CGLIB simply can't generate an override, so calls hit the original body directly with no advice. No exception is thrown; the advice is just absent. - **`private` method** — private methods are not inherited and are not part of the subclass's visible API; they use static (non-virtual) dispatch. The subclass can't override them, and even internal calls to them resolve to the original body. So they can't be advised. - **`static` method** — belongs to the class, not to any instance. There is no polymorphic dispatch through an object reference, and CGLIB proxies are *instances* of a subclass. Static methods therefore have no interception point. - **`final` class** — cannot be subclassed at all. CGLIB cannot generate a subclass, so Spring cannot create the proxy. Depending on configuration this surfaces as a proxy-creation error at startup (or Spring falls back / fails, rather than silently producing an unadvised bean). **Why 'silent' is the real trap.** For final/private/static *methods* on an otherwise-proxyable class, Spring successfully builds the proxy for the other methods; the un-overridable ones simply pass through unadvised. So `@Transactional` on a `final` or `private` method compiles, starts, and runs — but without a transaction. This is a classic source of 'my transaction/cache isn't working' bugs. **Related trap — self-invocation.** Even a perfectly overridable public method won't be advised if it's called from *within the same object* (`this.other()`), because that call doesn't go through the proxy. This is a separate issue from final/private/static but often confused with it. **Kotlin note.** In Kotlin, classes and methods are `final` by default. That's why Spring Boot Kotlin projects use the `kotlin-spring` (all-open) compiler plugin, which automatically makes `@Component`/`@Transactional`/etc. classes and their methods open so CGLIB can subclass them. **How to avoid the problem:** - Make advised methods `public` (or at least non-final, non-private, non-static). - Don't mark a class `final` if it needs proxying (or rely on `kotlin-spring` in Kotlin). - Prefer interface-based design + JDK proxies when it fits, which sidesteps subclassing entirely (though the final/self-invocation issues for *method visibility* still apply differently).

  • Does Spring throw an error if you put @Transactional on a final method?
    No — with CGLIB it's silently ignored (the method runs without a transaction). That silent failure is exactly why it's dangerous. AspectJ compile/load-time weaving could handle it, but standard Spring AOP can't.
  • How do Kotlin Spring projects deal with final-by-default?
    The kotlin-spring (all-open) compiler plugin marks Spring-annotated classes and methods as open, letting CGLIB subclass and override them.

saying these in an interview costs you the question

  • Claiming Spring throws a clear error for @Transactional on a final/private method (it's silently skipped)
  • Saying private methods can be proxied if you make the interceptor reflective
  • Confusing this with self-invocation as the only cause

context