Spring BootSpring MVC Architecture
Spring Boot

Spring MVC Architecture

Spring MVC is the web framework at the core of Spring Boot's REST API support. It implements the Model-View-Controller pattern on top of the Java Servlet API, routing HTTP requests through a pipeline of components — DispatcherServlet, handler mappings, handler adapters, and message converters — before returning a response. Understanding this pipeline is essential for debugging request routing issues, customising serialisation, adding filters, and building interceptors.

MVC Pattern in Spring

Model-View-Controller separates an application into three responsibilities: the Model holds data and business logic, the View renders the response, and the Controller handles the request and coordinates the other two. In a Spring Boot REST API the View layer is effectively replaced by JSON serialisation — HttpMessageConverters write the model directly to the response body. The three-layer separation still applies: @Service classes own the model and business logic, @RestController classes own request handling, and Jackson (the default HttpMessageConverter) owns serialisation. Spring MVC is built on the Java Servlet API. Every HTTP request enters the application as a javax.servlet.http.HttpServletRequest and must produce an HttpServletResponse. Spring MVC places a single front-controller servlet — DispatcherServlet — in front of everything and routes each request through a consistent pipeline.

The Request Processing Pipeline

Every HTTP request processed by Spring MVC follows the same sequence of components. Each step is a well-defined extension point — you can replace, decorate, or add to any component in the chain.
Shell
# ── Full request pipeline (simplified): ──────────────────────────────
#
#  Client
#    │
#    ▼
#  Servlet Container (Tomcat / Jetty / Undertow)
#    │  Parses raw TCP bytes → HttpServletRequest
#    ▼
#  Filter Chain  (OncePerRequestFilter, CORS, Security, Logging...)
#    │  Runs before AND after DispatcherServlet
#    ▼
#  DispatcherServlet  (the Front Controller)
#    │
#    ├─► HandlerMapping        finds which handler handles this request
#    │     RequestMappingHandlerMapping  (most common — reads @RequestMapping)
#    │     BeanNameUrlHandlerMapping
#    │     RouterFunctionMapping
#    │
#    ├─► HandlerInterceptor.preHandle()   (before the handler)
#    │
#    ├─► HandlerAdapter        invokes the handler
#    │     RequestMappingHandlerAdapter  (invokes @RequestMapping methods)
#    │     HttpRequestHandlerAdapter
#    │
#    │     Inside HandlerAdapter:
#    │       ArgumentResolvers   bind @PathVariable, @RequestBody, etc.
#    │       HttpMessageConverter deserialise request body (JSON → Java)
#    │       Handler method executes (your @RestController method)
#    │       HttpMessageConverter serialise return value (Java → JSON)
#    │
#    ├─► HandlerInterceptor.postHandle()  (after the handler, before view)
#    │
#    ├─► ExceptionResolver      maps exceptions to error responses
#    │     ExceptionHandlerExceptionResolver  (@RestControllerAdvice)
#    │     ResponseStatusExceptionResolver    (@ResponseStatus)
#    │     DefaultHandlerExceptionResolver    (Spring built-ins: 404, 405...)
#    │
#    ├─► HandlerInterceptor.afterCompletion() (after response committed)
#    │
#    ▼
#  Filter Chain (response phase — same filters, reverse order)
#    │
#    ▼
#  Servlet Container writes response bytes to the socket
#    │
#    ▼
#  Client receives HTTP response

HandlerMapping — Finding the Handler

HandlerMapping resolves an incoming request to a handler object — typically a controller method. Spring Boot registers several HandlerMapping implementations automatically; RequestMappingHandlerMapping is the most important because it reads @RequestMapping annotations.
Java
// ── RequestMappingHandlerMapping (default, highest priority) ─────────
// Scans all @Controller and @RestController beans at startup.
// Builds an index of (method + URI pattern + conditions) → handler method.
// At request time, finds the best matching handler.

@RestController
@RequestMapping("/users")
public class UserController {
    @GetMapping("/{id}")              // registered in RequestMappingHandlerMapping
    public UserResponse findById(...) { ... }
}

// ── HandlerMapping resolution order (lower number = higher priority): ─
// 0  — RequestMappingHandlerMapping   (@RequestMapping, most common)
// 1  — BeanNameUrlHandlerMapping      (bean name = URL, e.g. "/usersController")
// 2  — RouterFunctionMapping          (functional endpoints — WebFlux style)

// ── Inspecting registered mappings at runtime: ────────────────────────
@Component
@RequiredArgsConstructor
public class MappingLogger implements ApplicationListener<ApplicationReadyEvent> {

    private final RequestMappingHandlerMapping handlerMapping;

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        handlerMapping.getHandlerMethods().forEach((info, method) ->
            log.info("Mapped: {} → {}", info, method));
    }
}

// ── Actuator endpoint — list all mappings (add to application.yml): ───
management:
  endpoints:
    web:
      exposure:
        include: mappings
// GET /actuator/mappings → full list of registered request mappings

HandlerAdapter — Invoking the Handler

HandlerAdapter bridges DispatcherServlet and the handler. It knows how to invoke a specific type of handler — a @RequestMapping method, a HttpRequestHandler, a functional endpoint. RequestMappingHandlerAdapter is the most important and does the heavy lifting: argument resolution, message conversion, and return value handling.
Java
// ── What RequestMappingHandlerAdapter does for each request: ──────────

// 1. ARGUMENT RESOLUTION — binds method parameters from the request:
//    @PathVariable Long id         → extracted from URI template
//    @RequestParam String q        → extracted from query string
//    @RequestHeader String auth    → extracted from request header
//    @RequestBody UserRequest req  → deserialized from request body via MessageConverter
//    @Valid                        → triggers Bean Validation after binding
//    HttpServletRequest / Response → raw servlet objects
//    Principal                     → authenticated user from SecurityContext
//    Pageable                      → assembled from page/size/sort params

// 2. HANDLER INVOCATION — calls the @RequestMapping method via reflection

// 3. RETURN VALUE HANDLING — converts the return value to an HTTP response:
//    ResponseEntity<T>             → sets status, headers, then serializes body
//    plain object (UserResponse)   → 200 OK + serialized body
//    String (in @Controller)       → view name, resolved to template
//    void / null200 OK with no body (or 204 if @ResponseStatus)
//    Callable / DeferredResult     → async processing

// ── Registering a custom argument resolver ────────────────────────────
// Example: resolve a @CurrentUser annotation to the authenticated user:

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

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        return auth != null ? (UserDetails) auth.getPrincipal() : null;
    }
}

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

// Usage in a controller:
@GetMapping("/me")
public UserResponse getMe(@CurrentUser UserDetails currentUser) {
    return userService.findByUsername(currentUser.getUsername());
}

HttpMessageConverters — Serialisation and Deserialisation

HttpMessageConverters transform Java objects to and from HTTP message bodies. The converter is selected based on the Content-Type (for deserialisation) and Accept header (for serialisation). Spring Boot auto-configures Jackson's MappingJackson2HttpMessageConverter as the default JSON converter when Jackson is on the classpath.
yaml
// ── Built-in converters (registered in this order): ──────────────────
// ByteArrayHttpMessageConverter   — byte[], */*
// StringHttpMessageConverter      — String, text/plain, text/*
// ResourceHttpMessageConverter    — Resource, */*
// MappingJackson2HttpMessageConverter — any Object, application/json
// MappingJackson2XmlHttpMessageConverter — any Object, application/xml (if enabled)
// FormHttpMessageConverter        — MultiValueMap, application/x-www-form-urlencoded

// ── Customising Jackson globally in application.yml: ──────────────────
spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false
      indent-output: false
    deserialization:
      fail-on-unknown-properties: false
    default-property-inclusion: non_null
    property-naming-strategy: LOWER_CAMEL_CASE
    date-format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
    time-zone: UTC

// ── Customising Jackson programmatically: ─────────────────────────────
@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder -> builder
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
            .modules(new JavaTimeModule())
            .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
    }
}

// ── Registering a custom HttpMessageConverter: ────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // Replace all default converters (use extendMessageConverters to add):
        converters.add(new MappingJackson2HttpMessageConverter(customObjectMapper()));
    }

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        // Add a CSV converter alongside the defaults:
        converters.add(new CsvHttpMessageConverter());
    }
}

Filters vs Interceptors

Both filters and interceptors run code around the handler method, but at different points in the pipeline and with different capabilities. Filters are Servlet-level — they see the raw request before DispatcherServlet. Interceptors are Spring MVC-level — they run inside DispatcherServlet and have access to the resolved handler.
Java
# ── Servlet Filter ───────────────────────────────────────────────────
# Runs before DispatcherServlet. Has access to raw HttpServletRequest/Response.
# Cannot access the Spring handler or ModelAndView.
# Use for: CORS, authentication, request/response logging, compression.

@Component
@Order(1)
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        long start = System.currentTimeMillis();
        log.info("→ {} {}", request.getMethod(), request.getRequestURI());

        chain.doFilter(request, response);   // proceed down the chain

        long duration = System.currentTimeMillis() - start;
        log.info("← {} {} {}ms",
            response.getStatus(), request.getRequestURI(), duration);
    }
}

# ── HandlerInterceptor ────────────────────────────────────────────────
# Runs inside DispatcherServlet, after handler resolution.
# Has access to the resolved HandlerMethod — can inspect @RequestMapping metadata.
# Three hooks: preHandle (before), postHandle (after, before view),
#              afterCompletion (after response committed, always runs).

@Component
public class AuditInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) {
        // Return false to short-circuit — handler is NOT invoked:
        if (handler instanceof HandlerMethod hm) {
            RequiresAudit annotation = hm.getMethodAnnotation(RequiresAudit.class);
            if (annotation != null) {
                log.info("Audit: {} {}", request.getMethod(), request.getRequestURI());
            }
        }
        return true;   // continue processing
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception ex) {
        if (ex != null) {
            log.error("Request failed: {}", ex.getMessage());
        }
    }
}

// Register interceptors in WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuditInterceptor())
            .addPathPatterns("/api/**")           // only these paths
            .excludePathPatterns("/api/health");  // except these
    }
}

# ── Decision guide ────────────────────────────────────────────────────
# Use Filter when:
#   - You need to act before Spring's DispatcherServlet
#   - You need to modify the raw request/response stream
#   - The concern is cross-cutting at the Servlet level (CORS, security, compression)
#   - You are integrating with a non-Spring component

# Use HandlerInterceptor when:
#   - You need access to the resolved handler method
#   - You want to inspect @RequestMapping metadata or custom annotations
#   - The concern is specific to Spring MVC controllers (audit, rate limiting per endpoint)

Exception Handling in the Pipeline

Exceptions thrown by handler methods are caught by DispatcherServlet and delegated to HandlerExceptionResolvers in priority order. @RestControllerAdvice is the recommended approach because it handles exceptions from any controller and produces JSON error responses automatically.
Java
// ── Three built-in HandlerExceptionResolvers (evaluated in order): ────

// 1. ExceptionHandlerExceptionResolver
//    Handles @ExceptionHandler methods in @RestControllerAdvice classes.
//    Highest priority — use this for all custom exception mapping.

// 2. ResponseStatusExceptionResolver
//    Handles exceptions annotated with @ResponseStatus.
//    Lower priority than @ExceptionHandler.

// 3. DefaultHandlerExceptionResolver
//    Handles Spring MVC built-in exceptions:
//    NoHandlerFoundException      → 404
//    HttpRequestMethodNotSupportedException → 405
//    HttpMediaTypeNotSupportedException     → 415
//    MethodArgumentNotValidException        → 400

// ── @RestControllerAdvice — recommended approach ──────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(EntityNotFoundException ex) {
        return ErrorResponse.of(404, "Not Found", ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleAll(Exception ex) {
        log.error("Unhandled exception", ex);
        return ErrorResponse.of(500, "Internal Server Error",
            "An unexpected error occurred");
    }
}

// ── ResponseStatusException — throw from anywhere without @Advice ─────
// Use when you want a quick status code without a dedicated exception class:
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
    return userRepository.findById(id)
        .map(UserResponse::from)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "User not found: " + id));
}

WebMvcConfigurer — Customising the MVC Pipeline

WebMvcConfigurer is the primary extension point for customising Spring MVC without replacing the auto-configured defaults. Implement it on any @Configuration class to add interceptors, CORS rules, formatters, converters, resource handlers, and more.
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {

    // ── CORS ──────────────────────────────────────────────────────────
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com", "http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
    }

    // ── Interceptors ──────────────────────────────────────────────────
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new RateLimitInterceptor())
            .addPathPatterns("/api/**");
        registry.addInterceptor(new AuditInterceptor())
            .addPathPatterns("/api/admin/**");
    }

    // ── Custom argument resolvers ─────────────────────────────────────
    @Override
    public void addArgumentResolvers(
            List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserArgumentResolver());
        resolvers.add(new TenantArgumentResolver());
    }

    // ── Custom formatters (for @RequestParam type conversion) ─────────
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToMoneyConverter());
        registry.addConverter(new StringToInstantConverter());
    }

    // ── Static resource handlers ──────────────────────────────────────
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
            .addResourceLocations("classpath:/static/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS));
    }

    // ── Path matching config (Spring Boot 3) ──────────────────────────
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        // Trailing slash matching disabled by default in Spring Boot 3:
        configurer.setUseTrailingSlashMatch(false);
    }

    // ── Default servlet ───────────────────────────────────────────────
    @Override
    public void configureDefaultServletHandling(
            DefaultServletHandlerConfigurer configurer) {
        configurer.enable();  // serve static files not handled by Spring MVC
    }
}