DispatcherServlet Internals: Request Mapping, Handler Adapters, and the Filter Chain
Every HTTP request that reaches your Spring Boot application passes through exactly one servlet: DispatcherServlet. It is the front controller. There is no magic routing layer, no hidden proxy. One servlet receives the request, consults a chain of strategies, invokes your controller method, and writes the response. Understanding this sequence is the difference between debugging a routing issue in five minutes and losing an afternoon.
The DispatcherServlet.doDispatch() Sequence
The core loop lives in DispatcherServlet.doDispatch(). Every request follows the same path:
-
Find the handler. Iterate the registered
HandlerMappinginstances. The first one that returns a non-nullHandlerExecutionChainwins. This chain wraps the handler object (your controller method) and anyHandlerInterceptorinstances that apply. -
Find the adapter. Iterate the registered
HandlerAdapterinstances. Callsupports(handler)on each. The first adapter that returnstruehandles invocation. -
Run interceptor pre-handle. Call
preHandle()on each interceptor in order. If any returnsfalse, the request stops here. -
Invoke the handler. The adapter calls your controller method. This is where argument resolution, type conversion, and deserialization happen.
-
Run interceptor post-handle. Call
postHandle()on each interceptor in reverse order. -
Render the view or write the response. For
@ResponseBodymethods, the response is already written during step 4. For view-based controllers, view resolution happens here. -
Run interceptor afterCompletion(). Always executes, even if an exception was thrown. Cleanup goes here.
Here is a simplified trace of what the framework does:
// Inside DispatcherServlet.doDispatch() — simplified
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {
HandlerExecutionChain mappedHandler = getHandler(request); // Step 1
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // Step 2
if (!mappedHandler.applyPreHandle(request, response)) return; // Step 3
ModelAndView mv = ha.handle(request, response, mappedHandler.getHandler()); // Step 4
mappedHandler.applyPostHandle(request, response, mv); // Step 5
processDispatchResult(request, response, mappedHandler, mv); // Step 6
mappedHandler.triggerAfterCompletion(request, response, null); // Step 7
}
The getHandler() method iterates this.handlerMappings, a list populated during context initialization. In a standard Spring Boot app with @EnableWebMvc or the auto-configuration, this list contains RequestMappingHandlerMapping (for annotated controllers), WelcomePageHandlerMapping, and RouterFunctionMapping (for functional endpoints).
HandlerMapping: From URL to Handler
RequestMappingHandlerMapping is the primary HandlerMapping in annotation-based Spring MVC. At startup, it scans every bean annotated with @Controller or @RestController. For each method annotated with @RequestMapping (or its composed variants @GetMapping, @PostMapping, etc.), it creates a RequestMappingInfo object that captures the URL pattern, HTTP method, content type constraints, and parameter conditions.
When a request arrives, RequestMappingHandlerMapping.getHandlerInternal() matches the request against all registered RequestMappingInfo entries. The matching considers:
- URL path pattern (using
PathPatternParserby default in Spring 6+) - HTTP method (GET, POST, etc.)
consumesandproducesmedia types- Request parameters and headers
The result is a HandlerMethod: a reference to the controller bean and the specific Method object. This gets wrapped in a HandlerExecutionChain along with any applicable interceptors.
In our multi-tenant SaaS backend:
@RestController
@RequestMapping("/api/v1/tenants/{tenantId}/orders")
public class OrderController {
@GetMapping
public List<OrderDto> listOrders(@PathVariable String tenantId) {
// RequestMappingHandlerMapping matches GET /api/v1/tenants/acme/orders here
return orderService.findByTenant(tenantId);
}
@PostMapping
public ResponseEntity<OrderDto> createOrder(
@PathVariable String tenantId,
@RequestBody CreateOrderRequest request) {
// Matches POST /api/v1/tenants/acme/orders
OrderDto created = orderService.create(tenantId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
You can inspect every registered mapping at runtime. Hit /actuator/mappings and you will see the full list of URL patterns, HTTP methods, and the controller methods they map to. This is the first place to look when a 404 confuses you.
HandlerAdapter: Invoking the Controller Method
RequestMappingHandlerAdapter is the adapter for annotated controller methods. Its handleInternal() method creates a ServletInvocableHandlerMethod, which orchestrates argument resolution, method invocation via reflection, and return value handling.
The adapter holds two critical lists:
List<HandlerMethodArgumentResolver>: resolves each method parameterList<HandlerMethodReturnValueHandler>: processes the return value
These are composed into HandlerMethodArgumentResolverComposite and HandlerMethodReturnValueHandlerComposite, which iterate in registration order.
HandlerMethodArgumentResolver: Binding Parameters
When the adapter invokes your controller method, it must provide values for every parameter. It iterates the HandlerMethodArgumentResolver chain for each parameter. The first resolver where supportsParameter() returns true handles it.
Key resolvers in a standard Spring Boot app:
| Annotation | Resolver | Behavior |
|---|---|---|
@RequestBody | RequestResponseBodyMethodProcessor | Reads the HTTP body, selects an HttpMessageConverter, deserializes |
@PathVariable | PathVariableMethodArgumentResolver | Extracts from URI template variables |
@RequestParam | RequestParamMethodArgumentResolver | Reads query parameters or form data |
@RequestHeader | RequestHeaderMethodArgumentResolver | Reads HTTP headers |
| None (simple type) | RequestParamMethodArgumentResolver | Falls back to query parameter by name |
For @RequestBody, the resolver iterates registered HttpMessageConverter instances. MappingJackson2HttpMessageConverter matches application/json. It uses Jackson’s ObjectMapper to deserialize the request body into your target type.
For @PathVariable and @RequestParam, the resolver uses the ConversionService to convert the raw String value to the target type. This is how @PathVariable Long id works: StringToNumberConverterFactory handles the conversion.
HandlerMethodReturnValueHandler: Serializing Responses
After your method returns, the adapter must write the response. The HandlerMethodReturnValueHandler chain processes the return value.
If the return type is ResponseEntity, HttpEntityMethodProcessor handles it. It sets the status code, copies headers, then delegates body serialization to an HttpMessageConverter.
If the method is annotated with @ResponseBody (or the class is @RestController, which implies it), RequestResponseBodyMethodProcessor handles the return value. It selects a message converter based on content negotiation: the Accept header from the client intersected with the produces attribute of the mapping.
@GetMapping(value = "/{orderId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<OrderDto> getOrder(
@PathVariable String tenantId,
@PathVariable Long orderId) {
OrderDto order = orderService.findByTenantAndId(tenantId, orderId);
return ResponseEntity.ok(order);
// HttpEntityMethodProcessor handles ResponseEntity
// Delegates to MappingJackson2HttpMessageConverter for the body
}
The ResponseEntity path gives you explicit control over status codes and headers. The @ResponseBody path is more concise. Both end up at the same HttpMessageConverter for body serialization.
HandlerInterceptor vs Servlet Filter
Both intercept requests. They operate at different layers.
Servlet Filters wrap the entire servlet invocation. They see raw HttpServletRequest/HttpServletResponse. They execute before DispatcherServlet even starts. Spring Security is a filter chain. Filters do not have access to the handler method metadata.
HandlerInterceptors execute inside DispatcherServlet.doDispatch(), between steps 3 and 7 above. They have access to the handler object (the HandlerMethod), which means they can inspect annotations on the target controller method. Interceptors are Spring-managed beans.
Use filters for cross-cutting concerns that do not need handler context: logging, CORS, security, compression. Use interceptors when you need to know which controller method will handle (or just handled) the request.
// Filter: operates at the servlet level, outside DispatcherServlet
@Component
public class RequestTimingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long start = System.nanoTime();
filterChain.doFilter(request, response);
long duration = System.nanoTime() - start;
log.info("Request {} took {} ms", request.getRequestURI(),
Duration.ofNanos(duration).toMillis());
}
}
// Interceptor: operates inside DispatcherServlet, has handler context
@Component
public class TenantContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
if (handler instanceof HandlerMethod hm) {
// Can inspect annotations on the controller method
TenantRequired annotation = hm.getMethodAnnotation(TenantRequired.class);
if (annotation != null) {
String tenantId = extractTenant(request);
TenantContext.set(tenantId);
}
}
return true;
}
}
The Failure Mode: Modifying a Committed Response
A common mistake with interceptors is attempting to modify the response body in postHandle().
// BROKEN: modifying the response after the handler has already written to it
@Component
public class ResponseWrapperInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
// By this point, for @ResponseBody methods, the response body
// has already been written and flushed by the HttpMessageConverter.
// response.isCommitted() returns true.
// This write either silently fails or throws IllegalStateException.
response.getWriter().write("{\"wrapped\": true}");
}
}
With @ResponseBody or ResponseEntity return types, the message converter writes the body during ha.handle() in step 4. By the time postHandle() runs in step 5, the response output stream is committed. Calling getWriter() or getOutputStream() on a committed response either throws IllegalStateException or does nothing. The interceptor silently fails, and the developer spends hours wondering why the response is not wrapped.
The Correct Pattern: ResponseBodyAdvice
When you need to transform the response body before serialization, use ResponseBodyAdvice. This interface hooks into the message converter pipeline, before bytes are written to the output stream.
// CORRECT: using ResponseBodyAdvice to modify the response before serialization
@RestControllerAdvice
public class ApiResponseWrapper implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
// Apply to all controller methods in our API package
return returnType.getDeclaringClass().getPackageName()
.startsWith("com.saas.api");
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
// Wrap the response body before Jackson serializes it
return new ApiResponse<>(body, Instant.now());
}
}
record ApiResponse<T>(T data, Instant timestamp) {}
ResponseBodyAdvice.beforeBodyWrite() executes after the handler returns but before the HttpMessageConverter writes bytes. The response is not committed yet. You can wrap, filter, or transform the body freely.
This distinction matters in production. The interceptor approach fails silently in most configurations. The ResponseBodyAdvice approach integrates with the converter pipeline and works reliably.
Observing the Machinery
Three tools for debugging the dispatch sequence:
-
/actuator/mappings: Shows every registered handler mapping, the URL patterns, HTTP methods, and the controller method they resolve to. Start here when requests return 404. -
Breakpoint in
DispatcherServlet.doDispatch(): Step through the handler resolution, adapter selection, and interceptor chain. You will see exactly whichHandlerMappingmatches and whichHandlerAdapterinvokes. -
DEBUGlogging fororg.springframework.web.servlet: Setlogging.level.org.springframework.web.servlet=DEBUGand the framework logs every step: which handler was found, which interceptors applied, which argument resolvers were used.
The DispatcherServlet is not a black box. It is a well-defined pipeline with pluggable strategies at every stage. When something goes wrong, you trace the pipeline step by step.