Debugging Spring AOP Execution Flow: A Complete Walkthrough

Debugging Environment

Before diving into the execution flow, ensure you have access to the following project for reference:

Start the application and navigate to: http://localhost:9002/queryNews?htmlid=1531872732684

Step 1: Controller Entry Point

The request enters the controller layer. Set a breakpoint at the service call:

News news = foreService.queryNews(htmlid);

At this point, foreService is already a proxy object wrapping the actual service implementation. This proxy is created by Spring's AOP framework and contains the logic for intercepting method invocations.

Step 2: Service Layer Logging

When stepping into the service implementation, you can observe logging operations:

logger.debug(htmlid);

This leads to the underlying logging mechanism:

public void debug(final Object message) {
    logIfEnabled(FQCN, Level.DEBUG, null, message, null);
}

Step 3: Mapper Proxy Invocation

The service layer calls the mapper, which is also a proxy object:

return foreMapper.queryNews(htmlid);

At this stage, foreMapper is a MapperProxy instance that implements the InvocationHandler inteerface. This proxy contains references to the SqlSession and holds the SQL statement definitions from the corresponding Mapper XML file.

Step 4: JDK Dynamic Proxy Execution

The call enters the JDK dynamic proxy's invoke method, specifically the JdkDynamicAopProxy class. This is where the AOP invocation chain is constructed and executed.

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    MethodInvocation invocation;
    Object oldProxy = null;
    boolean setProxyContext = false;

    TargetSource targetSource = this.advised.targetSource;
    Class<?> targetClass = null;
    Object target = null;

    try {
        // Handle special methods like equals and hashCode
        if (!this.equalsDefined && AopUtils.isEqualsMethod(method)) {
            return equals(args[0]);
        } else if (!this.hashCodeDefined && AopUtils.isHashCodeMethod(method)) {
            return hashCode();
        } else if (method.getDeclaringClass() == DecoratingProxy.class) {
            return AopProxyUtils.ultimateTargetClass(this.advised);
        } else if (!this.advised.opaque && method.getDeclaringClass().isInterface() &&
                method.getDeclaringClass().isAssignableFrom(Advised.class)) {
            return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args);
        }

        Object retVal;

        // Expose proxy if configured
        if (this.advised.exposeProxy) {
            oldProxy = AopContext.setCurrentProxy(proxy);
            setProxyContext = true;
        }

        // Obtain target instance
        target = targetSource.getTarget();
        if (target != null) {
            targetClass = target.getClass();
        }

        // Retrieve interceptor chain for this method
        List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(
            method, targetClass);

        // Execute directly if no interceptors exist
        if (chain.isEmpty()) {
            Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
            retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
        } else {
            // Create method invocation with interceptor chain
            invocation = new ReflectiveMethodInvocation(
                proxy, target, method, args, targetClass, chain);
            retVal = invocation.proceed();
        }

        // Handle return value if it is 'this'
        Class<?> returnType = method.getReturnType();
        if (retVal != null && retVal == target &&
                returnType != Object.class && returnType.isInstance(proxy) &&
                !RawTargetAccess.class.isAssignableFrom(method.getDeclaringClass())) {
            retVal = proxy;
        } else if (retVal == null && returnType != Void.TYPE && returnType.isPrimitive()) {
            throw new AopInvocationException(
                "Null return value from advice does not match primitive return type for: " + method);
        }
        return retVal;
    } finally {
        if (target != null && !targetSource.isStatic()) {
            targetSource.releaseTarget(target);
        }
        if (setProxyContext) {
            AopContext.setCurrentProxy(oldProxy);
        }
    }
}

Step 5: Obtaining the Interceptor Chain

The framework retrieves all interceptors and advisors associated with this method:

List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(
    method, targetClass);

This chain contains all the advice (before, after, around, etc.) that should be applied to the method invocation.

Step 6: Creating the Method Invocation Objecct

Since the interceptor chain is not empty, a ReflectiveMethodInvocation object is created:

invocation = new ReflectiveMethodInvocation(
    proxy, target, method, args, targetClass, chain);
retVal = invocation.proceed();

This invocation object encapsulates all the information needed to proceed through the interceptor chain and eventually execute the target method.

Step 7: Processing the Interceptor Chain

The ReflectiveMethodInvocation.proceed() method iterative processes each interceptor in the chain:

@Override
public Object proceed() throws Throwable {
    if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        return invokeJoinpoint();
    }

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

    if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher) {
        InterceptorAndDynamicMethodMatcher dm =
                (InterceptorAndDynamicMethodMatcher) interceptorOrInterceptionAdvice;
        if (dm.methodMatcher.matches(this.method, this.targetClass, this.arguments)) {
            return dm.interceptor.invoke(this);
        } else {
            return proceed();
        }
    } else {
        return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
    }
}

Each interceptor is responsible for calling proceed() to continue the chain, eventually reaching the target method.

Step 8: Persistence Exception Translation

One common interceptor in Spring applications is the PersistenceExceptionTranslationInterceptor:

public class PersistenceExceptionTranslationInterceptor
        implements MethodInterceptor, BeanFactoryAware, InitializingBean {

    @Override
    public Object invoke(MethodInvocation mi) throws Throwable {
        try {
            return mi.proceed();
        } catch (RuntimeException ex) {
            if (!this.alwaysTranslate &&
                ReflectionUtils.declaresException(mi.getMethod(), ex.getClass())) {
                throw ex;
            } else {
                if (this.persistenceExceptionTranslator == null) {
                    this.persistenceExceptionTranslator =
                        detectPersistenceExceptionTranslators(this.beanFactory);
                }
                throw DataAccessUtils.translateIfNecessary(ex,
                    this.persistenceExceptionTranslator);
            }
        }
    }
}

This interceptor translates database exceptions into Spring's unified data access exception hierarchy.

Step 9: Invoking the Joinpoint

When all interceptors have been processed, the actual target method is invoked through reflection:

protected Object invokeJoinpoint() throws Throwable {
    return AopUtils.invokeJoinpointUsingReflection(
        this.target, this.method, this.arguments);
}

Step 10: Reflection-Based Method Invocation

The AopUtils class handles the actual method invocation:

public static Object invokeJoinpointUsingReflection(Object target, Method method, Object[] args)
        throws Throwable {
    try {
        ReflectionUtils.makeAccessible(method);
        return method.invoke(target, args);
    } catch (InvocationTargetException ex) {
        throw ex.getTargetException();
    } catch (IllegalArgumentException ex) {
        throw new AopInvocationException("AOP configuration seems to be invalid: tried calling method [" +
                method + "] on target [" + target + "]", ex);
    } catch (IllegalAccessException ex) {
        throw new AopInvocationException("Could not access method [" + method + "]", ex);
    }
}

The makeAccessible method ensures the method can be invoked even if it's not public:

public static void makeAccessible(Method method) {
    if ((!Modifier.isPublic(method.getModifiers()) ||
            !Modifier.isPublic(method.getDeclaringClass().getModifiers())) &&
            !method.isAccessible()) {
        method.setAccessible(true);
    }
}

Step 11: Entering the MyBatis Mapper Proxy

The call now enters the MyBatis MapperProxy:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, args);
        } else if (isDefaultMethod(method)) {
            return invokeDefaultMethod(proxy, method, args);
        }
    } catch (Throwable t) {
        throw ExceptionUtil.unwrapThrowable(t);
    }
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);
}

Step 12: Caching Mapper Methods

Each mapper method is cached to avoid repeated instantiation:

private MapperMethod cachedMapperMethod(Method method) {
    MapperMethod mapperMethod = methodCache.get(method);
    if (mapperMethod == null) {
        mapperMethod = new MapperMethod(mapperInterface, method,
            sqlSession.getConfiguration());
        methodCache.put(method, mapperMethod);
    }
    return mapperMethod;
}

Step 13: Configuration Retrieval

The MapperMethod constructor receives the MyBatis configuration:

public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
    this.command = new SqlCommand(config, mapperInterface, method);
    this.method = new MethodSignature(config, mapperInterface, method);
}

The configuration contains all SQL statement mappings defined in Mapper XML files.

Step 14: SQL Command Resolution

The SqlCommand class resolves the actual SQL statement:

public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
    final String methodName = method.getName();
    final Class<?> declaringClass = method.getDeclaringClass();
    MappedStatement ms = resolveMappedStatement(mapperInterface, methodName,
        declaringClass, configuration);

    if (ms == null) {
        if (method.getAnnotation(Flush.class) != null) {
            name = null;
            type = SqlCommandType.FLUSH;
        } else {
            throw new BindingException("Invalid bound statement (not found): "
                + mapperInterface.getName() + "." + methodName);
        }
    } else {
        name = ms.getId();
        type = ms.getSqlCommandType();
        if (type == SqlCommandType.UNKNOWN) {
            throw new BindingException("Unknown execution method for: " + name);
        }
    }
}

Step 15: Executing the SQL Statement

The MapperMethod.execute() method routes to the appropriate SQL operation:

public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    switch (command.getType()) {
        case INSERT: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.insert(command.getName(), param));
            break;
        }
        case UPDATE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.update(command.getName(), param));
            break;
        }
        case DELETE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.delete(command.getName(), param));
            break;
        }
        case SELECT:
            if (method.returnsVoid() && method.hasResultHandler()) {
                executeWithResultHandler(sqlSession, args);
                result = null;
            } else if (method.returnsMany()) {
                result = executeForMany(sqlSession, args);
            } else if (method.returnsMap()) {
                result = executeForMap(sqlSession, args);
            } else if (method.returnsCursor()) {
                result = executeForCursor(sqlSession, args);
            } else {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = sqlSession.selectOne(command.getName(), param);
            }
            break;
        case FLUSH:
            result = sqlSession.flushStatements();
            break;
        default:
            throw new BindingException("Unknown execution method for: " + command.getName());
    }

    if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
        throw new BindingException("Mapper method '" + command.getName()
            + "' attempted to return null from a method with a primitive return type ("
            + method.getReturnType() + ").");
    }
    return result;
}

For a query operation like queryNews(), the flow reaches sqlSession.selectOne(), which executes the actual database query and returns the result.

Summary

This debugging journey reveals the complete execution flow of a Spring AOP proxied method call. The request travels through multiple layers: controller → service proxy → interceptor chain → target method → mapper proxy → SQL execution. Each layer adds its own logic through the proxy mechanism, enabling powerful cross-cutting concerns like transaction management, logging, and security to be applied transparently.

Tags: Spring aop debugging Proxy jdk-dynamic-proxy

Posted on Thu, 27 Aug 2026 16:31:02 +0000 by Entanio