Spring Web MVC

Every REST call passes through DispatcherServlet—the front controller that maps URLs to methods, resolves arguments, invokes your controller, and serializes the response. This chapter covers that pipeline end-to-end: from @GetMapping to global @ControllerAdvice, Jackson customization, and CORS.

junior mid Spring Boot 3.x

DispatcherServlet architecture

Spring MVC is built on the Front Controller pattern: one servlet receives all requests and delegates to pluggable components. That servlet is DispatcherServlet—registered by Boot at / by default, mapped after embedded Tomcat starts.

sequenceDiagram
  participant Client as HTTP client
  participant DS as DispatcherServlet
  participant HM as HandlerMapping
  participant HA as HandlerAdapter
  participant Ctrl as Controller
  participant MC as HttpMessageConverter
  Client->>DS: HTTP request
  DS->>HM: getHandler request
  HM-->>DS: HandlerExecutionChain
  DS->>HA: handle request handler
  HA->>Ctrl: invoke controller method
  Ctrl-->>HA: return value
  HA->>MC: write body JSON
  MC-->>Client: HTTP response

Request lifecycle — step by step

  1. Request arrives — Servlet container calls DispatcherServlet.service()
  2. Locale / theme resolution — optional LocaleResolver, ThemeResolver
  3. Handler lookupHandlerMapping returns a HandlerExecutionChain (handler + interceptors)
  4. Interceptors preHandleHandlerInterceptor.preHandle() — return false to abort
  5. Handler executionHandlerAdapter.handle() invokes controller method
  6. Argument resolutionHandlerMethodArgumentResolver builds method parameters
  7. Return value handlingHandlerMethodReturnValueHandler processes result
  8. View or message conversionViewResolver (HTML) or HttpMessageConverter (REST JSON)
  9. Interceptors postHandle / afterCompletion — cleanup, logging
  10. Exception handling — if thrown, HandlerExceptionResolver chain (includes @ExceptionHandler)
🔬 Under the Hood

RequestMappingHandlerMapping builds a map of RequestMappingInfoHandlerMethod at startup by scanning @RequestMapping annotations. Matching considers HTTP method, path pattern (PathPattern in Spring 6), headers, params, and consumable/producible media types.

🎯 Interview Tip

DispatcherServlet is the single entry point. Filters (Servlet spec) run before the servlet; interceptors run inside MVC after handler mapping. Spring Security's filter chain sits in front of DispatcherServlet—that's why security applies before controllers.

HandlerMapping & HandlerAdapter

Spring supports multiple handler types (annotation controllers, functional endpoints, legacy controllers). HandlerMapping finds the handler; HandlerAdapter knows how to invoke it.

HandlerMappingHandler type
RequestMappingHandlerMapping @Controller / @RequestMapping methods — 99% of Boot REST apps
RouterFunctionMapping Functional style RouterFunction + HandlerFunction
BeanNameUrlHandlerMapping Legacy: bean name = URL path
HandlerAdapterInvokes
RequestMappingHandlerAdapter @RequestMapping methods — resolves args, invokes via reflection, handles return values
HandlerFunctionAdapter Functional HandlerFunction

ViewResolver vs HttpMessageConverter

REST APIs return objects serialized by HttpMessageConverter (Jackson for JSON). Server-rendered apps return view names resolved by ViewResolver (Thymeleaf, JSP). @RestController = message converters only; @Controller can return both.

📦 Real World

Microservices are almost entirely RequestMappingHandlerMapping + Jackson. Debugging 404s: check actuator mappings endpoint or enable logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping=TRACE to see registered routes at startup.

Controllers

Controllers are Spring beans (stereotype @Controller) whose methods handle HTTP requests. The framework discovers them via component scan and registers handler mappings at context refresh.

AnnotationBehavior
@Controller Return values can be view names (resolved by ViewResolver) or written via @ResponseBody per method
@RestController @Controller + @ResponseBody on class — every method body serialized directly to HTTP response
REST controller skeleton
@RestController
@RequestMapping("/api/v1/orders")
class OrderController {
  private final OrderService orderService;

  OrderController(OrderService orderService) {
    this.orderService = orderService;
  }

  @GetMapping("/{id}")
  OrderDto get(@PathVariable long id) {
    return orderService.findDto(id);
  }
}

Request mapping

Map HTTP verbs and paths to handler methods. Prefer composed annotations over raw @RequestMapping for clarity.

AnnotationHTTP methodTypical use
@GetMappingGETRead — idempotent, cacheable
@PostMappingPOSTCreate, commands, non-idempotent actions
@PutMappingPUTFull replace of resource
@PatchMappingPATCHPartial update (Spring 4.3+)
@DeleteMappingDELETERemove resource
Advanced mapping — consumes/produces
@PostMapping(
    path = "/{id}/documents",
    consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
    produces = MediaType.APPLICATION_JSON_VALUE
)
DocumentDto upload(
    @PathVariable long id,
    @RequestPart("file") MultipartFile file
) {
  return documentService.store(id, file);
}
🔖 Version Note

Spring Framework 6 / Boot 3 use PathPattern (parsed at startup) instead of Ant-style AntPathMatcher by default—better performance and stricter syntax. Patterns like {id:\\d+} constrain path variables with regex.

Method argument resolution

RequestMappingHandlerAdapter delegates to specialized resolvers—each annotation maps to an HTTP input source.

AnnotationSourceNotes
@PathVariable URI template /orders/{id} Type conversion via ConversionService
@RequestParam Query string ?page=0&size=20 required=false, defaultValue
@RequestBody HTTP body Deserialized by HttpMessageConverter — Jackson for JSON
@RequestHeader HTTP headers @RequestHeader("X-Request-Id")
@CookieValue Cookies Session tokens, preferences
@ModelAttribute Form fields / query params bound to object Traditional MVC forms; also binds query to POJO in REST
Pageable / Sort Query params Spring Data — ?page=0&size=20&sort=createdAt,desc
Full parameter example
@GetMapping("/search")
Page<OrderDto> search(
    @RequestParam(required = false) String q,
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size,
    @RequestHeader(value = "X-Tenant-Id", required = false) String tenantId,
    Pageable pageable  // overrides page/size if present
) {
  return orderService.search(q, tenantId, pageable);
}
⚠️ Pitfall

Missing @RequestBody on a POST JSON payload — Spring tries query param binding and fields stay null. Missing @PathVariable name when param name isn't retained (compile without -parameters) causes startup failure—always specify explicit name: @PathVariable("id").

Return value handling

Return type determines how the response is built—status code, headers, and body.

Return typeBehavior
Plain object (in @RestController)200 OK, serialized to JSON via Jackson
ResponseEntity<T>Full control — status, headers, body
@ResponseStatus on method/exceptionSets HTTP status without ResponseEntity boilerplate
void204 No Content (unless @ResponseStatus overrides)
String in @ControllerView name (not JSON string)
StreamingResponseBodyStream large files without buffering in memory
ResponseEntity patterns
@PostMapping
ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest request) {
  OrderDto created = orderService.create(request);
  URI location = URI.create("/api/v1/orders/" + created.id());
  return ResponseEntity.created(location).body(created);
}

@GetMapping("/{id}")
ResponseEntity<OrderDto> get(@PathVariable long id) {
  return orderService.findDto(id)
      .map(ResponseEntity::ok)
      .orElse(ResponseEntity.notFound().build());
}
💡 Pro Tip

For consistent API responses, wrap payloads in a standard envelope (ApiResponse<T>) or use RFC 7807 ProblemDetail for errors—avoid mixing raw strings, maps, and DTOs across endpoints.

Request validation

Bean Validation (Jakarta Validation, formerly JSR-380) declaratively constrains request DTOs. Spring MVC triggers validation when you annotate parameters with @Valid or @Validated.

Add spring-boot-starter-validation (Hibernate Validator implementation).

Validated request DTO
public record CreateOrderRequest(
    @NotBlank @Size(max = 64) String customerId,
    @NotEmpty @Size(max = 50) List<@NotNull @Positive Long> productIds,
    @Email String contactEmail,
    @Pattern(regexp = "STANDARD|EXPRESS") String shippingTier,
    @Min(1) @Max(100) int quantity
) {}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderDto create(@Valid @RequestBody CreateOrderRequest request) {
  return orderService.create(request);
}

Common constraint annotations

AnnotationConstraint
@NotNullNot null (null fails; empty string passes)
@NotBlankNot null, not empty, not whitespace-only (strings)
@NotEmptyNot null, not empty (strings, collections, maps, arrays)
@Size(min, max)String/collection/array length
@Min / @MaxNumeric bounds
@Positive / @NegativeSign constraints
@EmailEmail format (pragmatic, not RFC-exhaustive)
@PatternRegex match
@Past / @FutureDate/time relative to now

@Valid vs @Validated

AnnotationSourceGroup validation
@Valid Jakarta Validation No — cascades to nested objects with @Valid on fields
@Validated Spring Yes — @Validated(Create.class) activates constraint groups; also enables method-level validation on @Service beans
Validation groups
public interface OnCreate {}
public interface OnUpdate {}

public record UserRequest(
    @Null(groups = OnCreate.class)
    @NotNull(groups = OnUpdate.class)
    Long id,
    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    String name
) {}

@PostMapping
UserDto create(@Validated(OnCreate.class) @RequestBody UserRequest req) { /* ... */ }

@PutMapping("/{id}")
UserDto update(@Validated(OnUpdate.class) @RequestBody UserRequest req) { /* ... */ }

Custom ConstraintValidator

Cross-field business rule
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
  String message() default "endDate must be after startDate";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

class DateRangeValidator implements ConstraintValidator<ValidDateRange, BookingRequest> {
  @Override
  public boolean isValid(BookingRequest value, ConstraintValidatorContext ctx) {
    if (value == null) return true;
    return value.endDate().isAfter(value.startDate());
  }
}
⚠️ Pitfall

Validation on nested objects requires @Valid on the field: @Valid Address shippingAddress — without it, inner constraints are silently skipped. Records need constraints on components; class-level constraints need custom validators.

Exception handling

Unhandled exceptions become 500 errors with stack traces—unacceptable in production APIs. Spring MVC resolves exceptions through a chain ending at your @ControllerAdvice handlers.

@ExceptionHandler — controller scope

Local handler
@RestController
@RequestMapping("/api/orders")
class OrderController {
  @GetMapping("/{id}")
  OrderDto get(@PathVariable long id) {
    return orderService.findDto(id);
  }

  @ExceptionHandler(OrderNotFoundException.class)
  @ResponseStatus(HttpStatus.NOT_FOUND)
  ProblemDetail notFound(OrderNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
  }
}

@RestControllerAdvice — global handlers

Global exception advice
@RestControllerAdvice
class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

  @ExceptionHandler(BusinessRuleException.class)
  ResponseEntity<ProblemDetail> business(BusinessRuleException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
    problem.setProperty("errorCode", ex.getCode());
    return ResponseEntity.unprocessableEntity().body(problem);
  }

  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(
      MethodArgumentNotValidException ex,
      HttpHeaders headers,
      HttpStatusCode status,
      WebRequest request
  ) {
    List<String> errors = ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
        .toList();
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, "Validation failed");
    problem.setProperty("errors", errors);
    return ResponseEntity.badRequest().body(problem);
  }
}

ProblemDetail — RFC 7807 (Spring 6 / Boot 3)

Built-in org.springframework.http.ProblemDetail standardizes error JSON: type, title, status, detail, instance, plus extension properties.

application/problem+json response
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 42 not found",
  "instance": "/api/v1/orders/42"
}

Extend ResponseEntityExceptionHandler to override Spring MVC's built-in handling for MethodArgumentNotValidException, HttpMessageNotReadableException (malformed JSON), MissingServletRequestParameterException, etc.—instead of reimplementing each.

📦 Real World

Never return exception messages or stack traces to clients in prod. Log full stack server-side with correlation ID; return stable errorCode strings clients can branch on. Map DataIntegrityViolationException to 409 Conflict, not raw 500.

🎯 Interview Tip

Exception handler resolution order: most specific handler wins; local @ExceptionHandler on controller beats global advice if both match. @ControllerAdvice can be scoped with basePackageClasses or annotations to apply only to certain controllers.

Content negotiation & message converters

HttpMessageConverter transforms HTTP bodies ↔ Java objects. For REST APIs, MappingJackson2HttpMessageConverter handles application/json.

How serialization works

  1. Client sends Content-Type: application/json — converter selected for @RequestBody deserialization
  2. Client sends Accept: application/json — converter selected for response serialization
  3. @RequestMapping(produces = "application/json") constrains handler to JSON responses
  4. Default Boot stack: Jackson JSON, plain text, bytes, XML (if on classpath)
ConverterMedia types
MappingJackson2HttpMessageConverterapplication/json, application/*+json
StringHttpMessageConvertertext/plain
ByteArrayHttpMessageConverterapplication/octet-stream
MappingJackson2XmlHttpMessageConverterapplication/xml (optional dep)
🔬 Under the Hood

Boot auto-configures a shared ObjectMapper bean (Jackson 3 in Boot 4 / Jackson 2 in Boot 3). HttpMessageConverters auto-configuration registers converters with that mapper. Customizing the ObjectMapper @Bean affects all JSON read/write in the app.

Jackson integration

Control JSON shape at field level (annotations) or globally (ObjectMapper / application.yml).

Field-level Jackson annotations
public record OrderDto(
    long id,
    @JsonProperty("customer_id") String customerId,
    @JsonIgnore String internalNotes,
    @JsonInclude(JsonInclude.Include.NON_NULL) BigDecimal discount,
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
    Instant createdAt
) {}
Global Jackson config — application.yml
spring:
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
      indent-output: true
    deserialization:
      fail-on-unknown-properties: false
    property-naming-strategy: SNAKE_CASE
Custom serializer
public class MoneySerializer extends JsonSerializer<Money> {
  @Override
  public void serialize(Money value, JsonGenerator gen, SerializerProvider serializers)
      throws IOException {
    gen.writeStartObject();
    gen.writeNumberField("amount", value.amount());
    gen.writeStringField("currency", value.currency().getCurrencyCode());
    gen.writeEndObject();
  }
}

@JsonSerialize(using = MoneySerializer.class)
public record Money(BigDecimal amount, Currency currency) {}
⚠️ Pitfall

fail-on-unknown-properties: true breaks clients on any new API field—prefer false for public APIs, document schema versioning instead. Circular references in JPA entities serialized directly cause infinite JSON—return DTOs, not entities.

Filters & interceptors

Cross-cutting request logic—logging, auth headers, timing—can live in Servlet filters or MVC interceptors. They run at different layers; choosing wrong causes subtle bugs.

AspectServlet FilterHandlerInterceptor
Spec Servlet API — container-level Spring MVC — dispatcher-level
Runs when Before/after any servlet (static, actuator, MVC) After handler mapping — only MVC requests with a handler
Knows controller? No Yes — access HandlerMethod in preHandle
Use for Security, encoding, request ID, body caching Per-controller logging, permission checks, MVC-only timing
sequenceDiagram
  participant F as Servlet Filters
  participant DS as DispatcherServlet
  participant I as HandlerInterceptor
  participant C as Controller
  F->>F: Filter chain forward
  F->>DS: enter servlet
  DS->>I: preHandle
  DS->>C: invoke handler
  C-->>DS: return
  DS->>I: postHandle
  DS->>I: afterCompletion
  DS-->>F: response
  F->>F: Filter chain backward

OncePerRequestFilter

Extend OncePerRequestFilter for filters that must run exactly once per request—prevents double execution on forwards/includes. Spring Security filters use this base class.

Request ID filter
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class RequestIdFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain chain
  ) throws ServletException, IOException {
    String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
        .orElse(UUID.randomUUID().toString());
    MDC.put("requestId", requestId);
    response.setHeader("X-Request-Id", requestId);
    try {
      chain.doFilter(request, response);
    } finally {
      MDC.remove("requestId");
    }
  }
}

Filter ordering

Use @Order on Filter beans or register via FilterRegistrationBean with explicit order. Lower order value = earlier in chain. Security filter chain typically runs first.

Explicit filter registration
@Bean
FilterRegistrationBean<RequestIdFilter> requestIdFilter(RequestIdFilter filter) {
  FilterRegistrationBean<RequestIdFilter> bean = new FilterRegistrationBean<>(filter);
  bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
  bean.addUrlPatterns("/api/*");
  return bean;
}

HandlerInterceptor lifecycle

Timing interceptor
@Component
class TimingInterceptor implements HandlerInterceptor {
  private static final String START = "timing.start";

  @Override
  public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    req.setAttribute(START, System.nanoTime());
    return true;
  }

  @Override
  public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
      Object handler, Exception ex) {
    long start = (long) req.getAttribute(START);
    long ms = (System.nanoTime() - start) / 1_000_000;
    log.info("{} {} {}ms", req.getMethod(), req.getRequestURI(), ms);
  }
}

@Configuration
class WebMvcConfig implements WebMvcConfigurer {
  private final TimingInterceptor timingInterceptor;

  WebMvcConfig(TimingInterceptor timingInterceptor) {
    this.timingInterceptor = timingInterceptor;
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(timingInterceptor).addPathPatterns("/api/**");
  }
}
CallbackWhenNotes
preHandleBefore controllerReturn false to stop — no controller, no postHandle
postHandleAfter controller, before view renderNot called if exception thrown (use afterCompletion)
afterCompletionAfter full request completeAlways called — cleanup, logging; receives exception if any
💡 Pro Tip

Need to read request body twice (log + parse)? Use ContentCachingRequestWrapper in a filter before the body is consumed—not in an interceptor.

Static resources & CORS

Boot serves static files from classpath locations automatically. CORS configures which browser origins may call your API cross-domain.

Static resource locations

By default, Spring Boot serves from (first match wins):

  • classpath:/META-INF/resources/
  • classpath:/resources/
  • classpath:/static/ — most common for SPAs, favicon, assets
  • classpath:/public/

Files at src/main/resources/static/app.jshttp://localhost:8080/app.js

Custom resource handlers
@Configuration
class StaticResourceConfig implements WebMvcConfigurer {
  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/docs/**")
        .addResourceLocations("classpath:/docs/")
        .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic());
  }
}

CORS — Cross-Origin Resource Sharing

Browsers block cross-origin AJAX unless the server returns CORS headers. Preflight OPTIONS requests fire for non-simple methods/headers.

Global CORS via WebMvcConfigurer

addCorsMappings — recommended for APIs
@Configuration
class CorsConfig implements WebMvcConfigurer {
  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/api/**")
        .allowedOrigins("https://app.acme.com", "http://localhost:3000")
        .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
        .allowedHeaders("*")
        .exposedHeaders("X-Request-Id")
        .allowCredentials(true)
        .maxAge(3600);
  }
}

@CrossOrigin — per controller/method

Controller-level CORS
@CrossOrigin(origins = "https://app.acme.com", maxAge = 3600)
@RestController
@RequestMapping("/api/public")
class PublicApiController {}
⚠️ Pitfall

allowedOrigins("*") with allowCredentials(true) is invalid—browsers reject it. Use explicit origins in prod. Spring Security has its own CORS integration—if using Security, configure CORS on HttpSecurity.cors() or global CorsConfigurationSource bean.

📦 Real World

SPAs (React/Vue) on CDN calling API on different subdomain: CORS on API + cookies need SameSite=None; Secure or token auth. Static SPA is often served from same origin via reverse proxy (nginx routes /api to backend, / to static)—eliminating CORS entirely.

🔖 Version Note

Spring Framework 5.3+ prefers allowedOriginPatterns over allowedOrigins for wildcard subdomain support (e.g. https://*.acme.com). Boot 3 / Spring 6 use PathPattern throughout MVC.