Spring Web MVC: Tracing doDispatch Through the Source Code

When working with Spring Web MVC, you naturally come across concepts like DispatcherServlet, HandlerMapping, and HandlerAdapter. But once you dig a little deeper, questions like “Couldn’t HandlerMapping just execute the controller directly? Why do we need a separate HandlerAdapter?” start to come up—and they are not always easy to answer clearly.

I had used Spring Web MVC while studying it in college and working on side projects, but I found it difficult to give a clear answer to these questions. So this time, rather than stopping at a simple list of concepts, I decided to open up the actual source code and trace the flow line by line at the code level.

Internal Structure of Spring Web MVC

Spring Web MVC is a web framework built on top of the Servlet API.

Spring Web MVC Architecture

Spring Web MVC consists of several components that work together internally. Let’s take a look at them one by one.

DispatcherServlet

The front controller of Spring Web MVC, DispatcherServlet is the central dispatcher that receives all HTTP requests, delegates them to the appropriate handlers, and renders the response.

Spring Web MVC is designed around the Front Controller pattern. The central servlet, DispatcherServlet, provides a common request-processing algorithm, while the actual work is handled by configurable delegate components. It plays the most central role in Spring Web MVC.

HandlerMapping

An interface responsible for mapping incoming web requests to the appropriate handler, along with a list of interceptors.

It maps requests to handlers and also specifies a list of interceptors for pre- and post-processing. The main implementations include RequestMappingHandlerMapping, which supports methods annotated with @RequestMapping, and SimpleUrlHandlerMapping, which manages explicit mappings between URI path patterns and handlers.

HandlerAdapter

An adapter that allows DispatcherServlet to invoke a handler in a consistent way, regardless of the handler’s underlying type.

It helps DispatcherServlet invoke the handler mapped to a request. The primary purpose of HandlerAdapter is to separate responsibilities so that DispatcherServlet does not need to concern itself with the details of how a handler is invoked.

ViewResolver

An interface that resolves the logical view name returned by a controller into an actual View object.

It resolves the logical view name returned by a handler into the actual view used to render the response.

Internal Implementation

Now, let’s take a look at how these components are implemented at the code level. The code in this article is based on spring-webmvc version 7.0.6.

doDispatch

We start with the doDispatch() method of DispatcherServlet.

HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
 
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

processedRequest initially holds the original request, but if the request is multipart, it is replaced with a MultipartHttpServletRequest wrapper. mappedHandler is declared before the try block because it also needs to be accessible from the finally and catch blocks. asyncManager is an object used to track whether asynchronous processing is in progress.

mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
    noHandlerFound(processedRequest, response);
    return;
}

If no handler can be found, the method immediately exits with return. From this point onward, the code runs under the assumption that a handler exists.

if (!mappedHandler.applyPreHandle(processedRequest, response)) {
    return;
}

If applyPreHandle() returns false, it means that one of the interceptors has intercepted the request. At this point, request processing ends immediately.

applyPreHandle() is a method of the HandlerExecutionChain class. It calls the preHandle() method of each registered interceptor in order.

  • If it returns true, the request proceeds to the next interceptor or to the handler itself (the controller method).
  • If it returns false, the interceptor is considered to have already handled the response, so DispatcherServlet does nothing further.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

This is where HandlerAdapter actually invokes the controller and receives the resulting ModelAndView.

if (asyncManager.isConcurrentHandlingStarted()) {
    return;
}

If the controller starts asynchronous processing, the final result is not available yet. The method therefore returns at this point without rendering the view.

applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);

There are cases where a controller does not explicitly specify a view name when returning. In such cases, applyDefaultViewName() fills in a default view name based on the request information.

Next, applyPostHandle() is called, which invokes the postHandle() method of each registered interceptor. While preHandle() is called in order as the request comes in, before the handler is executed, postHandle() is called after the handler has executed and before the view is rendered, unwinding the interceptor chain in reverse order.

catch (Exception ex) {
    dispatchException = ex;
}
catch (Throwable err) {
    // As of 4.3, we're processing Errors thrown from handler methods as well,
    // making them available for @ExceptionHandler methods and other scenarios.
    dispatchException = new ServletException("Handler dispatch failed: " + err, err);
}

One important detail here is that exceptions are not thrown immediately. Instead, they are stored in the dispatchException variable. Throwable is also caught and wrapped in a ServletException. According to the comment in the source code, this is intended to allow an Error thrown from a handler method to be handled by an @ExceptionHandler.

finally {
    if (asyncManager.isConcurrentHandlingStarted()) {
        // Instead of postHandle and afterCompletion
        if (mappedHandler != null) {
            mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
        }
        asyncManager.setMultipartRequestParsed(multipartRequestParsed);
    }
    else {
        // Clean up any resources used by a multipart request.
        if (multipartRequestParsed || asyncManager.isMultipartRequestParsed()) {
            cleanupMultipart(processedRequest);
        }
    }
}

The subsequent processing differs depending on whether the request is being handled synchronously or asynchronously.

  • Asynchronous processing: The request has not finished yet, so multipart cleanup (cleanupMultipart) is not performed. Instead, the multipartRequestParsed state is passed to asyncManager, which delegates the cleanup until the asynchronous processing has actually completed.
  • Synchronous processing: The request has already finished, so if it was processed as a multipart request, its resources are immediately cleaned up by calling cleanupMultipart().

getHandler

Next, let’s take a look at the getHandler() method that appeared in doDispatch(). Before we do that, it will be helpful to first look at the inheritance hierarchy of HandlerMapping.

HandlerMapping layer

The HandlerMapping class has the following inheritance hierarchy. Let’s keep these parent-child relationships in mind as we continue.

getHandler() is defined in HandlerMapping Looking at the Javadoc above the method, we can see that it serves the following purpose:

Return a handler and any interceptors for this request. The returned HandlerExecutionChain contains a handler Object, rather than even a tag interface, so that handlers are not constrained in any way. Returns null if no match was found. This is not an error. The DispatcherServlet will query all registered HandlerMapping beans to find a match, and only decide there is an error if none can find a handler.

protected @Nullable HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    if (this.handlerMappings != null) {
        for (HandlerMapping mapping : this.handlerMappings) {
            HandlerExecutionChain handler = mapping.getHandler(request);
            if (handler != null) {
                return handler;
            }
        }
    }
    return null;
}

When a Spring Boot REST API application starts up, an instance of RequestMappingHandlerMapping is created as a bean and registered in the handlerMappings list. This is where getHandler() is called. Since the actual type of the mapping is RequestMappingHandlerMapping, but it does not override getHandler(), the call proceeds up the inheritance hierarchy until AbstractHandlerMapping.getHandler() is executed. Let’s take a look at part of the AbstractHandlerMapping.getHandler() implementation.

Object handler = getHandlerInternal(request);
if (handler == null) {
    handler = getDefaultHandler();
}
if (handler == null) {
    return null;
}

This is where the actual handler is looked up. However, the handler is not looked up directly here either. Instead, getHandlerInternal(), which is overridden by a subclass, is called. If no handler is found, it falls back to the default handler (defaultHandler) configured in HandlerMapping. If there is no default handler either, it returns null, and the loop in DispatcherServlet.getHandler() moves on to the next HandlerMapping.

HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);

Next, it assembles a HandlerExecutionChain. A HandlerExecutionChain represents a handler execution chain and consists of a handler object and handler interceptors. Inside this method, the common interceptors registered with this HandlerMapping, along with any MappedInterceptors that match the current request URL, are added to the chain.

Let’s take another look at getHandlerInternal(), which appeared in AbstractHandlerMapping.getHandler(). In AbstractHandlerMapping.getHandlerInternal() is declared as an abstract method.

protected abstract @Nullable Object getHandlerInternal(HttpServletRequest request) throws Exception;

따라서 동적 바인딩에 의해 RequestMappingHandlerMapping 인스턴스의 상속 체인을 따라 실제로 오버라이드가 존재하는 가장 가까운 RequestMappingInfoHandlerMappinggetHandlerInternal()을 호출하게 됩니다. 해당 메서드는 아래와 같습니다.

Therefore, through dynamic dispatch, the call follows the inheritance hierarchy of the RequestMappingHandlerMapping instance and reaches the nearest override, RequestMappingInfoHandlerMapping.getHandlerInternal().

The method looks like this:

@Override
protected @Nullable HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
    request.removeAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
    try {
        return super.getHandlerInternal(request);
    }
    finally {
        ProducesRequestCondition.clearMediaTypesAttribute(request);
    }
}

As you can see, it calls the parent implementation with super.getHandlerInternal(request). This leads to AbstractHandlerMethodMapping.getHandlerInternal(). Let’s look at the key part of AbstractHandlerMethodMapping.getHandlerInternal().

HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);

It calls lookupHandlerMethod(), which contains the core logic for finding the best match among the registered mappings for the current request. Once a HandlerMethod is found, if it is still holding only the bean name as a String, it is fully resolved into the actual controller bean instance at this point and then returned.

Here again, the actual mapping lookup is delegated to lookupHandlerMethod(). Let’s continue by looking at this method.

List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
if (directPathMatches != null) {
    addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
    addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, request);
}

getMappingsByDirectPath() is a cache lookup that quickly finds mappings registered with a fixed string path, rather than a URL pattern—for example, /users/list. If a match is found here, there is no need to iterate over all mappings (getRegistrations().keySet()), so the lookup ends there. In other words, this is a performance optimization. Patterns containing path variables, such as /users/{id}, do not go through this branch and instead proceed to the second block, where all mappings are iterated over.

private void addMatchingMappings(Collection<T> mappings, List<Match> matches, HttpServletRequest request) {
    for (T mapping : mappings) {
        T match = getMatchingMapping(mapping, request);
        if (match != null) {
            matches.add(new Match(match, this.mappingRegistry.getRegistrations().get(mapping)));
        }
    }
}

Inside addMatchingMappings(), the getMatchingMapping() method is called. getMatchingMapping() is an abstract method responsible for determining whether a mapping actually matches the current request. The concrete implementation in RequestMappingInfoHandlerMapping performs this combined matching logic.

Let’s return to lookupHandlerMethod().

if (matches.size() > 1) {
    Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
    matches.sort(comparator);
    bestMatch = matches.get(0);

The method handles the possibility that multiple controller methods may match the same request. The process of sorting the matching controller methods to select the best one is delegated to getMappingComparator().

    request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
    handleMatch(bestMatch.mapping, lookupPath, request);
    return bestMatch.getHandlerMethod();
}
else {
    return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);
}
  • If a match is found: setAttribute() stores the BEST_MATCHING_HANDLER_ATTRIBUTE value, handleMatch() is called, and the handler is returned.
  • If no match is found: Instead of simply returning null, the method delegates to the handleNoMatch() hook. This is where the flow leads to more specific error handling, such as 404 or 405.

Through this process, the matching handler is eventually returned to mappedHandler in doDispatch().

At this point, it might seem that DispatchServlet.doDispatch() could simply execute mappedHandler directly. But instead, we see it obtain a HandlerAdapter. If we already have the matching handler, why don't we just execute it? Why do we need to look up another adapter?

The return type of mappedHandler.getHandler() is Object. The official Javadoc for HandlerMapping.getHandler() also explicitly states that the handler is kept as an Object so that it can be of any type. In a REST API controller, the actual object is typically a HandlerMethod. But at the code level, DispatcherServlet receives it without knowing whether it is a HandlerMethod, an implementation of the Controller interface, an HttpRequestHandler, or something else. An Object has no common method for saying “execute this.” Even if we wanted to call something like mappedHandler.getHandler().handle() directly, the code would not compile because Object does not have a handle() method. That is where HandlerAdapter comes in. Regardless of the handler's actual type, a HandlerAdapter first uses supports(Object handler) to determine whether it can handle that type. It then uses handle(request, response, Object handler) to cast the handler as needed and execute it. In other words, DispatcherServlet does not need to know the concrete type of the handler. It can simply ask it to “handle this request” through the intermediate HandlerAdapter layer in a consistent way.

Put another way, the reason for separating the Handler and Adapter is to divide responsibilities so that each component can focus on its own role without needing to know the concrete implementation of the other. As a result, the system becomes extensible: even if a new handler type is introduced, there is no need to modify DispatcherServlet or the existing implementations. We can simply add a new HandlerAdapter dedicated to that handler type.

Now that we understand why an adapter is necessary, let’s move on to HandlerAdapter itself.

getHandlerAdapter

Let’s first take a look at the inheritance hierarchy of HandlerAdapter, just as we did with HandlerMapping.

HandlerAdapter layer

With this hierarchy in mind, let’s examine the logic of DispatcherServlet.getHandlerAdapter().

for (HandlerAdapter adapter : this.handlerAdapters) {
    if (adapter.supports(handler)) {
        return adapter;
    }
}

This follows exactly the same pattern as the loop in DispatcherServlet.getHandler() we looked at earlier. Just as DispatcherServlet iterates over handlerMappings and asks each HandlerMapping whether it can handle the request, here it iterates over handlerAdapters and asks each HandlerAdapter whether it can handle the given handler. That is what adapter.supports(handler) does. The decision is entirely delegated to each adapter, and DispatcherServlet simply uses the first adapter that returns true.

For a REST API using @RestController, the runtime type of the adapter object is RequestMappingHandlerAdapter. However, supports() is already implemented as final in its parent class, AbstractHandlerMethodAdapter, so RequestMappingHandlerAdapter cannot override it. Therefore, when adapter.supports(handler) is called, AbstractHandlerMethodAdapter.supports() is executed directly.

throw new ServletException("No adapter for handler [" + handler +
        "]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");

Unlike getHandler(), which returns null and moves on to the next HandlerMapping when it cannot find a handler, this method immediately throws an exception if no supporting adapter can be found. "Not finding a handler" can be a normal situation—for example, the request URL itself may be invalid. But "finding a handler without having an adapter capable of executing it" appears to be treated as a problem with the application’s configuration itself. The error message, which says “The DispatcherServlet configuration needs to include...”, also indicates that this situation is treated as a configuration error.

Now, let’s take a closer look at AbstractHandlerMethodAdapter.supports().

@Override
public final boolean supports(Object handler) {
    return (handler instanceof HandlerMethod handlerMethod && supportsInternal(handlerMethod));
}

This method consists of two steps. First, it checks whether handler is an instance of HandlerMethod. If it is not, short-circuit evaluation of the && operator means that supportsInternal() is never called, and false is returned immediately. Only after passing the type check does it delegate the more specific check to supportsInternal(handlerMethod).

The supports() method itself is fixed as final, preventing subclasses from modifying it. In that sense, it plays a role similar to AbstractHandlerMapping.getHandler(), which we saw earlier in the getHandler section. The actual, type-specific decision is delegated to the abstract supportsInternal() method, which serves a role similar to getHandlerInternal().

In AbstractHandlerMethodAdapter, supportsInternal() is declared as an abstract method as follows.

protected abstract boolean supportsInternal(HandlerMethod handlerMethod);

The actual implementation is provided by RequestMappingHandlerAdapter, which extends AbstractHandlerMethodAdapter. The supportsInternal() implementation in RequestMappingHandlerAdapter looks like this:

@Override
protected boolean supportsInternal(HandlerMethod handlerMethod) {
    return true;
}

Despite its name, the method simply returns true without checking any additional conditions. According to the official Javadoc, RequestMappingHandlerAdapter has fallback logic that can handle unrecognized method arguments or return values as request parameters or model attributes. As a result, there are effectively no HandlerMethods that it cannot handle.

The comments above the method in the source code explicitly state this.

Always return true since any method argument and return value type will be processed in some way. A method argument not recognized by any HandlerMethodArgumentResolver is interpreted as a request parameter if it is a simple type, or as a model attribute otherwise. A return value not recognized by any HandlerMethodReturnValueHandler will be interpreted as a model attribute.

This stands in contrast to RequestMappingInfoHandlerMapping.getMatchingMapping(), which carefully compares conditions such as the URL, HTTP method, and headers. The job of precisely determining “who should handle this request” has already been completed at the HandlerMapping stage. The HandlerAdapter stage only needs to focus on “how to execute the handler” that has already been selected. This provides a code-level answer to the question we discussed earlier: why are HandlerMapping and HandlerAdapter separated?

At this point, the handler adapter is stored in ha inside DispatcherServlet.doDispatch(). The handler is then executed through:

ha.handle(processedRequest, response, mappedHandler.getHandler())

Finally, let’s take a look at the handle() method.

handle

handle() is a method declared in HandlerAdapter. Just like supports() we looked at above, the implementation that is ultimately executed is AbstractHandlerMethodAdapter.handle().

@Override
public final @Nullable ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws Exception {
 
    return handleInternal(request, response, (HandlerMethod) handler);
}

Unlike supports(), which safely checks the type with handler instanceof HandlerMethod, handle() performs an unchecked cast with (HandlerMethod) handler without any additional validation. In doDispatch(), getHandlerAdapter() first uses supports() to verify the handler and returns only an adapter that supports it. handle() is then called only on the adapter returned from that process. As long as this order is maintained, handle() can safely cast the handler without performing another check. The return statement also shows that the call is delegated to the subclass method handleInternal(). handleInternal() is declared as an abstract method in AbstractHandlerMethodAdapter and implemented by RequestMappingHandlerAdapter, which extends that class.

checkRequest(request);

Before invoking the controller, it checks whether the request uses a supported HTTP method and whether the required session is available.

if (this.synchronizeOnSession) {
    HttpSession session = request.getSession(false);
    if (session != null) {
        Object mutex = WebUtils.getSessionMutex(session);
        synchronized (mutex) {
            mav = invokeHandlerMethod(request, response, handlerMethod);
        }
    }
    else {
        // No HttpSession available -> no mutex necessary
        mav = invokeHandlerMethod(request, response, handlerMethod);
    }
}
else {
    // No synchronization on session demanded at all...
    mav = invokeHandlerMethod(request, response, handlerMethod);
}

If synchronizeOnSession is enabled, requests from the same session are prevented from executing the controller concurrently by locking at the session level. It obtains a session-specific lock (mutex) through WebUtils.getSessionMutex(session) and processes requests from the same session sequentially so that they cannot execute the controller at the same time. If there is no session or this option is disabled, it simply calls invokeHandlerMethod() without using synchronized.

Although the logic branches into different paths, they ultimately all lead to a call to invokeHandlerMethod(request, response, handlerMethod). This is where the controller method is actually invoked.

if (!response.containsHeader(HEADER_CACHE_CONTROL)) {
    if (getSessionAttributesHandler(handlerMethod).hasSessionAttributes()) {
        applyCacheSeconds(response, this.cacheSecondsForSessionAttributeHandlers);
    }
    else {
        prepareResponse(response);
    }
}

Once execution is complete, if no Cache-Control header has been set yet, the method automatically applies an appropriate cache policy. Handlers using @SessionAttributes receive a different cache policy from regular controllers.

Now, let’s take a closer look at invokeHandlerMethod().

WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
AsyncWebRequest asyncWebRequest = WebAsyncUtils.createAsyncWebRequest(request, response);
asyncWebRequest.setTimeout(this.asyncRequestTimeout);
 
asyncManager.setTaskExecutor(this.taskExecutor);
asyncManager.setAsyncWebRequest(asyncWebRequest);

This part sets up WebAsyncManager before execution in preparation for the possibility that the controller uses an asynchronous return type.

ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
if (this.argumentResolvers != null) {
    invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
    invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.setDataBinderFactory(binderFactory);

This is where the HandlerMethod we have been following is wrapped in a ServletInvocableHandlerMethod. You can think of ServletInvocableHandlerMethod as an object capable of resolving method parameters and actually invoking the controller method.

ModelAndViewContainer mavContainer = new ModelAndViewContainer();
mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));
modelFactory.initModel(webRequest, mavContainer, invocableMethod);

Because a controller method may receive a Model parameter or use @ModelAttribute, this step prepares the model objects before the actual invocation.

if (asyncManager.hasConcurrentResult()) {
    Object result = asyncManager.getConcurrentResult();
    Object[] resultContext = asyncManager.getConcurrentResultContext();
    Assert.state(resultContext != null && resultContext.length > 0, "Missing result context");
    mavContainer = (ModelAndViewContainer) resultContext[0];
    asyncManager.clearConcurrentResult();
    LogFormatUtils.traceDebug(logger, traceOn -> {
        String formatted = LogFormatUtils.formatValue(result, !traceOn);
        return "Resume with async result [" + formatted + "]";
    });
    invocableMethod = invocableMethod.wrapConcurrentResult(result);
}

This branch handles the case where asynchronous processing has completed and the Servlet container dispatches the request again.

invocableMethod.invokeAndHandle(webRequest, mavContainer);

This is where the controller is actually executed. Within this single line, both the actual method invocation and the processing of its return value take place.

if (asyncManager.isConcurrentHandlingStarted()) {
    return null;
}
 
return getModelAndView(mavContainer, modelFactory, webRequest);

If asynchronous processing has just started as a result of executing the controller, there is not yet a final result, so null is returned. Otherwise, a final ModelAndView is created and returned based on the information accumulated in mavContainer. If the controller uses @ResponseBody, the response has already been written inside invokeAndHandle() by this point. As a result, the returned ModelAndView is effectively null from the perspective of view rendering.

Wrapping Up

We’ve now followed the request-processing flow at the code level, starting from DispatcherServlet.doDispatch(), finding the controller that matches the request through getHandler(), determining how that controller should be invoked through getHandlerAdapter(), and finally reaching invokeHandlerMethod().

If I were to summarize the entire process in one sentence, it would be this:

HandlerMapping decides “who handles the request,” while HandlerAdapter decides “how to invoke it.”

One thing that stood out to me while going through the actual source code is that there is a recurring pattern running throughout Spring Web MVC. From AbstractHandlerMapping.getHandler() and getHandlerInternal(), to AbstractHandlerMethodAdapter.supports() and supportsInternal(), and finally handle() and handleInternal(), the same structure appears again and again: common logic is fixed in the parent class through final methods, while the actual decision-making or execution is delegated to subclasses through abstract methods. Once you recognize this pattern, I think it becomes much easier to understand other extension points in Spring Web MVC when you encounter them for the first time.

Comments0

Nickname

Please enter a nickname.