Everything between the wire and your method
A controller method looks deceptively simple — an object comes in, an object goes out. Underneath,
Spring MVC is converting bytes, matching media types, binding headers, and choosing a status code.
Understanding that machinery is what lets you handle uploads, set a Location header, fix a CORS
error, or stream a huge export without running out of memory.
HttpMessageConverters are the bridge
Every body — in or out — passes through an HttpMessageConverter. It deserializes the request body
for @RequestBody and serializes the return value for @ResponseBody.
@PostMapping("/orders")
Order create(@RequestBody CreateOrderRequest req) { // JSON bytes → object (read)
return service.create(req); // object → JSON bytes (write)
}
Spring chooses a converter by matching Content-Type (reading) or Accept (writing) against each
converter's supported types. Boot registers Jackson for JSON, a string converter for text, a byte
converter for binary, plus XML if the dependency is on the classpath. Want a new media type? Add the
dependency — don't parse bodies by hand.
On a @RestController, @ResponseBody is implied on every method. You only spell it out on a plain
@Controller that mixes data and view-rendering handlers.
Headers and cookies
Read a header with @RequestHeader and a cookie with @CookieValue. Write a header most cleanly
through a ResponseEntity builder, and a cookie through a ResponseCookie:
@GetMapping("/dashboard")
ResponseEntity<String> dashboard(
@CookieValue(value = "session", required = false) String session) {
ResponseCookie cookie = ResponseCookie.from("theme", "dark")
.httpOnly(true).secure(true).sameSite("Lax").path("/")
.maxAge(Duration.ofDays(30)).build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body("ok");
}
ResponseCookie gives you HttpOnly, Secure, and SameSite — set all three on any session
cookie. For headers shared across many endpoints (security headers, server tags), set them once in a
filter or interceptor instead of repeating them.
File uploads
Bind the upload to a MultipartFile via @RequestPart on a handler that consumes
multipart/form-data:
@PostMapping(value = "/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<String> upload(
@RequestPart("file") MultipartFile file,
@RequestPart("meta") @Valid AvatarMeta meta) { // a JSON part — deserialized + validated
storage.save(file.getOriginalFilename(), file.getBytes());
return ResponseEntity.ok("stored");
}
@RequestPart beats @RequestParam for mixed bodies because it runs each part through the message
converters, so a JSON part can be validated. Cap sizes with spring.servlet.multipart.max-file-size.
The Location header on create
When you create a resource, return 201 with a Location header pointing at it, built from the
current request so it stays environment-correct:
URI location = ServletUriComponentsBuilder
.fromCurrentRequest().path("/{id}")
.buildAndExpand(saved.id()).toUri();
return ResponseEntity.created(location).body(saved);
Clients can then GET the new resource directly.
CORS
CORS decides which browser origins may call your API. Use @CrossOrigin per handler or a global
WebMvcConfigurer:
@Configuration
class CorsConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry r) {
r.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
}
Spring answers the browser's preflight OPTIONS automatically. Never combine wildcard origins with
allowCredentials(true) — browsers reject it. With Spring Security on the classpath, also permit
CORS at the security layer.
Filters vs interceptors
Both wrap requests, but at different layers. A Filter is a servlet-spec component outside
Spring MVC that sees every request. A HandlerInterceptor runs inside the DispatcherServlet
and only wraps mapped handlers, with access to the resolved handler.
request → Filter(s) → DispatcherServlet → Interceptor.preHandle
→ @Controller method → Interceptor.postHandle → afterCompletion
→ Filter(s) → response
Use a filter for MVC-agnostic concerns that must run first (logging, compression, low-level security); use an interceptor when you need the matched handler or want to limit to MVC routes.
Asynchronous and streaming responses
Returning a CompletableFuture, DeferredResult, or Callable releases the servlet thread
while slow work runs, then writes the response on completion — better throughput under I/O-bound
load:
@GetMapping("/quote")
CompletableFuture<Quote> quote(@RequestParam String symbol) {
return quoteService.fetchAsync(symbol); // container thread freed until resolved
}
For large downloads, stream with a StreamingResponseBody so bytes aren't buffered in memory; for
server-pushed events, use an SseEmitter. Each writes on an async thread.
When binding fails
Spring maps binding failures to sensible status codes automatically — 400 for a malformed or invalid
body, 415 for an unsupported Content-Type, 406 when Accept can't be met, 405 for the wrong
method. You customize the body of those errors with a @RestControllerAdvice (the next page),
not by catching exceptions in every handler.
Recap
HttpMessageConverters bridge bytes and objects; @RequestBody/@ResponseBody mark the directions;
ResponseEntity and ResponseCookie shape headers and cookies; @RequestPart handles uploads;
Location advertises new resources; WebMvcConfigurer configures CORS; filters and interceptors
wrap requests at different layers; and CompletableFuture/StreamingResponseBody keep throughput
and memory healthy. Master this layer and the HTTP surface of your service stops being a mystery.