Spring · 05

REST Controllers & the MVC Request Path

Every REST endpoint you write is a plain method that Spring MVC calls on your behalf — after a dozen hidden steps. This page walks the whole path: how DispatcherServlet finds your method, how arguments are built from the URL, headers and JSON body, how the return value becomes bytes, where filters and interceptors sit, and the design choices (status codes, pagination, versioning) that separate a demo API from a production one. It ends with a runnable 150-line mini-MVC so you can see the mechanism with no framework at all.

9
Deep chapters
10
Steps in doDispatch
40+
Code examples
1
Runnable mini-MVC
01
Chapter One · Why It Exists

From Servlet Plumbing to a Method Call

GET /books/7 Dispatcher one(7L) URL + verb route + bind + convert your method you write the method; Spring writes the plumbing

A web request arrives as bytes on a socket. Tomcat (the embedded servlet container in Boot) parses it into an HttpServletRequest and hands it to a servlet. Everything between that object and your business logic — choosing which method to call, extracting 7 from /books/7, parsing a JSON body into a Java object, turning your return value back into JSON, choosing 200 vs 404 — is plumbing. Spring MVC owns that plumbing so a controller method can be an ordinary method.

Before: the raw servlet
Raw servlet — you do the plumbing
public class BookServlet extends HttpServlet { private final ObjectMapper mapper = new ObjectMapper(); private final BookService service = new BookService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String path = req.getPathInfo(); // "/7" -- you parse the URL if (path == null || path.length() < 2) { resp.sendError(400); return; } long id; try { id = Long.parseLong(path.substring(1)); } // you convert the type catch (NumberFormatException e) { resp.sendError(400, "bad id"); return; } Book book = service.find(id); if (book == null) { resp.sendError(404); return; } // you choose the status resp.setContentType("application/json"); // you set the content type mapper.writeValue(resp.getOutputStream(), book); // you serialise } // doPost, doPut, doDelete ... each repeats the same parse/convert/serialise dance }
After: the controller
Spring MVC — you write the intent
@RestController @RequestMapping("/books") class BookController { private final BookService service; BookController(BookService service) { this.service = service; } @GetMapping("/{id}") Book one(@PathVariable long id) { return service.find(id); // BookNotFoundException -> 404 via @RestControllerAdvice } @PostMapping ResponseEntity<Book> create(@Valid @RequestBody NewBook body) { Book saved = service.create(body); URI location = URI.create("/books/" + saved.id()); return ResponseEntity.created(location).body(saved); // 201 + Location header } }

The two snippets do the same work. The second one never mentions a URL parser, a number-format exception, a content type or a JSON library — each of those is a pluggable strategy object inside Spring MVC. That is the real reason to learn the request path: when something goes wrong (415, 406, a 400 with an unreadable body, an interceptor that never fires) the cause is always one specific strategy in one specific step.

Analogy: DispatcherServlet is a restaurant host. Every guest (request) comes through the same door; the host checks the reservation book (HandlerMapping), assigns a waiter who knows how to serve that kind of table (HandlerAdapter), the waiter collects the order details from what the guest said and carried (argument resolvers), the kitchen (your controller) cooks, and the waiter plates the dish in the format the guest asked for (message converters). You are the kitchen — and you only ever see the order ticket.
ConcernRaw servletSpring MVC strategyInterface / class
Routingweb.xml / manual path switchannotation scan at startupHandlerMapping / RequestMappingHandlerMapping
Invoking your codeyou are the servletadapter calls any method signatureHandlerAdapter / RequestMappingHandlerAdapter
Reading inputsgetParameter, manual parsingresolver per parameter kindHandlerMethodArgumentResolver
JSON in / outcall Jackson yourselfconverter chosen by Content-Type / AcceptHttpMessageConverter
Errors to HTTPtry/catch + sendErrorresolver chainHandlerExceptionResolver
Cross-cuttingservlet filters onlyfilters + interceptorsFilter, HandlerInterceptor
02
Chapter Two · Mental Model

The Ten-Step Request Path

One HTTP request produces exactly one call to DispatcherServlet.doDispatch(). Reading that method top to bottom is the best map of Spring MVC you can have. Boot registers the servlet for / so it sees every request not claimed by another servlet; filters run before it, interceptors run inside it.

Boot registers one DispatcherServlet bean on / (via DispatcherServletRegistrationBean)
▶
every request that reaches the servlet layer enters doDispatch — single front controller, single place to reason about
At startup RequestMappingHandlerMapping scans every bean annotated @Controller / @RestController
▶
it builds a registry of RequestMappingInfo (verb, path pattern, consumes, produces, params) to HandlerMethod — a pure lookup table; no reflection scan per request
getHandler(request) asks each HandlerMapping in order and returns a HandlerExecutionChain
▶
the chain = the matched HandlerMethod plus every HandlerInterceptor whose path patterns match
RequestMappingHandlerAdapter invokes the method through ServletInvocableHandlerMethod
▶
each parameter is built by the first HandlerMethodArgumentResolver that supportsParameter; the return value goes to the first HandlerMethodReturnValueHandler
For @ResponseBody / ResponseEntity the return handler picks an HttpMessageConverter
▶
it intersects the request Accept header with what converters can write for the return type — empty intersection = 406 Not Acceptable
Any exception escapes to processHandlerException
▶
resolvers run in order: ExceptionHandlerExceptionResolver (your @ExceptionHandler / advice), ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver
DispatcherServlet.doDispatch — one request, in order
Tomcat thread + filter chain 1 Http11Processor ApplicationFilterChain: encoding, security, your OncePerRequestFilter DispatcherServlet.doDispatch 2 FrameworkServlet.service sets up LocaleContext + RequestContextHolder then calls doService to doDispatch getHandler() to HandlerExecutionChain 3 RequestMappingHandlerMapping.lookupHandlerMethod (PathPattern match) miss in every mapping: 404 NoResourceFoundException getHandlerAdapter() 4 RequestMappingHandlerAdapter.supports(HandlerMethod) interceptor.preHandle() in order 5 return false stops here: no handler, no postHandle afterCompletion still runs for interceptors already passed Argument resolvers build each parameter 6 PathVariableMethodArgumentResolver, RequestParamMethodArgumentResolver, RequestResponseBodyMethodProcessor (Jackson read + @Valid) Your controller method runs 7 ServletInvocableHandlerMethod.invokeAndHandle via reflection thrown exception skips straight to step 10 Return value handler + converter 8 HttpEntityMethodProcessor or RequestResponseBodyMethodProcessor MappingJackson2HttpMessageConverter.write: status, headers, body interceptor.postHandle() in reverse 9 skipped when the handler threw; response is usually already committed for @ResponseBody, so headers can no longer change Exception resolvers + afterCompletion 10 processDispatchResult: HandlerExceptionResolver chain triggerAfterCompletion always runs (reverse order)
Reading order for debugging
  • Put a breakpoint in DispatcherServlet.doDispatch and step over getHandler / applyPreHandle / ha.handle — you will see exactly which mapping matched and which interceptor stopped the request.
  • Turn on logging.level.org.springframework.web=DEBUG: Spring logs the matched handler (Mapped to com.acme.BookController#one(long)) and any resolved exception (Resolved [org.springframework...]).
  • List every registered mapping at runtime with the actuator mappings endpoint (/actuator/mappings) — the fastest way to answer "why 404?".
⚠ Production gotchas & how to diagnose them:
  • Ambiguous mapping at startup: two methods mapped to the same verb + path give IllegalStateException: Ambiguous mapping. Cannot map ... method ... to {GET [/books]}: There is already ... bean method mapped. The app refuses to start — good. Fix: differentiate by params, headers, consumes/produces, or merge the methods.
  • 404 with no controller trace: the request never reached doDispatch. Common causes: a filter or Spring Security chain rejected it first; the context path (server.servlet.context-path) is missing from the URL; a @RestController lives outside the component-scan base package of the @SpringBootApplication class.
  • Trailing slash: since Spring Framework 6.0 /books/ no longer matches @GetMapping("/books") (trailing-slash matching defaults to off). Clients that worked on Boot 2 start getting 404 after the upgrade.
  • postHandle cannot modify the response of a @ResponseBody method: the converter has already written (and usually committed) the response before postHandle runs. Use a ResponseBodyAdvice or a filter for that.
03
Chapter Three · Routing

Controllers & Mapping Annotations

@RestController is meta-annotated with @Controller + @ResponseBody: the first makes the class a bean that RequestMappingHandlerMapping scans; the second tells the return-value handler to write the result straight to the response body through a message converter instead of treating it as a view name. Class-level @RequestMapping supplies a prefix; method-level mappings add to it.

A complete, realistic controller
@RestController @RequestMapping("/api/v1/orders") class OrderController { private final OrderService orders; OrderController(OrderService orders) { this.orders = orders; } @GetMapping // GET /api/v1/orders?status=PAID List<OrderSummary> list(@RequestParam(required = false) OrderStatus status) { return orders.list(status); } @GetMapping("/{id}") // GET /api/v1/orders/42 OrderDetail get(@PathVariable long id) { return orders.get(id); } @PostMapping(consumes = "application/json") // 415 unless Content-Type is JSON ResponseEntity<OrderDetail> place(@Valid @RequestBody PlaceOrder cmd) { OrderDetail created = orders.place(cmd); return ResponseEntity.created(URI.create("/api/v1/orders/" + created.id())).body(created); } @PutMapping("/{id}") // full replacement, idempotent OrderDetail replace(@PathVariable long id, @Valid @RequestBody PlaceOrder cmd) { return orders.replace(id, cmd); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) // 204, empty body void cancel(@PathVariable long id) { orders.cancel(id); } }
What a mapping really matches

A @RequestMapping is not just a path. It is a RequestMappingInfo made of independent conditions, and a request must satisfy all of them. When several methods match, the most specific wins by comparing conditions in a fixed order.

ConditionAttributeMatches onFailure status
Pathvalue / pathURL path pattern (PathPattern): /orders/{id}, /files/**, /v{n:[0-9]+}/x404
Methodmethod or @GetMapping etc.HTTP verb405 HttpRequestMethodNotSupportedException (with Allow header)
Consumesconsumesrequest Content-Type415 HttpMediaTypeNotSupportedException
Producesproducesrequest Accept406 HttpMediaTypeNotAcceptableException
Paramsparams = "debug"query parameter present / equals / absent400 UnsatisfiedServletRequestParameterException
Headersheaders = "X-Api-Version=2"request header present / equals404 (treated as no match)
Path patterns, precisely
PatternRequest pathMatch?Captured
/orders/{id}/orders/42yesid = 42
/orders/{id}/orders/42/itemsno— (one segment only)
/orders/{id:[0-9]+}/orders/abcno— falls to the next mapping or 404
/files/**/files/a/b/c.txtyes** only allowed at the end with PathPattern
/orders/*/orders/42yessingle segment wildcard
/orders/orders/notrailing slash off since Spring 6
/orders/me vs /orders/{id}/orders/meboth matchliteral beats variable: me method wins
Specificity in action
@GetMapping("/orders/me") // chosen for /orders/me : literal segments beat URI variables OrderDetail mine() { ... } @GetMapping("/orders/{id}") // chosen for /orders/42 OrderDetail byId(@PathVariable long id) { ... } @GetMapping(value = "/orders/{id}", produces = "application/pdf") // chosen only when Accept: application/pdf byte[] invoice(@PathVariable long id) { ... }
⚠ Production gotchas & how to diagnose them:
  • Missing @RequestMapping prefix on one controller but not another leads to "works locally, 404 in prod" when a gateway strips a prefix. Keep the prefix in one place and use server.servlet.context-path only for deployment concerns.
  • Wrong stereotype: @Controller without @ResponseBody makes Spring treat the returned String as a view name. Symptom: TemplateInputException: Error resolving template [books] or an empty page with a 404 for /error. Use @RestController for APIs.
  • Stateful controller fields: controllers are singletons shared by every request thread. A mutable field such as private User current; leaks data between users. Keep controllers stateless; pass state through parameters.
  • @PathVariable without a name relies on the -parameters compiler flag (Spring Boot Maven/Gradle plugins turn it on). In a hand-rolled build you get IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not found in class file. Fix: enable -parameters or name it: @PathVariable("id").
  • @RequestMapping without method on a method accepts every verb. Prefer the composed @GetMapping/@PostMapping so a DELETE cannot hit a read handler.
04
Chapter Four · Request Data

Argument Resolvers & Binding

Each controller parameter is filled by one HandlerMethodArgumentResolver, picked by annotation and type. Simple values (path variables, query parameters, headers) go through the ConversionService; the request body goes through an HttpMessageConverter (Jackson for JSON). The distinction matters: conversion failures and body-parse failures raise different exceptions with different messages.

AnnotationSource of the valueResolverConverted byFailure exception
@PathVariableURI template segmentPathVariableMethodArgumentResolverConversionServiceMethodArgumentTypeMismatchException
@RequestParamquery string or form fieldRequestParamMethodArgumentResolverConversionServiceMissingServletRequestParameterException / type mismatch
@RequestHeaderHTTP headerRequestHeaderMethodArgumentResolverConversionServiceMissingRequestHeaderException
@CookieValuecookieServletCookieValueMethodArgumentResolverConversionServiceMissingRequestCookieException
@RequestBodyraw bodyRequestResponseBodyMethodProcessorHttpMessageConverter (Jackson)HttpMessageNotReadableException
@ModelAttribute / none (non-simple type)query + form fields bound to an objectServletModelAttributeMethodProcessorDataBinderBindException / MethodArgumentNotValidException
HttpServletRequest, Principal, Localethe request itselfServletRequestMethodArgumentResolver——
From request parts to method parameters — one resolver per parameter
REQUEST PART RESOLVER (first that supports it) CONTROLLER PARAMETER path segment: 12 PathVariable...ArgumentResolver @PathVariable long shopId query: ?page=2 RequestParam...ArgumentResolver @RequestParam int page header: X-Request-Id RequestHeader...ArgumentResolver @RequestHeader String requestId body: JSON bytes RequestResponseBodyMethodProcessor @Valid @RequestBody NewProduct Authentication in context PrincipalMethodArgumentResolver Principal caller
Every kind of input in one handler
record SearchCriteria(String q, Integer minPrice, Integer maxPrice, List<String> tag) { } @GetMapping("/shops/{shopId}/products") Page<ProductSummary> search( @PathVariable long shopId, // /shops/12/products @RequestParam(defaultValue = "1") @Min(1) int page, // ?page=2 (default 1) @RequestParam Optional<String> sort, // optional, no 400 when absent @RequestParam(name = "tag", required = false) List<String> tags, // ?tag=a&tag=b or ?tag=a,b @RequestHeader(value = "X-Request-Id", required = false) String requestId, @CookieValue(name = "tenant", defaultValue = "public") String tenant, SearchCriteria criteria, // no annotation: ModelAttribute binding of ?q=&minPrice= Locale locale, // resolved from Accept-Language Principal caller) { // from the security context ... } @PostMapping("/shops/{shopId}/products") ResponseEntity<Product> create(@PathVariable long shopId, @Valid @RequestBody NewProduct body, // JSON -> record via Jackson, then Bean Validation UriComponentsBuilder uri) { // injected helper for Location Product saved = service.create(shopId, body); return ResponseEntity.created(uri.path("/shops/{s}/products/{id}").build(shopId, saved.id())).body(saved); }
How @RequestBody actually reads JSON
StepWhat happensClass
1Resolver supports @RequestBody; reads Content-Type of the requestRequestResponseBodyMethodProcessor
2Finds the first converter whose canRead(targetClass, contentType) is trueMappingJackson2HttpMessageConverter
3No converter matches (e.g. text/plain body for a record)HttpMediaTypeNotSupportedException to 415
4Jackson parses and builds the object (records via canonical constructor)ObjectMapper.readValue
5Syntax error / wrong type / empty bodyHttpMessageNotReadableException to 400
6If the parameter has @Valid / @Validated, run the validatorDataBinder.validate to MethodArgumentNotValidException to 400
7Value injected into your method—
Records as request DTOs — and the primitive trap
// Entities never cross the HTTP boundary: a separate DTO decouples wire format from the schema. record NewProduct( @NotBlank String name, @NotNull @DecimalMin("0.00") BigDecimal price, // boxed: absent field => null => @NotNull rejects it @NotNull Integer stock) { } // 'int stock' would silently become 0 when the field is missing!
Request / response on the wire
POST /shops/12/products HTTP/1.1 Content-Type: application/json {"name": "Keyboard", "price": 49.90, "stock": 5} HTTP/1.1 201 Created Location: http://localhost:8080/shops/12/products/731 Content-Type: application/json {"id": 731, "name": "Keyboard", "price": 49.90, "stock": 5}
⚠ Production gotchas & how to diagnose them:
  • Type mismatch in path or query: GET /orders/abc throws MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'long' and returns 400. If you see 500 instead, no advice handles it — check your @ExceptionHandler(Exception.class) is not swallowing it.
  • Unreadable body: HttpMessageNotReadableException: JSON parse error: Unexpected character or Required request body is missing (empty body with required=true). The message often includes the Jackson path (through reference chain: ...NewProduct["price"]) — surface only a generic message to clients, log the detail.
  • Missing primitive fields become 0/false because Jackson uses the default value for absent properties. Use boxed types plus @NotNull, or turn on spring.jackson.deserialization.fail-on-null-for-primitives=true.
  • Unknown JSON properties are ignored by default in Boot (Boot sets FAIL_ON_UNKNOWN_PROPERTIES to false). A typo like "nmae" is silently dropped and the field stays null. If you want strictness: spring.jackson.deserialization.fail-on-unknown-properties=true.
  • Binding an entity directly from @RequestBody enables mass assignment: a client sends "role":"ADMIN" or "id":1 and overwrites fields you never meant to expose. Always bind to a purpose-built DTO and map explicitly.
  • List<String> query params: ?tag=a,b is split by the conversion service but ?tag=a&tag=b is the canonical form; a comma inside a value ("Doe, John") is then ambiguous.
05
Chapter Five · Responses

ResponseEntity, Status Codes & Verb Semantics

Returning a plain object means "200 OK + JSON of this". The moment you need a different status, a header (Location, ETag, Cache-Control) or a conditional body, return ResponseEntity. The status code is part of your API contract: clients branch on it, retries depend on it, and proxies cache by it.

Four ways to control the response
// 1. Plain return value: 200 OK. Null return from @RestController => 200 with EMPTY body (often not what you want). @GetMapping("/{id}") Book plain(@PathVariable long id) { return service.find(id); } // 2. @ResponseStatus: fixed status for the happy path @PostMapping @ResponseStatus(HttpStatus.CREATED) Book create(@RequestBody NewBook b) { ... } // 3. ResponseEntity: status + headers + body decided at runtime @GetMapping("/{id}") ResponseEntity<Book> get(@PathVariable long id, @RequestHeader(value = "If-None-Match", required = false) String etag) { return service.findOptional(id) .map(b -> { String tag = '"' + b.version() + '"'; if (tag.equals(etag)) return ResponseEntity.<Book>status(HttpStatus.NOT_MODIFIED).eTag(tag).build(); return ResponseEntity.ok().eTag(tag).cacheControl(CacheControl.maxAge(Duration.ofMinutes(5))).body(b); }) .orElseGet(() -> ResponseEntity.notFound().build()); } // 4. Throw ResponseStatusException for ad-hoc errors (better: a domain exception + @RestControllerAdvice) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Book 42 not found");
SituationStatusBodyNotes
Read succeeded200 OKresourceidempotent, cacheable
Created a resource201 Createdresourceadd Location header pointing at the new URI
Accepted for async processing202 Acceptedjob id / status URLclient polls the status resource
Success, nothing to return204 No Contentnonetypical for DELETE and PUT that does not echo
Conditional GET unchanged304 Not Modifiednonedriven by ETag / If-None-Match
Malformed syntax / bad types400 Bad Requesterror bodyunreadable JSON, type mismatch, failed validation
Unauthenticated401 Unauthorizederror bodyactually means "unauthenticated"; send WWW-Authenticate
Authenticated but forbidden403 Forbiddenerror bodypermission denied; do not use 401
No such resource404 Not Founderror bodyalso used to hide existence from unauthorised callers
Wrong verb for resource405 Method Not Allowederror bodyautomatic; includes Allow
State conflict / duplicate409 Conflicterror bodyunique key violation, optimistic-lock failure
Precondition failed412 / 428error bodyIf-Match mismatch / required but missing
Semantically invalid422 Unprocessable Contenterror bodyoptional alternative to 400 for business-rule failures
Throttled429 Too Many Requestserror bodyadd Retry-After
Server bug500 Internal Server Errorgeneric error bodynever include stack trace
Dependency down502 / 503 / 504error bodygateway/overload; clients may retry
PUT vs PATCH vs POST
VerbSemanticsIdempotentTypical bodyPitfall
POST /orderscreate (server picks id) or trigger an actionnonew resourceclient retry duplicates; protect with an Idempotency-Key header
PUT /orders/42replace the whole resourceyesfull representationomitted fields are reset to null — surprising for partial clients
PATCH /orders/42partial updatedepends on the patchJSON Merge Patch (application/merge-patch+json) or JSON PatchMerge Patch cannot distinguish "set null" from "absent" without extra work
DELETE /orders/42removeyesnonesecond call returns 404 — still idempotent in effect
⚠ Production gotchas & how to diagnose them:
  • 200 with an empty body for "not found": returning null (or Optional.empty() mapped lazily) from a @RestController yields 200 and zero bytes. Clients then crash parsing JSON. Throw a domain exception or return ResponseEntity.notFound().
  • Using 200 for errors ({"success":false}) defeats retries, monitoring dashboards and every HTTP client library. Use real statuses.
  • @ResponseStatus(CREATED) but no Location: clients cannot navigate to the new resource without guessing. Build the URI from the request with ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") so proxies and context paths stay right.
  • void method returns 200: a @DeleteMapping returning void gives 200 with no body; add @ResponseStatus(NO_CONTENT) if you promise 204.
  • Setting headers after the body was written (in an interceptor postHandle or after response.getOutputStream().write) silently does nothing because the response is committed. Diagnose by checking response.isCommitted().
06
Chapter Six · Content

Content Negotiation & Jackson

The client says what it can read (Accept) and what it is sending (Content-Type). The server holds an ordered list of HttpMessageConverter beans. For output, AbstractMessageConverterMethodProcessor.writeWithMessageConverters computes the media types the handler can produce for the return type, intersects them with the acceptable types from Accept, sorts by quality value (q) and specificity, and uses the first converter that canWrite. Nothing in common means 406.

RequestHandler producesChosen typeResult
Accept: application/jsonany (default)application/json200 JSON via Jackson
Accept: application/xmlonly JSON converter on classpathnone406 HttpMediaTypeNotAcceptableException
Accept: text/html, */*;q=0.1anyapplication/json (via */*)200 JSON
no Acceptanyfirst producible type200 JSON
Accept: application/jsonproduces = "application/pdf"none406
Content-Type: text/plain on @RequestBody Book—no reader415 HttpMediaTypeNotSupportedException
Content-Type: application/json;charset=UTF-8—Jacksonbody parsed
Content negotiation by produces + custom converter ordering
@GetMapping(value = "/{id}", produces = { "application/json", "application/xml" }) Book get(@PathVariable long id) { ... } // XML needs jackson-dataformat-xml on the classpath // Adding a converter without losing the defaults: @Configuration class WebConfig implements WebMvcConfigurer { @Override public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { converters.add(new CsvHttpMessageConverter()); // extend = keep Boot's defaults } // configureMessageConverters(...) would REPLACE the defaults -- Jackson disappears, everything returns 406/415 }
Jackson in Spring Boot

Boot builds one shared ObjectMapper bean through Jackson2ObjectMapperBuilder and registers well-known modules found on the classpath (Java time, JDK 8 types, parameter names). It deliberately changes three Jackson defaults. Knowing them explains most "why did my JSON change?" surprises.

SettingJackson defaultBoot defaultProperty
Dates as numberstimestamps (1735689600000)ISO-8601 strings (2025-01-01T00:00:00Z)spring.jackson.serialization.write-dates-as-timestamps=false
Unknown JSON fields on readfailignorespring.jackson.deserialization.fail-on-unknown-properties
Default view inclusiononoff (DEFAULT_VIEW_INCLUSION disabled)spring.jackson.mapper.default-view-inclusion
Null inclusionalwaysalways (unchanged)spring.jackson.default-property-inclusion=non_null
Naming strategycamelCasecamelCase (unchanged)spring.jackson.property-naming-strategy=SNAKE_CASE
application.yml — the most useful Jackson knobs
spring: jackson: default-property-inclusion: non_null # omit null fields from responses property-naming-strategy: SNAKE_CASE # firstName -> first_name deserialization: fail-on-unknown-properties: true # be strict with clients fail-on-null-for-primitives: true serialization: write-dates-as-timestamps: false time-zone: UTC
Fine-grained control: annotations and a customizer
record BookView( long id, String title, @JsonProperty("isbn_13") String isbn, // rename on the wire @JsonInclude(JsonInclude.Include.NON_NULL) String subtitle, @JsonFormat(shape = JsonFormat.Shape.STRING) Long snowflakeId, // 64-bit ids as strings: JS loses precision above 2^53 @JsonFormat(pattern = "yyyy-MM-dd") LocalDate published, @JsonIgnore String internalNotes) { } @Bean Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder .featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .serializationInclusion(JsonInclude.Include.NON_ABSENT) .modulesToInstall(new SimpleModule().addSerializer(Money.class, new MoneySerializer())); }
⚠ Production gotchas & how to diagnose them:
  • Defining your own ObjectMapper bean with new ObjectMapper() replaces Boot's configured one (@ConditionalOnMissingBean). Dates suddenly serialise as arrays or fail with InvalidDefinitionException: Java 8 date/time type java.time.Instant not supported by default. Customise through Jackson2ObjectMapperBuilderCustomizer or properties instead.
  • Lazy Hibernate proxies in responses: returning an entity serialises its proxy and fails with InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor, or triggers LazyInitializationException or an N+1 query storm. Return DTOs.
  • Bidirectional entity graphs (Order to Item to Order) cause StackOverflowError / JsonMappingException: Infinite recursion — another reason not to expose entities.
  • Long ids above 2^53 are corrupted by JavaScript clients (9007199254740993 becomes 9007199254740992). Serialise snowflake/64-bit ids as strings.
  • 406 after adding a converter: configureMessageConverters replaces the default list. Use extendMessageConverters unless replacing everything on purpose.
  • Mixed BigDecimal formatting: new BigDecimal("1.10") serialises as 1.10 but 1.1 if it went through double. Keep money as BigDecimal end to end and fix the scale in the domain.
07
Chapter Seven · Cross-cutting

Filters vs Interceptors

Both let you run code around a request, but they live in different layers. A servlet Filter belongs to the container: it sees every request (including static files and non-MVC servlets), runs before DispatcherServlet, and can replace the request/response with wrappers. A HandlerInterceptor belongs to Spring MVC: it only runs for requests that matched a handler, and it knows which HandlerMethod will run.

Where each hook sits — the request travels inward, the response travels outward
Servlet container: Filter chain (Filter, OncePerRequestFilter) Spring Security: FilterChainProxy (is itself just a Filter) DispatcherServlet + HandlerMapping HandlerInterceptor: preHandle / postHandle / afterCompletion Controller method @ControllerAdvice @ExceptionHandler lives here (inside DispatcherServlet) Exceptions from Filters never reach @ControllerAdvice: they bubble to the container error page (/error)
Servlet FilterHandlerInterceptor
Defined byJakarta Servlet specSpring MVC
Seesevery request the container routes to itonly requests mapped to a handler
Knows the target methodnoyes — handler is a HandlerMethod (annotations, bean type)
Can wrap request / responseyes (HttpServletRequestWrapper)no
Can short-circuitdon't call chain.doFilterreturn false from preHandle
Access to exceptionsonly by try/catch around doFilterafterCompletion(ex) receives the handler's exception
Exception handling by @ControllerAdvicenopreHandle exceptions yes (thrown inside doDispatch)
Spring bean injectionyes when registered as a bean / FilterRegistrationBeanyes (@Bean or @Component)
Typical usesecurity, CORS, request logging, correlation id, compression, rate limiting at the edgeper-handler auth checks, audit logging with handler info, locale / tenant resolution
A production filter: correlation id + MDC + timing
@Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) // run early, before Spring Security if you want ids in its logs class CorrelationIdFilter extends OncePerRequestFilter { // once per request even across async dispatch / forwards private static final Logger log = LoggerFactory.getLogger(CorrelationIdFilter.class); @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String id = Optional.ofNullable(req.getHeader("X-Correlation-Id")).orElseGet(() -> UUID.randomUUID().toString()); MDC.put("correlationId", id); // every log line on this thread now carries the id res.setHeader("X-Correlation-Id", id); long start = System.nanoTime(); try { chain.doFilter(req, res); // everything inward happens here } finally { log.info("{} {} -> {} in {} ms", req.getMethod(), req.getRequestURI(), res.getStatus(), (System.nanoTime() - start) / 1_000_000); MDC.clear(); // threads are pooled: never leak MDC to the next request } } }
A HandlerInterceptor that uses handler metadata
@Component class AuditInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { if (handler instanceof HandlerMethod hm && hm.hasMethodAnnotation(Audited.class)) { req.setAttribute("audit.start", System.nanoTime()); } return true; // false would stop the chain: write the response yourself } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { if (req.getAttribute("audit.start") instanceof Long start) { audit.record(req.getRequestURI(), res.getStatus(), ex, System.nanoTime() - start); } } } @Configuration class WebConfig implements WebMvcConfigurer { private final AuditInterceptor audit; WebConfig(AuditInterceptor audit) { this.audit = audit; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(audit) .addPathPatterns("/api/**") .excludePathPatterns("/api/health") .order(10); // lower runs first on the way in, last on the way out } }
⚠ Production gotchas & how to diagnose them:
  • Reading the request body in a filter consumes the stream; the controller then fails with HttpMessageNotReadableException: Required request body is missing. Wrap with ContentCachingRequestWrapper and read the cached bytes after chain.doFilter, or use CommonsRequestLoggingFilter.
  • Filter bean registered twice: a @Component Filter is auto-registered for /*. Adding it also to a FilterRegistrationBean or to the Security chain runs it twice. Extend OncePerRequestFilter to make double execution harmless, or disable the auto-registration with a FilterRegistrationBean whose setEnabled(false).
  • Exceptions thrown in a filter bypass @ControllerAdvice and show up as Tomcat's /error page or an empty 500. Catch them in the filter and write the response, or delegate to a HandlerExceptionResolver bean.
  • preHandle returning false without writing a response produces a blank 200. Always set a status or sendError before returning false.
  • MDC leaks: forgetting MDC.clear() in finally leaves the previous request's id on a reused Tomcat thread — logs for unrelated requests share an id. Also remember MDC does not follow @Async or reactive hops automatically.
  • Ordering confusion: @Order on a filter orders it among filters only; interceptor order() is separate. Spring Security's filter default order is -100; run before it with a lower number.
08
Chapter Eight · Scale & Evolution

Pagination, HATEOAS & Versioning

Returning every row of a table is the classic API outage. Pagination protects the database, the JVM heap and the client. Spring Data integrates with MVC through PageableHandlerMethodArgumentResolver, which turns ?page=0&size=20&sort=name,desc into a Pageable parameter — no parsing code on your side.

Pageable in a controller
@GetMapping("/books") Page<BookSummary> list(@PageableDefault(size = 20, sort = "title") Pageable pageable, @RequestParam(required = false) String author) { return repo.findByAuthor(author, pageable).map(BookSummary::from); // map() keeps paging metadata } // application.yml // spring.data.web.pageable.default-page-size: 20 (default 20) // spring.data.web.pageable.max-page-size: 100 (default 2000 -- lower it!) // spring.data.web.pageable.one-indexed-parameters: false
StrategyHow it worksCostStable under writes?Use when
Offset (Page)LIMIT n OFFSET k + a count(*) queryDB scans and discards k rows; deep pages are slowno — rows shift between pagesback-office tables, small data, "page 5 of 20" UI
Slicefetch n+1 rows to know if there is a next page; no countcheaper than Pagenoinfinite scroll, huge tables
Keyset / cursorWHERE (created_at, id) < (:ts, :id) ORDER BY ... LIMIT nindex seek, constant per pageyesfeeds, exports, large data. Spring Data 3.1+ has a scroll API (Window, ScrollPosition)
Page JSON and the serialisation warning
{ "content": [ ... ], "pageable": { "pageNumber": 0, "pageSize": 20, "sort": {...}, ... }, "totalElements": 134, "totalPages": 7, "last": false, "first": true, "numberOfElements": 20, ... } WARN Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure! For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))

Since Spring Data 3.3 the warning above appears when a Page is serialised directly, because the internal PageImpl shape is not a public contract. Return your own DTO (record PageResponse<T>(List<T> items, int page, int size, long total)) or enable @EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO) to get a stable content + page object.

⚠ Production gotchas & how to diagnose them:
  • Unbounded size: ?size=1000000 is accepted up to max-page-size (default 2000). Cap it low; otherwise one call can load a table into memory.
  • Sorting by a non-indexed or user-supplied property allows ORDER BY on any column — slow queries and information leak. Whitelist sortable properties; an unknown property gives PropertyReferenceException: No property xyz found for type Book (a 500 unless handled).
  • Unstable sort + offset paging: without a unique tie-breaker (sort=createdAt,id) rows can appear on two pages or none. Always end the sort with the primary key.
  • Count query on every page: select count(*) on a big filtered table can cost more than the page itself. Use Slice or cache the total.
Hypermedia (HATEOAS) — what it is and when it pays

HATEOAS means responses carry links describing what the client can do next, so clients follow links instead of hard-coding URLs. Spring HATEOAS (spring-boot-starter-hateoas) provides EntityModel, CollectionModel, PagedModel and linkTo(methodOn(...)) to build links from controller methods, rendered as HAL (application/hal+json). Most public APIs ship plain JSON with an explicit next cursor or Link header instead; adopt full HATEOAS only when you really need clients decoupled from URL structure.

Spring HATEOAS: links built from the controller itself
@GetMapping("/{id}") EntityModel<Order> get(@PathVariable long id) { Order o = service.get(id); return EntityModel.of(o, linkTo(methodOn(OrderController.class).get(id)).withSelfRel(), linkTo(methodOn(OrderController.class).cancel(id)).withRel("cancel"), // omit when state forbids it linkTo(methodOn(OrderController.class).list(Pageable.unpaged())).withRel("orders")); } // => { "id": 42, "status": "NEW", "_links": { "self": {"href": ".../orders/42"}, "cancel": {...}, "orders": {...} } }
API versioning
StyleExampleProsCons
URI path/api/v2/ordersvisible, cacheable, trivial to route and test with curlURL changes; whole-API bumps encourage big-bang versions
HeaderX-Api-Version: 2 (use headers = ...)clean URLsinvisible in browser/logs unless captured; caches must Vary
Media typeAccept: application/vnd.acme.v2+json (produces)true content negotiation, per-resource versionsharder for clients and tooling
Query param?version=2easypollutes cache keys; easy to forget
Version by path with separate controllers (works on any Spring 6 / Boot 3)
@RestController @RequestMapping("/api/v1/orders") class OrderControllerV1 { @GetMapping("/{id}") OrderV1 get(@PathVariable long id) { return mapper.toV1(service.get(id)); } } @RestController @RequestMapping("/api/v2/orders") class OrderControllerV2 { @GetMapping("/{id}") OrderV2 get(@PathVariable long id) { return mapper.toV2(service.get(id)); } // adds "currency", renames "total" } // One service, two thin controllers + two DTO families. Never version your domain model.

Spring Framework 7 adds built-in API versioning (a version attribute on mapping annotations plus a configurable version resolver); on Framework 6 / Boot 3 use one of the styles above. Whichever you choose: prefer additive, backward-compatible changes (new optional fields) so a new version is rare, and publish a deprecation policy (Deprecation / Sunset headers).

09
Chapter Nine · Build It Yourself

A 150-Line Mini Spring MVC

The fastest way to prove you understand the request path is to build it. The program below is plain Java 17 — no Spring, no servlet container — yet it implements the same moving parts: a startup scan that builds a mapping table (HandlerMapping), argument resolvers for path / query / body, a message converter in both directions, an interceptor chain with the real preHandle / postHandle / afterCompletion ordering, and a dispatch method shaped like doDispatch. Compile it with javac MiniMvc.java and run it.

MiniMvc pieceReal Spring counterpart
register(bean) scanning annotations into Handler recordsRequestMappingHandlerMapping.afterPropertiesSet() building RequestMappingInfo to HandlerMethod
regex from /books/{id}PathPattern / PathPatternParser
loop over Parameters with @PathVar/@Param/@BodyHandlerMethodArgumentResolverComposite
convert(String, Class)ConversionService / TypeConverter
toJson / fromJsonMappingJackson2HttpMessageConverter
dispatch(Request)DispatcherServlet.doDispatch
Interceptor list and the ran counterHandlerExecutionChain.applyPreHandle / triggerAfterCompletion with interceptorIndex
catch (Exception e) to 500processHandlerException to HandlerExceptionResolver chain
MiniMvc.java — ~150 lines, compile and run it
import java.lang.annotation.*; import java.lang.reflect.*; import java.util.*; import java.util.regex.*; public class MiniMvc { @Retention(RetentionPolicy.RUNTIME) @interface Get { String value(); } @Retention(RetentionPolicy.RUNTIME) @interface Post { String value(); } @Retention(RetentionPolicy.RUNTIME) @interface PathVar { String value(); } @Retention(RetentionPolicy.RUNTIME) @interface Param { String value(); } @Retention(RetentionPolicy.RUNTIME) @interface Body { } record Request(String method, String path, Map<String, String> query, String body) { } record Response(int status, String body) { } record Handler(Object bean, Method method, String verb, Pattern regex, List<String> vars) { } interface Interceptor { boolean preHandle(Request r); void postHandle(Request r, Response resp); void afterCompletion(Request r, Exception ex); } static final List<Handler> mappings = new ArrayList<>(); static final List<Interceptor> interceptors = new ArrayList<>(); static final List<String> trace = new ArrayList<>(); // ---- HandlerMapping: scan beans once at startup (RequestMappingHandlerMapping does this) static void register(Object bean) { for (Method m : bean.getClass().getDeclaredMethods()) { String verb = null, tpl = null; if (m.isAnnotationPresent(Get.class)) { verb = "GET"; tpl = m.getAnnotation(Get.class).value(); } if (m.isAnnotationPresent(Post.class)) { verb = "POST"; tpl = m.getAnnotation(Post.class).value(); } if (verb == null) continue; List<String> vars = new ArrayList<>(); Matcher vm = Pattern.compile("\\{(\\w+)\\}").matcher(tpl); while (vm.find()) vars.add(vm.group(1)); String rx = tpl.replaceAll("\\{\\w+\\}", "([^/]+)"); mappings.add(new Handler(bean, m, verb, Pattern.compile(rx), vars)); trace.add("mapped " + verb + " " + tpl + " -> " + m.getName()); } } // ---- HttpMessageConverter (write side): records -> JSON static String toJson(Object o) { if (o == null) return "null"; if (o instanceof Number || o instanceof Boolean) return o.toString(); if (o instanceof String s) return "\"" + s + "\""; if (o instanceof List<?> l) { StringJoiner j = new StringJoiner(",", "[", "]"); for (Object e : l) j.add(toJson(e)); return j.toString(); } if (o.getClass().isRecord()) { StringJoiner j = new StringJoiner(",", "{", "}"); for (RecordComponent rc : o.getClass().getRecordComponents()) { try { j.add("\"" + rc.getName() + "\":" + toJson(rc.getAccessor().invoke(o))); } catch (Exception e) { throw new RuntimeException(e); } } return j.toString(); } return "\"" + o + "\""; } // ---- HttpMessageConverter (read side): tiny JSON object -> record of String/int fields static Object fromJson(String json, Class<?> type) throws Exception { Map<String, String> kv = new HashMap<>(); Matcher m = Pattern.compile("\"(\\w+)\"\\s*:\\s*(\"[^\"]*\"|[-\\d.]+)").matcher(json); while (m.find()) kv.put(m.group(1), m.group(2).replace("\"", "")); RecordComponent[] rcs = type.getRecordComponents(); Object[] args = new Object[rcs.length]; Class<?>[] types = new Class<?>[rcs.length]; for (int i = 0; i < rcs.length; i++) { types[i] = rcs[i].getType(); String v = kv.get(rcs[i].getName()); args[i] = convert(v, types[i]); } return type.getDeclaredConstructor(types).newInstance(args); } // ---- Converter used by @PathVariable / @RequestParam (ConversionService in Spring) static Object convert(String v, Class<?> t) { if (v == null) return null; if (t == int.class || t == Integer.class) return Integer.parseInt(v); if (t == long.class || t == Long.class) return Long.parseLong(v); return v; } // ---- DispatcherServlet.doDispatch static Response dispatch(Request req) { trace.add("--> " + req.method() + " " + req.path()); Handler h = null; Matcher match = null; for (Handler c : mappings) { Matcher mm = c.regex().matcher(req.path()); if (c.verb().equals(req.method()) && mm.matches()) { h = c; match = mm; break; } } if (h == null) return new Response(404, "{\"error\":\"no handler\"}"); int ran = 0; Exception failure = null; Response resp = null; try { for (Interceptor i : interceptors) { ran++; if (!i.preHandle(req)) { return new Response(403, "{\"error\":\"blocked\"}"); } } // HandlerAdapter: resolve each parameter via argument resolvers Parameter[] ps = h.method().getParameters(); Object[] args = new Object[ps.length]; for (int i = 0; i < ps.length; i++) { Parameter p = ps[i]; if (p.isAnnotationPresent(PathVar.class)) { int idx = h.vars().indexOf(p.getAnnotation(PathVar.class).value()); args[i] = convert(match.group(idx + 1), p.getType()); trace.add(" resolver PathVariable -> " + args[i]); } else if (p.isAnnotationPresent(Param.class)) { args[i] = convert(req.query().get(p.getAnnotation(Param.class).value()), p.getType()); trace.add(" resolver RequestParam -> " + args[i]); } else if (p.isAnnotationPresent(Body.class)) { args[i] = fromJson(req.body(), p.getType()); trace.add(" resolver RequestBody -> " + args[i]); } } Object ret = h.method().invoke(h.bean(), args); resp = new Response(h.verb().equals("POST") ? 201 : 200, toJson(ret)); // return value handler for (int i = ran - 1; i >= 0; i--) interceptors.get(i).postHandle(req, resp); } catch (Exception e) { failure = e; resp = new Response(500, "{\"error\":\"" + e.getClass().getSimpleName() + "\"}"); } for (int i = ran - 1; i >= 0; i--) interceptors.get(i).afterCompletion(req, failure); return resp; } // ---- application code record Book(int id, String title, int pages) { } record NewBook(String title, int pages) { } static class BookController { @Get("/books/{id}") Book one(@PathVar("id") int id) { return new Book(id, "Spring in Action", 520); } @Get("/books") List<Book> list(@Param("minPages") int min) { return List.of(new Book(1, "Effective Java", 412), new Book(2, "Spring in Action", 520)).stream().filter(b -> b.pages() >= min).toList(); } @Post("/books") Book create(@Body NewBook b) { return new Book(99, b.title(), b.pages()); } } public static void main(String[] a) { register(new BookController()); interceptors.add(new Interceptor() { public boolean preHandle(Request r) { trace.add(" A.preHandle"); return true; } public void postHandle(Request r, Response x) { trace.add(" A.postHandle"); } public void afterCompletion(Request r, Exception e) { trace.add(" A.afterCompletion ex=" + e); } }); interceptors.add(new Interceptor() { public boolean preHandle(Request r) { trace.add(" B.preHandle"); return true; } public void postHandle(Request r, Response x) { trace.add(" B.postHandle"); } public void afterCompletion(Request r, Exception e) { trace.add(" B.afterCompletion ex=" + e); } }); Response r1 = dispatch(new Request("GET", "/books/7", Map.of(), null)); Response r2 = dispatch(new Request("GET", "/books", Map.of("minPages", "500"), null)); Response r3 = dispatch(new Request("POST", "/books", Map.of(), "{\"title\":\"Clean Code\",\"pages\":464}")); Response r4 = dispatch(new Request("GET", "/nope", Map.of(), null)); trace.forEach(System.out::println); for (Response r : List.of(r1, r2, r3, r4)) System.out.println(r.status() + " " + r.body()); } }
Real output of java MiniMvc
mapped GET /books -> list mapped POST /books -> create mapped GET /books/{id} -> one --> GET /books/7 A.preHandle B.preHandle resolver PathVariable -> 7 B.postHandle A.postHandle B.afterCompletion ex=null A.afterCompletion ex=null --> GET /books A.preHandle B.preHandle resolver RequestParam -> 500 B.postHandle A.postHandle B.afterCompletion ex=null A.afterCompletion ex=null --> POST /books A.preHandle B.preHandle resolver RequestBody -> NewBook[title=Clean Code, pages=464] B.postHandle A.postHandle B.afterCompletion ex=null A.afterCompletion ex=null --> GET /nope 200 {"id":7,"title":"Spring in Action","pages":520} 200 [{"id":2,"title":"Spring in Action","pages":520}] 201 {"id":99,"title":"Clean Code","pages":464} 404 {"error":"no handler"}

Read the trace carefully — it demonstrates three behaviours that surprise people in the real framework:

  • Mapping happens once. The mapped GET ... lines print at startup; per-request work is only a lookup. Notice /books was registered before /books/{id} — iteration order of reflection is not a contract, which is why real Spring ranks matches by specificity.
  • Interceptors nest like parentheses. A.pre, B.pre, handler, B.post, A.post, B.after, A.after. postHandle and afterCompletion run in reverse order.
  • Argument resolution is per-parameter. The resolver ... lines show each parameter coming from a different source; the controller never touched a request object.
Try these experiments
  • Make BookController.one throw an exception: observe postHandle disappears but both afterCompletion lines still print with ex != null — exactly how real interceptors behave.
  • Change interceptor B to return false from preHandle: A.afterCompletion still runs, B does not, and the controller is never invoked (ran counts how many interceptors passed).
  • POST a body without pages: the int component receives null and the reflective constructor call fails — the same primitive trap described in Chapter 4.
10
Chapter Ten · Interview Angles

Questions You Will Be Asked

  • Walk me through what happens from the socket to your controller method.
    Tomcat parses the request and runs the filter chain (security, encoding, your filters). DispatcherServlet.doDispatch asks HandlerMapping for a HandlerExecutionChain, picks a HandlerAdapter, runs interceptor preHandle, resolves each argument with HandlerMethodArgumentResolvers, invokes the method, passes the return value to a HandlerMethodReturnValueHandler that uses an HttpMessageConverter, then runs postHandle/afterCompletion. Exceptions go to the HandlerExceptionResolver chain.
  • @Controller vs @RestController?
    @RestController = @Controller + @ResponseBody: return values are written to the body through message converters instead of being resolved as view names.
  • @RequestParam vs @PathVariable vs @RequestBody?
    Query/form value vs URI-template segment vs entire request body. The first two use the ConversionService; the body uses an HttpMessageConverter chosen by Content-Type.
  • What does 415 vs 406 mean?
    415: server cannot read the request Content-Type (no converter). 406: server cannot produce anything matching the request Accept.
  • Filter vs interceptor — when would you pick each?
    Filter for container-level, handler-agnostic concerns and when you must wrap request/response (security, CORS, correlation id, body logging). Interceptor when you need the matched HandlerMethod or per-handler behaviour. Exceptions in filters are not seen by @ControllerAdvice.
  • Why return DTOs instead of entities?
    Decouples wire format from the schema, avoids lazy-proxy serialisation errors and infinite recursion, prevents leaking fields, and stops mass assignment on input.
  • How do you return 201 with the new resource URI?
    ResponseEntity.created(location).body(dto) where location is built from the current request (ServletUriComponentsBuilder); the Location header points to the new resource.
  • PUT vs PATCH vs POST — idempotency?
    PUT and DELETE are idempotent; POST is not (protect with an idempotency key); PATCH depends on the patch format. Idempotency is what makes client retries safe.
  • Why might /books/ 404 after upgrading to Boot 3?
    Spring Framework 6 turned trailing-slash matching off by default; map /books only, or configure PathMatchConfigurer / redirect at the proxy.
  • Offset vs keyset pagination?
    Offset scans and skips rows (cost grows with page number, unstable under writes). Keyset seeks by the last seen sort key — constant cost and stable, but no random page jump.
  • Why do my controller exceptions return 500 and not 400?
    No handler maps the exception to a status. Spring maps its own web exceptions (type mismatch, unreadable body) to 4xx through DefaultHandlerExceptionResolver; your own exceptions need @ResponseStatus or an @ExceptionHandler.
  • Follow-up trap: can you change headers in postHandle of a @ResponseBody method?
    Not reliably — the converter has already written the body and the response is typically committed. Use ResponseBodyAdvice.
11
Chapter Eleven · Summary

Cheat Sheet & Next Steps

NeedUseRemember
Route by verb + path@GetMapping etc.literal beats variable; no trailing slash match in Spring 6
URL segment / query / header@PathVariable / @RequestParam / @RequestHeaderneeds -parameters for implicit names
JSON body@RequestBody + record DTO + @Validboxed types so absent != 0
Custom status / headersResponseEntity201 + Location, 204, ETag / 304
JSON shapeJackson properties and annotationsBoot: dates as ISO, unknown properties ignored
Per-handler hookHandlerInterceptorpost/after run in reverse; skipped on early false
Container-level hookOncePerRequestFilteroutside @ControllerAdvice; clear MDC
Large listsPageable / keysetcap size, stable sort, DTO page
Evolve an APIadditive changes first, then URI or media-type versionsone service, many thin controllers
∑ REST & MVC — what to remember
  • One front controller. DispatcherServlet.doDispatch is the entire request path: mapping, adapter, interceptors, argument resolvers, controller, return handler, exception resolvers.
  • Strategies, not magic. Every surprise (404, 405, 406, 415, 400 vs 500) maps to one named strategy; the exception class tells you which.
  • Wire contract is yours. DTO records, explicit status codes, Location headers, a pagination cap and a versioning policy.
  • Filters outside, interceptors inside. Choose by whether you need the handler or the raw request.
  • Next: errors and validation — turning every one of those exceptions into a consistent response.
←

Previous: Boot Auto-Configuration

  • how the servlet and MVC beans get registered
→

Next: Validation & Error Handling

  • Bean Validation, @ControllerAdvice, ProblemDetail
Related pages