AOP Internals: Pointcuts, Advice Chains, and the Order That Determines What Wraps What
AOP Internals: Pointcuts, Advice Chains, and the Order That Determines What Wraps What
Spring AOP is not a separate system bolted onto the container. It is the proxy infrastructure from CH8 with a targeting mechanism layered on top. The proxy creates the interception point. AOP decides what gets intercepted and in what order. If you understood how AbstractAutoProxyCreator wraps a bean in a CGLIB or JDK dynamic proxy, you already understand half of AOP. This chapter covers the other half: how Spring selects which beans to proxy, how it builds the chain of interceptors inside that proxy, and why the ordering of that chain determines whether your application behaves correctly or silently fails.
The Entry Point: AnnotationAwareAspectJAutoProxyCreator
Every Spring Boot application that uses @EnableAspectJAutoProxy (auto-configured by AopAutoConfiguration) registers a BeanPostProcessor called AnnotationAwareAspectJAutoProxyCreator. This class extends AbstractAutoProxyCreator, which we covered in CH8. The proxy creation mechanism is identical. What changes is how this subclass answers the question: which advisors apply to this bean?
During postProcessAfterInitialization, for every bean in the container, the creator calls getAdvicesAndAdvisorsForBean(). This method:
- Finds all beans of type
Advisorin the container. - Finds all beans annotated with
@Aspect. - For each
@Aspectbean, extracts every advice method (@Before,@After,@Around,@AfterReturning,@AfterThrowing) and wraps it in anAdvisorobject that pairs the advice with its pointcut. - Evaluates each advisor’s pointcut against the target bean’s class. If any method on the bean matches the pointcut, the advisor is included.
- If at least one advisor matches, the bean is proxied. If none match, the bean is returned unwrapped.
Step 3 is where the AspectJ annotation model meets Spring’s internal advisor model. A method annotated with @Around("execution(* com.saas..*Service.*(..))") becomes an InstantiationModelAwarePointcutAdvisorImpl containing a AspectJAroundAdvice (the advice) and an AspectJExpressionPointcut (the pointcut). The advice knows how to invoke the annotated method. The pointcut knows how to match target methods.
From @Aspect to Advisors
Consider a logging aspect for the SaaS backend:
@Aspect
@Component
public class AuditLoggingAspect {
@Around("execution(* com.saas.tenant.service.*.*(..))")
public Object auditServiceCall(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
long start = System.nanoTime();
try {
Object result = pjp.proceed();
log.info("AUDIT {} completed in {}ms", method,
(System.nanoTime() - start) / 1_000_000);
return result;
} catch (Throwable t) {
log.error("AUDIT {} failed: {}", method, t.getMessage());
throw t;
}
}
}
When AnnotationAwareAspectJAutoProxyCreator processes this aspect, it calls BeanFactoryAspectJAdvisorsBuilder.buildAspectJAdvisors(). This method:
- Detects
@Aspecton the class usingAjTypeSystem.getAjType(). - Iterates over non-pointcut methods using
ReflectiveAspectJAdvisorFactory.getAdvisors(). - For the
auditServiceCallmethod, identifies the@Aroundannotation and creates anAspectJAroundAdvice. - Parses the pointcut expression
execution(* com.saas.tenant.service.*.*(..))into anAspectJExpressionPointcut. - Combines them into a single
Advisor.
This advisor is now a candidate for every bean in the container. When TenantService is created and post-processed, the creator evaluates the pointcut against TenantService’s methods. If TenantService lives in com.saas.tenant.service, the pointcut matches. The bean gets proxied.
The Interceptor Chain Model
A proxy does not directly call your advice. It delegates to a chain of MethodInterceptor instances, managed by ReflectiveMethodInvocation. This is the central execution model of Spring AOP, and understanding it explains every observable behavior of advised methods.
When you call a method on a proxied bean, the call enters the proxy (CGLIB subclass or JDK InvocationHandler). The proxy creates a ReflectiveMethodInvocation initialized with:
- The target object (the actual bean instance).
- The method being called.
- The method arguments.
- An ordered list of
MethodInterceptorinstances (the chain). - An index starting at 0.
The proxy calls invocation.proceed(). This method checks the index. If the index is less than the chain length, it retrieves the interceptor at that index, increments the index, and calls interceptor.invoke(this). The interceptor does its work and, when ready, calls invocation.proceed() again. This advances to the next interceptor. When the index reaches the end of the chain, proceed() calls the actual target method via reflection.
This is a recursive pipeline. Each interceptor wraps the next. The outermost interceptor executes first and finishes last. The innermost interceptor executes last (just before the target method) and finishes first (right after the target method returns).
Client call
→ Interceptor[0].invoke() (outermost)
→ Interceptor[1].invoke()
→ Interceptor[2].invoke() (innermost)
→ Target method
← Interceptor[2] returns
← Interceptor[1] returns
← Interceptor[0] returns
← Client receives result
This is the same call stack model as servlet filters or middleware in other frameworks. The difference is that Spring builds this chain automatically from your aspects and orders it using the @Order annotation.
Advice Type Conversion
Not every advice type maps directly to MethodInterceptor. Spring converts them:
@Aroundmaps toAspectJAroundAdvice, which implementsMethodInterceptordirectly. TheProceedingJoinPoint.proceed()call inside your advice method is theinvocation.proceed()call that advances the chain.@Beforemaps toMethodBeforeAdviceInterceptor. This interceptor calls your before method, then callsinvocation.proceed(). You cannot prevent the chain from advancing.@AfterReturningmaps toAfterReturningAdviceInterceptor. This interceptor callsinvocation.proceed(), captures the return value, and then calls your after-returning method.@AfterThrowingmaps toAspectJAfterThrowingAdvice. This interceptor callsinvocation.proceed()inside a try-catch. If an exception is thrown, it calls your after-throwing method, then rethrows.@Aftermaps toAspectJAfterAdvice. This interceptor callsinvocation.proceed()inside a try-finally. Your method runs in the finally block regardless of success or failure.
The conversion matters because it determines what your advice can do. @Before cannot prevent method execution. @Around can, by not calling proceed(). @AfterReturning can inspect but not replace the return value (unless you use @Around). These are not style preferences. They are structural constraints imposed by the interceptor wrappers.
@Order and the Chain Sequence
When multiple aspects match the same bean, Spring must decide the order. This is where most AOP bugs originate.
Each aspect class can declare its priority using @Order(value) or by implementing the Ordered interface. Lower values execute first, meaning they become the outermost interceptors in the chain. An aspect with @Order(1) wraps an aspect with @Order(2).
For the SaaS backend, consider three aspects:
@Aspect
@Component
@Order(1)
public class TenantContextAspect { /* verifies tenant context exists */ }
@Aspect
@Component
@Order(2)
public class AuditLoggingAspect { /* logs method entry/exit */ }
@Aspect
@Component
@Order(3)
public class PerformanceMonitoringAspect { /* records execution time */ }
When a service method is called, the chain executes as:
TenantContextAspect (Order 1, outermost)
→ AuditLoggingAspect (Order 2)
→ PerformanceMonitoringAspect (Order 3, innermost)
→ Target method
The tenant context check runs first. If the tenant is not set, it throws before audit or performance monitoring execute. Audit logging captures the full execution time including performance monitoring overhead. Performance monitoring captures only the target method time. This ordering is intentional and correct.
The Broken Pattern: Undefined Order
// BROKEN: No @Order on either aspect. Spring uses registration order,
// which depends on component scanning order, which depends on classpath
// order. The behavior changes between environments.
@Aspect
@Component
public class SecurityValidationAspect {
@Around("execution(* com.saas.tenant.service.*.*(..))")
public Object validateAccess(ProceedingJoinPoint pjp) throws Throwable {
TenantContext ctx = TenantContextHolder.get();
if (ctx == null) {
throw new SecurityException("No tenant context");
}
return pjp.proceed();
}
}
@Aspect
@Component
public class AuditLoggingAspect {
@Around("execution(* com.saas.tenant.service.*.*(..))")
public Object audit(ProceedingJoinPoint pjp) throws Throwable {
// This might execute BEFORE SecurityValidationAspect.
// If it does, it logs the tenant ID from a context that
// has not been validated yet.
String tenant = TenantContextHolder.get().getTenantId();
log.info("AUDIT tenant={} method={}", tenant, pjp.getSignature());
return pjp.proceed();
}
}
This code has no compilation error, no startup warning, no test failure on a developer machine. It breaks in production when the classpath order changes between a fat JAR and an exploded deployment. The audit aspect assumes the security aspect ran first, but nothing guarantees that.
The Correct Pattern: Explicit Order
// CORRECT: Every aspect declares its order explicitly.
// Security validates first. Audit logs second.
@Aspect
@Component
@Order(1)
public class SecurityValidationAspect {
@Around("execution(* com.saas.tenant.service.*.*(..))")
public Object validateAccess(ProceedingJoinPoint pjp) throws Throwable {
TenantContext ctx = TenantContextHolder.get();
if (ctx == null) {
throw new SecurityException("No tenant context");
}
return pjp.proceed();
}
}
@Aspect
@Component
@Order(2)
public class AuditLoggingAspect {
@Around("execution(* com.saas.tenant.service.*.*(..))")
public Object audit(ProceedingJoinPoint pjp) throws Throwable {
// SecurityValidationAspect has already run.
// TenantContext is guaranteed to be present.
String tenant = TenantContextHolder.get().getTenantId();
log.info("AUDIT tenant={} method={}", tenant, pjp.getSignature());
return pjp.proceed();
}
}
The rule is simple: if you have more than one aspect, every aspect gets an explicit @Order. No exceptions. Undefined ordering is undefined behavior.
How the Proxy Stores the Chain
The proxy object created by ProxyFactory (called internally by AbstractAutoProxyCreator) implements the Advised interface. This interface exposes the full advisor chain attached to the proxy. You can inspect it at runtime:
@Component
public class ProxyInspector implements CommandLineRunner {
@Autowired
private TenantService tenantService;
@Override
public void run(String... args) {
if (AopUtils.isAopProxy(tenantService)) {
Advised advised = (Advised) tenantService;
Advisor[] advisors = advised.getAdvisors();
for (int i = 0; i < advisors.length; i++) {
System.out.printf("Advisor[%d]: %s%n", i, advisors[i]);
if (advisors[i] instanceof PointcutAdvisor pa) {
System.out.printf(" Pointcut: %s%n", pa.getPointcut());
System.out.printf(" Advice: %s%n", pa.getAdvice());
}
}
}
}
}
This prints the ordered list of advisors. If the order does not match your expectations, this is how you find out. The advisor at index 0 is the outermost interceptor. The advisor at the highest index is the innermost. Between them and the target method, there is nothing hidden.
Single Proxy, Multiple Aspects
A critical point that surprises developers: Spring creates one proxy per bean, not one proxy per aspect. If three aspects match TenantService, there is still only one CGLIB subclass (or one JDK proxy) wrapping the original TenantService. That single proxy holds all three advisors in its chain. When a method is called, all three interceptors fire in sequence within the same ReflectiveMethodInvocation.
This means the proxy type decision from CH8 (CGLIB vs. JDK dynamic proxy) happens once, and all aspects share it. It also means getBean(TenantService.class) returns the proxy, and every method call on that proxy, regardless of which method, passes through the full advisor chain. Each advisor’s pointcut is re-evaluated per method call to determine if the advice should actually execute for that specific method.
This per-method check is an optimization point. AspectJExpressionPointcut caches its match results per method signature. The first call to TenantService.createTenant() evaluates the pointcut. Subsequent calls to the same method skip the evaluation and use the cached result. But calls to a different method, say TenantService.deleteTenant(), trigger a fresh evaluation. This is why overly broad pointcuts (covered in CH9-S1) have a performance cost: they force more cache entries and more initial evaluations.
What This Means for the SaaS Backend
In the multi-tenant SaaS system, AOP provides cross-cutting infrastructure that would otherwise require manual method-by-method implementation:
- Tenant context propagation: An aspect verifies and logs the tenant context before any service method executes.
- Audit logging: Every mutation operation is recorded with the tenant ID, user, timestamp, and method signature.
- Performance monitoring: Service and repository method execution times are captured and exported to metrics.
Without AOP, each of these concerns would require explicit calls at the top and bottom of every service method. With AOP, they are declared once as aspects, applied automatically by pointcut matching, and ordered deterministically by @Order. The proxy from CH8 provides the interception point. The interceptor chain provides the execution model. The pointcut provides the targeting. Together, they turn cross-cutting concerns into composable, testable, orderable components.
The next sections dive into the two halves of this system: pointcut expressions (CH9-S1) and the interceptor chain mechanics (CH9-S2).