AOP and proxies
Cross-cutting concerns, JDK versus CGLIB proxies, pointcuts and advice, and the self-invocation problem that silently disables your annotation.
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.
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 proxy | CGLIB | |
|---|---|---|
| Requires | the bean implements an interface | nothing |
| Produces | a class implementing the same interfaces | a subclass of the bean's class |
| Cannot proxy | anything not in an interface | final classes, final methods |
| Bean's actual type | $Proxy37, not your class | PricingService$$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
finalclass cannot be subclassed, so it cannot be proxied. Kotlin classes arefinalby default, which is whykotlin-springexists to open them. - A
finalmethod 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
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:
caller. Some other bean holds the proxy, because that is what the container put in the registry, and calls outer().
proxy.outer. The proxy checks outer() for advice. It has none, so nothing is started; it delegates straight to the target.
target.outer. Now executing INSIDE the real object. `this` is the target, not the proxy.
this.inner. An ordinary Java call on `this`. It never leaves the object, so the proxy is not involved and never sees it.
inner runs. @Transactional on inner() was read at startup and the proxy is ready to honour it. Nothing ever asked.
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:
- Move
innerto 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. - Inject the bean into itself and call
self.inner(). Honest, slightly odd, and needs@Lazyto avoid the cycle. AopContext.currentProxy(), which requiresexposeProxy = trueand ties the code to Spring. Available, rarely right.
Pointcuts and advice, briefly
When you write aspects yourself rather than consuming annotations:
@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
@Cacheablewalkthrough. getClass()lies, andinstanceofdoes not. A CGLIB proxy is a subclass, soinstanceof PricingServiceholds andgetClass() == PricingService.classfails. Code that switches ongetClass()or reads annotations from it needsAopProxyUtils.ultimateTargetClassorAopUtils.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:
assertThat(AopUtils.isAopProxy(repository)).isTrue();Try it yourself
Which calls are advised?
@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?
@Autowired PricingServiceImpl pricing; // ClassCastException at startupPricingServiceImpl 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
- "
@Transactionalon 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
finalmethod, 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,DefaultAopProxyFactoryandCglibAopProxyin the source, for where the JDK-versus-CGLIB decision is made.AopUtilsandAopProxyUtils—isAopProxy,getTargetClass,ultimateTargetClass, the tools for asserting what you actually got.