Home / Spring Boot / Web and REST / Spring MVC Internals

Spring MVC Internals

When a request reaches your controller, many components work behind the scenes. Knowing this flow helps you debug and choose between filters, interceptors and advice.

Request flow

Client
  │  HTTP request
  ▼
Servlet container (embedded Tomcat) ── thread from the pool
  ▼
Filter chain (Servlet Filters: security, CORS, logging)
  ▼
DispatcherServlet  (front controller)
  ├─ 1. HandlerMapping      finds the controller method for the URL
  ├─ 2. HandlerInterceptor.preHandle()
  ├─ 3. HandlerAdapter      resolves arguments (@PathVariable, @RequestBody ...)
  │       └─ HttpMessageConverter: JSON → Java object
  ├─ 4. Controller method runs
  ├─ 5. HttpMessageConverter: Java object → JSON
  ├─ 6. HandlerInterceptor.postHandle() / afterCompletion()
  └─ 7. On exception: HandlerExceptionResolver → @ExceptionHandler / @ControllerAdvice
  ▼
Response to the client

Filter vs Interceptor

PointServlet FilterHandlerInterceptor
Belongs toServlet specificationSpring MVC
RunsBefore DispatcherServletAround the controller method
Knows the controller?NoYes (handler object)
Good forSecurity, CORS, request logging, compressionAuthorization checks by handler, timing, adding model data

Filter example: request id

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String id = UUID.randomUUID().toString();
        MDC.put("requestId", id);                       // available in every log line
        response.setHeader("X-Request-Id", id);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

Interceptor example and registration

public class TimingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        req.setAttribute("start", System.currentTimeMillis());
        return true;                                    // false stops the request
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
                                Object handler, Exception ex) {
        long ms = System.currentTimeMillis() - (long) req.getAttribute("start");
        System.out.println(req.getRequestURI() + " took " + ms + " ms");
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TimingInterceptor()).addPathPatterns("/api/**");
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://myfrontend.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE");
    }
}

Threading model

HttpMessageConverter

Converters translate between HTTP bodies and Java objects. Boot registers a JSON converter (Jackson), a String converter and others. The Accept and Content-Type headers decide which converter is used.

DispatcherServlet.doDispatch() step by step

doDispatch(request, response)
 1. checkMultipart(request)                          wrap multipart requests
 2. mappedHandler = getHandler(request)              loop over HandlerMappings: RequestMappingHandlerMapping finds a HandlerMethod
                                                     (built at startup from all @RequestMapping methods, match by path, HTTP method, params, headers, consumes, produces)
                                                     nothing found ─► 404 (NoHandlerFoundException / NoResourceFoundException)
 3. ha = getHandlerAdapter(handler)                  RequestMappingHandlerAdapter
 4. mappedHandler.applyPreHandle()                   HandlerInterceptor.preHandle (false ─► stop)
 5. ha.handle(request, response, handler)
        a. HandlerMethodArgumentResolvers create every argument (@PathVariable, @RequestBody -> HttpMessageConverter ...)
        b. binder + validator (@Valid) ─► MethodArgumentNotValidException on errors
        c. invoke your controller method by reflection
        d. HandlerMethodReturnValueHandlers write the result (@ResponseBody -> HttpMessageConverter, selected by Accept header)
 6. applyPostHandle()                                postHandle (not called for @ResponseBody once the response is committed)
 7. processDispatchResult: on exception ─► HandlerExceptionResolvers ─► @ExceptionHandler / @ControllerAdvice
 8. triggerAfterCompletion()                         HandlerInterceptor.afterCompletion (always, even after exceptions)

Custom argument resolver: inject the current tenant

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentTenant { }

public class CurrentTenantArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentTenant.class) && parameter.getParameterType().equals(String.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        String tenant = webRequest.getHeader("X-Tenant-Id");
        if (tenant == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "X-Tenant-Id header is required");
        return tenant;
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentTenantArgumentResolver());
    }
}

@GetMapping("/api/invoices")
public List<Invoice> invoices(@CurrentTenant String tenant) { return service.findFor(tenant); }

Custom converters and formatters for request parameters

@Component
public class StringToStatusConverter implements Converter<String, OrderStatus> {
    @Override
    public OrderStatus convert(String source) {
        return OrderStatus.valueOf(source.trim().toUpperCase());       // /orders?status=paid  ->  OrderStatus.PAID
    }
}
// Boot picks up Converter, GenericConverter and Formatter beans automatically and registers them in the ConversionService

Async request processing

1. Request thread http-nio-exec-3 calls the controller, which returns a Callable / DeferredResult / CompletableFuture
2. Spring starts async mode (request.startAsync()). Thread exec-3 is RELEASED to the pool immediately
3. The work runs on another thread (MVC async executor or your executor)
4. When the result is ready, Spring dispatches the request again to the container (a second thread, exec-9),
   the return value handler writes the response
Security context, MDC and transactions do NOT move automatically between these threads.

Register more message converters (for example CSV)

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    converters.add(new CsvHttpMessageConverter());       // your AbstractHttpMessageConverter<List<Product>> for text/csv
}
// Now @GetMapping(produces = "text/csv") returns CSV when the client sends Accept: text/csv

Common mistake: Doing security or logging work in the controller of every endpoint. Cross-cutting request work belongs in a filter or interceptor.

Interview questions

What is the DispatcherServlet?

The front controller of Spring MVC. Every request comes to it and it delegates to handler mappings, adapters, converters and view or exception resolvers.

Does a filter run before or after an interceptor?

Filters run first (before the DispatcherServlet). Interceptors run inside the DispatcherServlet, around the controller.

How do I change the JSON format of dates?

Configure Jackson through properties, for example spring.jackson.serialization.write-dates-as-timestamps=false (Jackson 2). Boot 4 uses Jackson 3 with similar properties under spring.jackson.*.

Where are @RequestMapping methods stored?

RequestMappingHandlerMapping scans all beans at startup and builds a registry of mapping infos to HandlerMethods. You can see it at /actuator/mappings.

Why is postHandle not useful with @ResponseBody?

The body is written to the response inside the handler adapter, and the response may be committed before postHandle runs, so you can no longer change headers or body there.