Skip to main content
spring internals

The Interceptor Chain and Aspect Ordering

9 min read Chapter 27 of 78

The Interceptor Chain and Aspect Ordering

The interceptor chain is the runtime execution model of Spring AOP. Every behavior you observe from aspects, the order of log statements, which advice sees exceptions, whether a return value is modified, is determined by the position and type of interceptors in this chain. This section opens the chain and walks through the execution step by step.

ReflectiveMethodInvocation.proceed()

The source of proceed() in ReflectiveMethodInvocation is roughly 15 lines of code. It is one of the most important methods in the framework:

public Object proceed() throws Throwable {
    // Index starts at -1. Incremented before each interceptor call.
    if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        // All interceptors have been called. Invoke the target method.
        return invokeJoinpoint();
    }

    Object interceptorOrInterceptionAdvice =
        this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);

    if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher dm) {
        // Dynamic pointcut: re-evaluate match at runtime
        if (dm.matcher().matches(this.method, this.targetClass, this.arguments)) {
            return dm.interceptor().invoke(this);
        } else {
            // Skip this interceptor, proceed to next
            return proceed();
        }
    } else {
        // Static pointcut: already matched at startup
        return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
    }
}

The mechanism is straightforward. A counter tracks the current position in the interceptor list. Each call to proceed() advances the counter and invokes the next interceptor. When no interceptors remain, the actual method is called via reflection (invokeJoinpoint()). The interceptor receives this (the MethodInvocation itself), which allows it to call proceed() to continue the chain or skip it entirely.

Two details matter:

  1. The counter is mutable state on the invocation object. Each call to proceed() modifies it. If an interceptor calls proceed() twice, the counter advances twice, and the chain breaks. This is not a theoretical concern. It happens when developers copy-paste @Around advice and accidentally leave two proceed() calls.

  2. Dynamic method matchers re-evaluate at runtime. Most pointcuts are static: they match based on the method signature, which does not change between calls. But pointcuts that examine method arguments (using args()) are dynamic. They are re-evaluated on every invocation because arguments change per call.

How Advice Types Become Interceptors

Spring does not execute @Before and @AfterReturning advice directly. It wraps them in MethodInterceptor adapters that integrate them into the chain.

@Around: Direct Interceptor

AspectJAroundAdvice implements MethodInterceptor. When the chain reaches it, the invoke() method calls your @Around method, passing a ProceedingJoinPoint that delegates to invocation.proceed().

// What Spring generates internally (simplified)
public class AspectJAroundAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation mi) throws Throwable {
        ProceedingJoinPoint pjp = new MethodInvocationProceedingJoinPoint(mi);
        return yourAspect.yourAroundMethod(pjp);
    }
}

When your @Around method calls pjp.proceed(), it calls mi.proceed(), which advances the chain. You control the flow. You can proceed, skip, modify arguments, wrap the return value, or catch and rethrow exceptions.

@Before: Proceed-After Interceptor

MethodBeforeAdviceInterceptor calls your before method, then unconditionally calls proceed():

public class MethodBeforeAdviceInterceptor implements MethodInterceptor {
    public Object invoke(MethodInvocation mi) throws Throwable {
        this.advice.before(mi.getMethod(), mi.getArguments(), mi.getThis());
        return mi.proceed();
    }
}

Your @Before method cannot prevent execution. It cannot modify arguments (it receives a copy). It can only observe and throw. If it throws, the chain stops, but that is exception propagation, not flow control.

@AfterReturning: Proceed-First Interceptor

AfterReturningAdviceInterceptor calls proceed() first, then your method:

public class AfterReturningAdviceInterceptor implements MethodInterceptor {
    public Object invoke(MethodInvocation mi) throws Throwable {
        Object retVal = mi.proceed();
        this.advice.afterReturning(retVal, mi.getMethod(), mi.getArguments(), mi.getThis());
        return retVal;
    }
}

Your method sees the return value but cannot replace it. The interceptor returns the original retVal, not whatever your method might try to return. If you need to modify return values, use @Around.

@AfterThrowing: Catch-Rethrow Interceptor

AspectJAfterThrowingAdvice wraps proceed() in a try-catch:

public class AspectJAfterThrowingAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation mi) throws Throwable {
        try {
            return mi.proceed();
        } catch (Throwable ex) {
            if (shouldInvokeOnThrowing(ex)) {
                this.advice.afterThrowing(mi.getMethod(), mi.getArguments(), mi.getThis(), ex);
            }
            throw ex; // Always rethrows
        }
    }
}

The exception is always rethrown. @AfterThrowing cannot swallow exceptions. It observes and records, then the exception propagates up the chain to outer interceptors.

@After: Finally Interceptor

AspectJAfterAdvice uses try-finally:

public class AspectJAfterAdvice implements MethodInterceptor {
    public Object invoke(MethodInvocation mi) throws Throwable {
        try {
            return mi.proceed();
        } finally {
            this.advice.after(mi.getMethod(), mi.getArguments(), mi.getThis());
        }
    }
}

Your method runs regardless of success or failure, like a finally block. It cannot modify the return value or suppress exceptions.

Ordering Between Aspects

@Order on an aspect class determines where all of that aspect’s interceptors are placed in the chain relative to other aspects. Lower values are outermost. The SaaS backend example with three aspects:

@Aspect @Component @Order(1)
public class SecurityAspect {
    @Around("execution(* com.saas.tenant.service..*.*(..))")
    public Object checkSecurity(ProceedingJoinPoint pjp) throws Throwable {
        TenantContext ctx = TenantContextHolder.get();
        if (ctx == null || !ctx.hasPermission("SERVICE_ACCESS")) {
            throw new AccessDeniedException("Unauthorized");
        }
        return pjp.proceed();
    }
}

@Aspect @Component @Order(2)
public class AuditAspect {
    @Around("execution(* com.saas.tenant.service..*.*(..))")
    public Object audit(ProceedingJoinPoint pjp) throws Throwable {
        String tenant = TenantContextHolder.get().getTenantId();
        String method = pjp.getSignature().toShortString();
        log.info("AUDIT START tenant={} method={}", tenant, method);
        Object result = pjp.proceed();
        log.info("AUDIT END tenant={} method={}", tenant, method);
        return result;
    }
}

@Aspect @Component @Order(3)
public class MetricsAspect {
    @Around("execution(* com.saas.tenant.service..*.*(..))")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        Timer.Sample sample = Timer.start(meterRegistry);
        try {
            return pjp.proceed();
        } finally {
            sample.stop(Timer.builder("service.method")
                .tag("method", pjp.getSignature().getName())
                .register(meterRegistry));
        }
    }
}

The resulting chain for a TenantService.createTenant() call:

SecurityAspect.checkSecurity()        [Order 1, outermost]
  → AuditAspect.audit()              [Order 2]
    → MetricsAspect.measure()        [Order 3, innermost]
      → TenantService.createTenant() [target method]

If security fails, audit never logs. This is correct: you do not want audit records for unauthorized requests. If the target method throws, the metrics timer still records (because of the finally block in measure()), audit logs “AUDIT END” does not fire (because the exception skips past it), and security’s @Around sees the exception propagate out.

Ordering Within a Single Aspect

When a single aspect has multiple advice types on the same pointcut, Spring applies a fixed ordering:

  1. @Around (outermost, wraps everything)
  2. @Before
  3. @AfterReturning
  4. @AfterThrowing
  5. @After (innermost, runs in finally)

This means within one aspect:

@Aspect @Component @Order(1)
public class ComprehensiveAspect {

    @Around("execution(* com.saas.tenant.service.TenantService.*(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        log.info("AROUND before");
        Object result = pjp.proceed();
        log.info("AROUND after");
        return result;
    }

    @Before("execution(* com.saas.tenant.service.TenantService.*(..))")
    public void before(JoinPoint jp) {
        log.info("BEFORE");
    }

    @AfterReturning("execution(* com.saas.tenant.service.TenantService.*(..))")
    public void afterReturning(JoinPoint jp) {
        log.info("AFTER_RETURNING");
    }
}

Execution order on a successful call:

AROUND before
BEFORE
[target method executes]
AFTER_RETURNING
AROUND after

The @Around advice wraps @Before, which wraps @AfterReturning. The @Around’s pjp.proceed() triggers the @Before interceptor, which triggers the target method, which triggers the @AfterReturning interceptor on the way back up. Then control returns to the @Around advice after the proceed() call.

Debugging the Chain

Use the Advised interface to inspect any proxy at runtime:

@Component
public class AopDiagnostics implements CommandLineRunner {

    @Autowired private TenantService tenantService;

    @Override
    public void run(String... args) {
        if (!(tenantService instanceof Advised advised)) {
            System.out.println("TenantService is NOT proxied");
            return;
        }

        System.out.println("Proxy type: " + tenantService.getClass().getName());
        System.out.println("Target class: " + advised.getTargetClass());
        System.out.println("Advisor count: " + advised.getAdvisors().length);

        for (int i = 0; i < advised.getAdvisors().length; i++) {
            Advisor advisor = advised.getAdvisors()[i];
            System.out.printf("  [%d] %s%n", i, advisor.getClass().getSimpleName());

            if (advisor instanceof PointcutAdvisor pa) {
                System.out.printf("       Pointcut: %s%n", pa.getPointcut());
                System.out.printf("       Advice:   %s%n", pa.getAdvice().getClass().getSimpleName());
            }
        }
    }
}

Output for the three-aspect SaaS setup:

Proxy type: com.saas.tenant.service.TenantService$$SpringCGLIB$$0
Target class: class com.saas.tenant.service.TenantService
Advisor count: 3
  [0] InstantiationModelAwarePointcutAdvisorImpl
       Pointcut: AspectJExpressionPointcut: execution(* com.saas.tenant.service..*.*(..))
       Advice:   AspectJAroundAdvice
  [1] InstantiationModelAwarePointcutAdvisorImpl
       Pointcut: AspectJExpressionPointcut: execution(* com.saas.tenant.service..*.*(..))
       Advice:   AspectJAroundAdvice
  [2] InstantiationModelAwarePointcutAdvisorImpl
       Pointcut: AspectJExpressionPointcut: execution(* com.saas.tenant.service..*.*(..))
       Advice:   AspectJAroundAdvice

Advisor [0] is the outermost (lowest @Order). If the order does not match your expectations, this output tells you exactly what happened. Compare the advisor order to your @Order annotations. If they disagree, check for duplicate aspect beans, missing @Order annotations, or aspect classes registered through both component scanning and @Bean methods.

The Broken Pattern: Forgetting proceed()

// BROKEN: @Around advice that never calls proceed().
// The target method is never executed. No exception is thrown.
// The method silently returns null.

@Aspect
@Component
@Order(2)
public class CachingAspect {

    private final Map<String, Object> cache = new ConcurrentHashMap<>();

    @Around("execution(* com.saas.tenant.service.TenantService.getTenant(..))")
    public Object cacheResult(ProceedingJoinPoint pjp) throws Throwable {
        String key = Arrays.toString(pjp.getArgs());
        Object cached = cache.get(key);
        if (cached != null) {
            return cached;
        }
        // BUG: Developer forgot to call pjp.proceed() for cache misses.
        // On the first call, cache is empty, cached is null.
        // The method falls through and returns null.
        // The target method getTenant() is never called.
        // No exception. No log. The caller gets null.
        return null;
    }
}

This bug is insidious because it produces no error. The method returns null, which might look like “tenant not found” to the caller. Integration tests that populate the cache before querying will pass. Only tests that start with a cold cache will catch it, and only if they assert the return value is non-null.

The Correct Pattern: Always Call proceed()

// CORRECT: proceed() is called on cache miss.
// The return value from proceed() is cached and returned.

@Aspect
@Component
@Order(2)
public class CachingAspect {

    private final Map<String, Object> cache = new ConcurrentHashMap<>();

    @Around("execution(* com.saas.tenant.service.TenantService.getTenant(..))")
    public Object cacheResult(ProceedingJoinPoint pjp) throws Throwable {
        String key = Arrays.toString(pjp.getArgs());
        Object cached = cache.get(key);
        if (cached != null) {
            return cached;
        }
        Object result = pjp.proceed();
        if (result != null) {
            cache.put(key, result);
        }
        return result;
    }
}

The rule: every @Around advice must call pjp.proceed() on every code path that should reach the target method. The only valid reason to skip proceed() is intentional short-circuiting, like returning a cached value or rejecting an unauthorized call. In those cases, add a comment explaining why proceed() is not called, so the next developer does not “fix” it by adding the missing call.

The interceptor chain is deterministic. Given the same set of aspects, the same @Order values, and the same pointcut matches, the chain is identical on every application startup, in every environment. When it behaves unexpectedly, the cause is always one of: missing @Order, a forgotten proceed() call, or a pointcut that matches more (or fewer) beans than intended. The diagnostic tools shown here make each of these visible.