How Spring AOP Works (Proxies)
How Spring AOP Works (Proxies)
Every previous lesson treated Spring AOP as a black box: you annotate a class with @Aspect, write some advice, and Spring somehow intercepts the method calls you specified. This lesson opens that black box. Understanding the two proxy strategies Spring uses — JDK dynamic proxies and CGLIB — and the self-invocation trap they both create will save you from subtle bugs that are extremely hard to diagnose without this knowledge.
The Core Idea: Proxy Objects
Spring AOP does not modify your bytecode at compile time (that is AspectJ's weaving mode). Instead, at runtime Spring wraps your bean in a proxy object — a generated class that implements the same interface (or extends the same concrete class) as your original bean. When a caller asks the Spring container for your OrderService, it receives the proxy, not the real object. The proxy intercepts every call, runs any applicable advice, and then delegates to the real object hidden inside it.
Strategy 1: JDK Dynamic Proxies
JDK dynamic proxies are built into the Java standard library (java.lang.reflect.Proxy). They work by generating a class at runtime that implements a given list of interfaces. The generated class routes every method call through an InvocationHandler — Spring supplies its own handler that applies advice and then calls the real method via reflection.
Requirement: the target bean must implement at least one interface. The proxy implements that interface; callers hold a reference typed to the interface.
Because the proxy only implements the declared interface, callers cannot cast it to PaymentServiceImpl. Any attempt throws a ClassCastException at runtime — a common mistake when someone tries to call a concrete method not listed in the interface.
Strategy 2: CGLIB Proxies
When a bean does not implement an interface — or when you configure proxyTargetClass = true — Spring falls back to CGLIB (Code Generation Library). CGLIB generates a subclass of your concrete class at runtime and overrides its methods to insert the advice chain. Because it is a subclass, CGLIB can proxy any non-final class without requiring an interface.
Spring Boot 2+ defaults to CGLIB proxies for all beans (it sets spring.aop.proxy-target-class=true by default). You will therefore see CGLIB proxies everywhere in a typical Spring Boot application, even for beans that do have interfaces.
- The class and any proxied method must not be
final— a final class or method cannot be subclassed, so CGLIB cannot intercept it. - CGLIB creates a subclass instance and needs a no-argument constructor (or a constructor that Spring can satisfy via injection). If you add a constructor with required arguments and omit the no-arg variant, Spring may fail to create the proxy in older setups. Spring 6 / Objenesis removes this restriction in most cases, but it is still worth knowing.
Choosing Between the Two Strategies
| Aspect | JDK Dynamic Proxy | CGLIB Proxy |
|---|---|---|
| Requires interface | Yes | No |
Works with final classes |
No (must be an interface) | No |
Works with final methods |
N/A — not in the proxy | No — cannot override |
| Default in Spring Boot | No (since Boot 2) | Yes |
| Caller cast to concrete type | Not possible | Possible (subclass) |
To force JDK proxies project-wide, set in application.properties:
To force CGLIB on a specific @EnableAspectJAutoProxy configuration (useful in plain Spring, not Boot):
The Self-Invocation Problem
Both proxy strategies share an unavoidable limitation: self-invocation bypasses the proxy entirely. This is the most common AOP bug seen in production code.
When a method on your bean calls another method on the same bean using this, the call never passes through the proxy — it goes directly to the real object. Any advice configured for that second method is silently skipped.
Here, createOrder is called via the proxy (advice runs), but its internal call to save goes directly to this — the real OrderService object — skipping the proxy entirely. The @Transactional on save never fires from this code path.
@Transactional, @Cacheable, @Async, @Secured, and any custom aspect you write. The symptom is that the advice runs when you call the method from another class but silently does nothing when called from within the same class.
Solutions to Self-Invocation
There are three practical patterns to break out of self-invocation:
1. Inject the proxy into itself (ApplicationContext lookup)
2. Extract to a separate bean — the cleanest and most common approach. Move save into its own Spring-managed OrderPersistenceService bean. Every call from OrderService now goes through a different proxy, and advice applies correctly. This also improves cohesion.
3. Use AspectJ compile-time or load-time weaving — full AspectJ weaves advice directly into the bytecode. Self-invocation is no longer an issue because there is no proxy at all. The trade-off is build complexity (compile-time weaving requires an AspectJ compiler; load-time weaving requires a Java agent). Most teams choose option 2 instead.
Verifying What Kind of Proxy You Have
During debugging you can print the actual class of a Spring bean to confirm which proxy strategy is in use:
The $$SpringCGLIB$$ suffix confirms CGLIB; the $Proxy prefix confirms JDK dynamic proxy.
Summary
Spring AOP works by wrapping beans in proxy objects at runtime. JDK dynamic proxies require an interface and are standard Java; CGLIB subclasses the concrete class and is the Spring Boot default. Both proxy strategies share the same limitation: a method calling another method on this bypasses the proxy, so any advice on the second method is silently ignored. The cleanest fix is to move the second method into a separate bean so the call crosses proxy boundaries. Knowing this mechanism makes every AOP-powered Spring feature — transactions, caching, async execution, security — predictable and debuggable.