AOP & Proxies¶
@Transactional, @Cacheable, @Async, @PreAuthorize, @Retryable, and Spring Data
repositories all rely on the same trick: the bean you inject is not your object but a
proxy that runs extra code around your method. This mechanism is called
aspect-oriented programming (AOP) in Spring's vocabulary. Understanding it explains a
whole family of "the annotation does nothing" bugs at once.
The vocabulary, briefly¶
- Aspect — a module of cross-cutting behavior (timing, auditing, retries).
- Join point — a point where behavior can be inserted. In Spring AOP, always a method execution on a Spring bean.
- Pointcut — an expression selecting join points (
execution(* com.example..*Service.*(..)),@annotation(Timed)). - Advice — the code to run:
@Before,@AfterReturning,@AfterThrowing,@After, or@Around(which wraps the call and decides whether to proceed).
Writing an aspect¶
Add spring-boot-starter-aspectj (Boot 4; in Boot 3 it is spring-boot-starter-aop).
Then:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogSlow {
long thresholdMs() default 200;
}
@Aspect
@Component
class SlowCallAspect {
private static final Logger log = LoggerFactory.getLogger(SlowCallAspect.class);
@Around("@annotation(logSlow)")
Object time(ProceedingJoinPoint pjp, LogSlow logSlow) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long ms = (System.nanoTime() - start) / 1_000_000;
if (ms >= logSlow.thresholdMs()) {
log.warn("Slow call {} took {} ms", pjp.getSignature().toShortString(), ms);
}
}
}
}
@Service
class ReportService {
@LogSlow(thresholdMs = 100)
public Report monthly(YearMonth month) { ... }
}
Binding the annotation as a parameter (@annotation(logSlow) + LogSlow logSlow) gives
the advice access to its attributes.
Before writing an aspect for metrics, check whether Micrometer's @Timed/@Observed
already does it (Level 3, lesson 08). Custom aspects are best for genuinely
application-specific concerns.
JDK proxies vs CGLIB¶
Spring can create two kinds of proxy:
| JDK dynamic proxy | CGLIB subclass | |
|---|---|---|
| How | Implements the bean's interfaces | Subclasses the bean's class |
| Injectable as | The interface only | The interface or the class |
| Cannot intercept | Methods not on an interface | final methods, private methods, final classes |
| Used by | Spring Data repositories (interfaces by nature) | Boot's default for everything else (spring.aop.proxy-target-class=true) |
Boot defaults to CGLIB so that injecting a concrete class (ReportService rather than an
interface) works. In the Level 2 project the injected service printed its class as
TxDemoService$$SpringCGLIB$$0 — a CGLIB subclass generated at startup.
Worked example: seeing the proxy¶
Put a breakpoint (or new Exception().printStackTrace()) inside a @Transactional method
and read the stack from the bottom up. Between your controller frame and your service
method you will find frames like:
com.example.library.CatalogService.retitle(CatalogService.java:42)
... (reflection frames)
org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(...)
org.springframework.transaction.interceptor.TransactionInterceptor.invoke(...)
org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(...)
com.example.library.CatalogService$$SpringCGLIB$$0.retitle(<generated>)
com.example.library.BookController.retitle(BookController.java:31)
That is the whole mechanism on one screen: the controller calls the generated subclass, which hands the call to a chain of interceptors, the last of which invokes your real method by reflection. (Exact frame names vary a little between versions; the structure does not.)
How It Actually Works¶
Creation. AnnotationAwareAspectJAutoProxyCreator is a BeanPostProcessor. After each
bean is instantiated and initialized, its postProcessAfterInitialization asks: do any
advisors apply to this bean's class? Advisors come from @Aspect beans (one per
advice method, with its pointcut) and from Spring's own infrastructure (the transaction
advisor matches methods annotated @Transactional, the cache advisor matches
@Cacheable, and so on). If any match, it builds a ProxyFactory with the target object
and the matching advisors, and returns the proxy instead of the original bean. The
container stores and injects the proxy from then on.
Invocation. Calling a method on the proxy creates a ReflectiveMethodInvocation
holding the ordered list of interceptors that apply to that method. Each interceptor's
invoke does its work and calls proceed(), which moves to the next interceptor; the
last proceed() calls the target method. @Around advice is literally one of these
interceptors, and pjp.proceed() is that call. Order between aspects is controlled with
@Order — the transaction advisor, for example, has lowest precedence by default, so it
is the innermost wrapper unless configured otherwise.
The consequences, all from "a wrapper around an object":
- Self-invocation bypasses advice. Inside the target,
this.method()is a plain call on the target, not the proxy. (Level 2, lesson 04 showed this with real output.) privateandfinalmethods are never advised with CGLIB; methods not declared on an interface are not advised with JDK proxies.- Only Spring beans are advised. Objects created with
neware not proxied. - Nothing is advised during construction, because the proxy is created after initialization.
If you truly need interception of internal calls, constructors, or non-bean objects, that is what full AspectJ weaving (compile-time or load-time) provides. It is rarely worth the build complexity; restructuring into separate beans almost always is.
Common mistakes¶
- Expecting an aspect to fire on an internal call. Move the method to another bean.
- Aspects with side effects on the return value that surprise callers. Keep advice transparent unless changing behavior is the whole point (retries, caching).
- Pointcuts that are too broad (
execution(* *(..))) — every bean gets proxied, startup slows, and advice runs where nobody expects it. - Swallowing exceptions in
@Aroundby forgetting to rethrow. - Checking
obj.getClass() == ReportService.classin code that receives proxies. UseAopUtils.getTargetClass(obj)or, better, avoid class checks.
Exercise¶
- Implement
@LogSlowand apply it to a service method with an artificialThread.sleep. Confirm the warning appears. - Call the annotated method from another method of the same class and confirm the aspect does not fire. Fix it by moving the method.
- Print
bean.getClass()andAopUtils.isCglibProxy(bean)for a service with and without an advised method. Then inject a Spring Data repository and print its class — explain why it is a JDK proxy (jdk.proxy…$Proxy…). - Write two aspects on the same method with different
@Ordervalues that each log "enter"/"exit", and predict the log order before running it.