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.
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
- Request arrives — Servlet container calls DispatcherServlet.service()
- Locale / theme resolution — optional LocaleResolver, ThemeResolver
- Handler lookup — HandlerMapping returns a HandlerExecutionChain (handler + interceptors)
- Interceptors preHandle — HandlerInterceptor.preHandle() — return false to abort
- Handler execution — HandlerAdapter.handle() invokes controller method
- Argument resolution — HandlerMethodArgumentResolver builds method parameters
- Return value handling — HandlerMethodReturnValueHandler processes result
- View or message conversion — ViewResolver (HTML) or HttpMessageConverter (REST JSON)
- Interceptors postHandle / afterCompletion — cleanup, logging
- Exception handling — if thrown, HandlerExceptionResolver chain (includes @ExceptionHandler)
RequestMappingHandlerMapping builds a map of RequestMappingInfo → HandlerMethod at startup by scanning @RequestMapping annotations. Matching considers HTTP method, path pattern (PathPattern in Spring 6), headers, params, and consumable/producible media types.
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.
| HandlerMapping | Handler type |
|---|---|
| RequestMappingHandlerMapping | @Controller / @RequestMapping methods — 99% of Boot REST apps |
| RouterFunctionMapping | Functional style RouterFunction + HandlerFunction |
| BeanNameUrlHandlerMapping | Legacy: bean name = URL path |
| HandlerAdapter | Invokes |
|---|---|
| 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.
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.
| Annotation | Behavior |
|---|---|
| @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 |
@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.
| Annotation | HTTP method | Typical use |
|---|---|---|
| @GetMapping | GET | Read — idempotent, cacheable |
| @PostMapping | POST | Create, commands, non-idempotent actions |
| @PutMapping | PUT | Full replace of resource |
| @PatchMapping | PATCH | Partial update (Spring 4.3+) |
| @DeleteMapping | DELETE | Remove resource |
@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);
}
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.
| Annotation | Source | Notes |
|---|---|---|
| @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 |
@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);
}
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 type | Behavior |
|---|---|
| Plain object (in @RestController) | 200 OK, serialized to JSON via Jackson |
| ResponseEntity<T> | Full control — status, headers, body |
| @ResponseStatus on method/exception | Sets HTTP status without ResponseEntity boilerplate |
| void | 204 No Content (unless @ResponseStatus overrides) |
| String in @Controller | View name (not JSON string) |
| StreamingResponseBody | Stream large files without buffering in memory |
@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());
}
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).
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
| Annotation | Constraint |
|---|---|
| @NotNull | Not null (null fails; empty string passes) |
| @NotBlank | Not null, not empty, not whitespace-only (strings) |
| @NotEmpty | Not null, not empty (strings, collections, maps, arrays) |
| @Size(min, max) | String/collection/array length |
| @Min / @Max | Numeric bounds |
| @Positive / @Negative | Sign constraints |
| Email format (pragmatic, not RFC-exhaustive) | |
| @Pattern | Regex match |
| @Past / @Future | Date/time relative to now |
@Valid vs @Validated
| Annotation | Source | Group 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 |
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
@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());
}
}
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
@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
@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.
{
"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.
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.
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
- Client sends Content-Type: application/json — converter selected for @RequestBody deserialization
- Client sends Accept: application/json — converter selected for response serialization
- @RequestMapping(produces = "application/json") constrains handler to JSON responses
- Default Boot stack: Jackson JSON, plain text, bytes, XML (if on classpath)
| Converter | Media types |
|---|---|
| MappingJackson2HttpMessageConverter | application/json, application/*+json |
| StringHttpMessageConverter | text/plain |
| ByteArrayHttpMessageConverter | application/octet-stream |
| MappingJackson2XmlHttpMessageConverter | application/xml (optional dep) |
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).
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
) {}
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
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) {}
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.
| Aspect | Servlet Filter | HandlerInterceptor |
|---|---|---|
| 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.
@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.
@Bean
FilterRegistrationBean<RequestIdFilter> requestIdFilter(RequestIdFilter filter) {
FilterRegistrationBean<RequestIdFilter> bean = new FilterRegistrationBean<>(filter);
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
bean.addUrlPatterns("/api/*");
return bean;
}
HandlerInterceptor lifecycle
@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/**");
}
}
| Callback | When | Notes |
|---|---|---|
| preHandle | Before controller | Return false to stop — no controller, no postHandle |
| postHandle | After controller, before view render | Not called if exception thrown (use afterCompletion) |
| afterCompletion | After full request complete | Always called — cleanup, logging; receives exception if any |
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.js → http://localhost:8080/app.js
@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
@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
@CrossOrigin(origins = "https://app.acme.com", maxAge = 3600)
@RestController
@RequestMapping("/api/public")
class PublicApiController {}
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.
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.
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.