Skip to main content
spring internals

AOP Internals: Pointcuts, Advice Chains, and the Order That Determines What Wraps What

10 min read Chapter 25 of 78

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.

AOP advice chain showing interceptor stack with Order values driving Security, Audit, and Transaction interceptors

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:

  1. Finds all beans of type Advisor in the container.
  2. Finds all beans annotated with @Aspect.
  3. For each @Aspect bean, extracts every advice method (@Before, @After, @Around, @AfterReturning, @AfterThrowing) and wraps it in an Advisor object that pairs the advice with its pointcut.
  4. Evaluates each advisor’s pointcut against the target bean’s class. If any method on the bean matches the pointcut, the advisor is included.
  5. 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:

  1. Detects @Aspect on the class using AjTypeSystem.getAjType().
  2. Iterates over non-pointcut methods using ReflectiveAspectJAdvisorFactory.getAdvisors().
  3. For the auditServiceCall method, identifies the @Around annotation and creates an AspectJAroundAdvice.
  4. Parses the pointcut expression execution(* com.saas.tenant.service.*.*(..)) into an AspectJExpressionPointcut.
  5. 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 MethodInterceptor instances (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:

  • @Around maps to AspectJAroundAdvice, which implements MethodInterceptor directly. The ProceedingJoinPoint.proceed() call inside your advice method is the invocation.proceed() call that advances the chain.
  • @Before maps to MethodBeforeAdviceInterceptor. This interceptor calls your before method, then calls invocation.proceed(). You cannot prevent the chain from advancing.
  • @AfterReturning maps to AfterReturningAdviceInterceptor. This interceptor calls invocation.proceed(), captures the return value, and then calls your after-returning method.
  • @AfterThrowing maps to AspectJAfterThrowingAdvice. This interceptor calls invocation.proceed() inside a try-catch. If an exception is thrown, it calls your after-throwing method, then rethrows.
  • @After maps to AspectJAfterAdvice. This interceptor calls invocation.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).