AOP and proxies

Cross-cutting concerns, JDK versus CGLIB proxies, pointcuts and advice, and the self-invocation problem that silently disables your annotation.

8 min read🌱 Spring Core

The lifecycle lesson ended on a fact that explains most of this one: postProcessAfterInitialization may return a different object than it received, and for @Transactional, @Async, @Cacheable and Spring Security's method annotations, it does — a proxy wrapping the bean your constructor built. This lesson is about what that proxy is, how it is made, and the one thing it cannot do, which trips up every Spring developer exactly once.

A proxy is an object that pretends to be another one

It implements the same contract, holds a reference to the real object, and gets to run code before and after each call.

java
class TransactionalProxy implements PricingService {      // roughly
    private final PricingService target;
 
    @Override public Money quote(Cart cart) {
        var tx = txManager.begin();
        try { var result = target.quote(cart); tx.commit(); return result; }
        catch (RuntimeException e) { tx.rollback(); throw e; }
    }
}

Nothing about that is magic, and @Transactional is not a language feature — it is this class, generated at startup. Everything surprising about AOP follows from the shape above: the advice only runs when the call arrives at the proxy.

JDK dynamic proxies and CGLIB

Spring has two ways to generate that class, and which one it picks changes what your bean is.

JDK dynamic proxyCGLIB
Requiresthe bean implements an interfacenothing
Producesa class implementing the same interfacesa subclass of the bean's class
Cannot proxyanything not in an interfacefinal classes, final methods
Bean's actual type$Proxy37, not your classPricingService$$SpringCGLIB$$0

Spring Boot sets spring.aop.proxy-target-class=true, so CGLIB is the default and the choice rarely comes up. It is the better default: a JDK proxy only exposes interface methods, so injecting the bean by its concrete class fails with a confusing cast error.

CGLIB's requirements are real constraints, and they explain two failures that look unrelated:

  • A final class cannot be subclassed, so it cannot be proxied. Kotlin classes are final by default, which is why kotlin-spring exists to open them.
  • A final method cannot be overridden, so the subclass cannot intercept it. The annotation is simply ignored — no error, no warning, no transaction.

Self-invocation: the one that catches everyone

java
class Demo {
    void outer() {
        System.out.println("outer: " + inTransaction());
        inner();
    }
    void inner() { System.out.println("inner: " + inTransaction()); }
    static boolean inTransaction() { return false; }   // stands in for the real check
}
new Demo().outer();

That is the plain-Java skeleton, and it prints what you expect. Now put @Transactional on inner() and call outer() through Spring, and inner is still not in a transaction.

The reason is the proxy diagram, not a bug:

callerproxy.outertarget.outerthis.innerinner runs
holdsproxyadvice runnonein transactionno

caller. Some other bean holds the proxy, because that is what the container put in the registry, and calls outer().

1 / 5

The annotation is not ignored — the proxy would honour it for a call that arrived from outside. The call simply never arrived.

Three fixes, in the order to prefer them:

  1. Move inner to another bean. The call then crosses a proxy boundary because it crosses an object boundary. This is usually also a better design: the two methods wanted different transactional behaviour, which is a hint they are different responsibilities.
  2. Inject the bean into itself and call self.inner(). Honest, slightly odd, and needs @Lazy to avoid the cycle.
  3. AopContext.currentProxy(), which requires exposeProxy = true and ties the code to Spring. Available, rarely right.

Pointcuts and advice, briefly

When you write aspects yourself rather than consuming annotations:

java
@Aspect @Component
class TimingAspect {
    @Around("@annotation(Timed)")
    Object time(ProceedingJoinPoint pjp) throws Throwable {
        long t0 = System.nanoTime();
        try { return pjp.proceed(); }
        finally { metrics.record(pjp.getSignature().toShortString(), System.nanoTime() - t0); }
    }
}

The advice types are @Before, @After, @AfterReturning, @AfterThrowing and @Around; @Around subsumes the others and is the only one that can prevent the call or change its result, which is also the argument for using the narrower ones when they suffice.

Pointcut expressions match on annotation (@annotation(Timed)), on type (within(com.example.service..*)), or on signature (execution(* com.example..*Service.*(..))). Prefer annotations: an execution expression matched against package names breaks silently on a refactor, and nothing tells you the aspect stopped applying.

Under the hood: where the proxy is decided

AbstractAutoProxyCreator is a BeanPostProcessor. At postProcessAfterInitialization it asks every registered advisor whether it applies to this bean; if any does, it builds a proxy and returns that instead. So the proxy decision is made per bean, at startup, from a static reading of annotations and pointcuts.

Two consequences follow directly:

  • A bean created before the auto-proxy creator is registered is never proxied, which is the early-instantiation warning from the lifecycle lesson and the cause of its @Cacheable walkthrough.
  • getClass() lies, and instanceof does not. A CGLIB proxy is a subclass, so instanceof PricingService holds and getClass() == PricingService.class fails. Code that switches on getClass() or reads annotations from it needs AopProxyUtils.ultimateTargetClass or AopUtils.getTargetClass.

Advisor order is @Order on the aspect. It matters more than it seems: @Transactional outside @Cacheable caches values that a later rollback discards; the other way round, a cache hit never opens a transaction at all.

Walkthrough: the retry that never retried

An @Retryable method on a repository class stopped retrying after a refactor that "cleaned up" the class by making methods final.

CGLIB could not override the final method, so the subclass inherited it unchanged, and the retry advice had nothing to intercept. Nothing failed. No warning was logged. The method simply behaved as if the annotation were a comment — for four months, until a database failover produced the errors the retries had been silently not absorbing.

The check that would have caught it in one line:

java
assertThat(AopUtils.isAopProxy(repository)).isTrue();

Try it yourself

Which calls are advised?

java
@Service
class ReportService {
    @Transactional public void generate() { collect(); }
    @Transactional public void collect() { }
}

An external caller invokes generate(). How many transactions are started?

Answer

One. generate() arrives at the proxy and starts a transaction. collect() is this.collect() — self-invocation — so the proxy never sees it; even if it had, the default REQUIRED propagation would have joined the existing one. Change collect to REQUIRES_NEW and it still gets one transaction, which is the bug that hides behind the first answer looking correct.

Why does the cast fail?

java
@Autowired PricingServiceImpl pricing;   // ClassCastException at startup

PricingServiceImpl implements PricingService.

Answer

With JDK dynamic proxies the proxy implements PricingService and is not a PricingServiceImpl, so the injection cannot be satisfied. Inject the interface — which is the better design anyway — or use CGLIB, which Spring Boot does by default. Seeing this error usually means proxyTargetClass was explicitly turned off somewhere.

Why is the aspect not applying?

An @Around advice on execution(* com.example.service.*Service.*(..)) stopped running. The classes were moved to com.example.core.service.

Answer

The pointcut matches package names as text, so the move stopped it matching. Nothing fails, nothing logs — the advice is registered and matches nothing. This is the argument for @annotation-based pointcuts: a refactor that moves a class carries the annotation with it, and a refactor that removes it is visible in the diff.

Misconceptions

  • "@Transactional on a private method just needs the right settings." A proxy overrides methods; it cannot intercept a private one. The annotation is inert.
  • "The proxy is created when I first call the bean." At startup, in postProcessAfterInitialization, before anything calls anything.
  • "AOP is slow." A proxied call is roughly an extra virtual call plus the advice's own work. What is slow is the advice — a transaction is a database round trip, not an interception cost.
  • "Spring AOP and AspectJ are the same thing." Spring AOP is proxy-based, method-level, runtime. AspectJ weaves bytecode and can advise fields and constructors. Spring uses AspectJ's annotations and pointcut syntax, which is why they are so easily confused.
  • "If the annotation is there, the behaviour is there." A final method, a self-invocation or an early-instantiated bean each make it decoration. All three fail silently.

Going deeper

  • Spring Framework reference, Aspect Oriented Programming with Spring, especially "Understanding AOP Proxies" — the self-invocation section is the canonical explanation.
  • AbstractAutoProxyCreator, DefaultAopProxyFactory and CglibAopProxy in the source, for where the JDK-versus-CGLIB decision is made.
  • AopUtils and AopProxyUtilsisAopProxy, getTargetClass, ultimateTargetClass, the tools for asserting what you actually got.
Progress is saved on this device and to your account when signed in.