Spring AOP & Cross-Cutting Concerns

How Spring AOP Works (Proxies)

18 min Lesson 9 of 13

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.

Key mental model: The proxy is the bean from every caller's perspective. The real implementation object sits inside the proxy, invisible to the outside world. Advice runs in the proxy layer, before and/or after the real method executes.

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.

// Interface — JDK proxy implements this public interface PaymentService { void process(Order order); } // Real implementation — Spring wraps this @Service public class PaymentServiceImpl implements PaymentService { @Override public void process(Order order) { /* ... */ } } // Caller — receives the JDK proxy, not PaymentServiceImpl @Component public class CheckoutFacade { private final PaymentService paymentService; // actually a Proxy$N at runtime public CheckoutFacade(PaymentService paymentService) { this.paymentService = paymentService; } }

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.

// No interface — Spring Boot uses CGLIB automatically @Service public class InventoryService { public void reserve(String sku, int qty) { /* ... */ } }

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.

CGLIB restrictions to remember:
  • 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:

spring.aop.proxy-target-class=false

To force CGLIB on a specific @EnableAspectJAutoProxy configuration (useful in plain Spring, not Boot):

@Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) public class AopConfig { }

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.

@Service public class OrderService { @Transactional // works fine when called from outside public void createOrder(Order order) { save(order); // calls this.save() — bypasses the proxy! } @Transactional(readOnly = true) // NEVER applied when called from createOrder() public Order save(Order order) { // ... return order; } }

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.

This is the number-one AOP pitfall. It affects every proxy-based Spring feature: @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)

@Service public class OrderService implements ApplicationContextAware { private ApplicationContext ctx; @Override public void setApplicationContext(ApplicationContext ctx) { this.ctx = ctx; } @Transactional public void createOrder(Order order) { // Ask Spring for the proxy — advice will now run on save() ctx.getBean(OrderService.class).save(order); } @Transactional(readOnly = true) public Order save(Order order) { /* ... */ return order; } }

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:

@Component public class ProxyInspector implements ApplicationRunner { @Autowired private OrderService orderService; @Override public void run(ApplicationArguments args) { System.out.println(orderService.getClass().getName()); // JDK: com.sun.proxy.$Proxy42 // CGLIB: com.example.OrderService$$SpringCGLIB$$0 } }

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.