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.
From Servlet Plumbing to a Method Call
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.
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.
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.
| Concern | Raw servlet | Spring MVC strategy | Interface / class |
|---|---|---|---|
| Routing | web.xml / manual path switch | annotation scan at startup | HandlerMapping / RequestMappingHandlerMapping |
| Invoking your code | you are the servlet | adapter calls any method signature | HandlerAdapter / RequestMappingHandlerAdapter |
| Reading inputs | getParameter, manual parsing | resolver per parameter kind | HandlerMethodArgumentResolver |
| JSON in / out | call Jackson yourself | converter chosen by Content-Type / Accept | HttpMessageConverter |
| Errors to HTTP | try/catch + sendError | resolver chain | HandlerExceptionResolver |
| Cross-cutting | servlet filters only | filters + interceptors | Filter, HandlerInterceptor |
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.
DispatcherServlet bean on / (via DispatcherServletRegistrationBean)doDispatch — single front controller, single place to reason aboutRequestMappingHandlerMapping scans every bean annotated @Controller / @RestControllerRequestMappingInfo (verb, path pattern, consumes, produces, params) to HandlerMethod — a pure lookup table; no reflection scan per requestgetHandler(request) asks each HandlerMapping in order and returns a HandlerExecutionChainHandlerMethod plus every HandlerInterceptor whose path patterns matchRequestMappingHandlerAdapter invokes the method through ServletInvocableHandlerMethodHandlerMethodArgumentResolver that supportsParameter; the return value goes to the first HandlerMethodReturnValueHandler@ResponseBody / ResponseEntity the return handler picks an HttpMessageConverterAccept header with what converters can write for the return type — empty intersection = 406 Not AcceptableprocessHandlerExceptionExceptionHandlerExceptionResolver (your @ExceptionHandler / advice), ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver- Put a breakpoint in
DispatcherServlet.doDispatchand step overgetHandler/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
mappingsendpoint (/actuator/mappings) — the fastest way to answer "why 404?".
- 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 byparams,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@RestControllerlives outside the component-scan base package of the@SpringBootApplicationclass. - 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
postHandleruns. Use aResponseBodyAdviceor a filter for that.
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 @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.
| Condition | Attribute | Matches on | Failure status |
|---|---|---|---|
| Path | value / path | URL path pattern (PathPattern): /orders/{id}, /files/**, /v{n:[0-9]+}/x | 404 |
| Method | method or @GetMapping etc. | HTTP verb | 405 HttpRequestMethodNotSupportedException (with Allow header) |
| Consumes | consumes | request Content-Type | 415 HttpMediaTypeNotSupportedException |
| Produces | produces | request Accept | 406 HttpMediaTypeNotAcceptableException |
| Params | params = "debug" | query parameter present / equals / absent | 400 UnsatisfiedServletRequestParameterException |
| Headers | headers = "X-Api-Version=2" | request header present / equals | 404 (treated as no match) |
| Pattern | Request path | Match? | Captured |
|---|---|---|---|
/orders/{id} | /orders/42 | yes | id = 42 |
/orders/{id} | /orders/42/items | no | — (one segment only) |
/orders/{id:[0-9]+} | /orders/abc | no | — falls to the next mapping or 404 |
/files/** | /files/a/b/c.txt | yes | ** only allowed at the end with PathPattern |
/orders/* | /orders/42 | yes | single segment wildcard |
/orders | /orders/ | no | trailing slash off since Spring 6 |
/orders/me vs /orders/{id} | /orders/me | both match | literal beats variable: me method wins |
- Missing
@RequestMappingprefix 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 useserver.servlet.context-pathonly for deployment concerns. - Wrong stereotype:
@Controllerwithout@ResponseBodymakes Spring treat the returnedStringas a view name. Symptom:TemplateInputException: Error resolving template [books]or an empty page with a 404 for/error. Use@RestControllerfor 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. @PathVariablewithout a name relies on the-parameterscompiler flag (Spring Boot Maven/Gradle plugins turn it on). In a hand-rolled build you getIllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not found in class file. Fix: enable-parametersor name it:@PathVariable("id").@RequestMappingwithoutmethodon a method accepts every verb. Prefer the composed@GetMapping/@PostMappingso aDELETEcannot hit a read handler.
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.
| Annotation | Source of the value | Resolver | Converted by | Failure exception |
|---|---|---|---|---|
@PathVariable | URI template segment | PathVariableMethodArgumentResolver | ConversionService | MethodArgumentTypeMismatchException |
@RequestParam | query string or form field | RequestParamMethodArgumentResolver | ConversionService | MissingServletRequestParameterException / type mismatch |
@RequestHeader | HTTP header | RequestHeaderMethodArgumentResolver | ConversionService | MissingRequestHeaderException |
@CookieValue | cookie | ServletCookieValueMethodArgumentResolver | ConversionService | MissingRequestCookieException |
@RequestBody | raw body | RequestResponseBodyMethodProcessor | HttpMessageConverter (Jackson) | HttpMessageNotReadableException |
@ModelAttribute / none (non-simple type) | query + form fields bound to an object | ServletModelAttributeMethodProcessor | DataBinder | BindException / MethodArgumentNotValidException |
HttpServletRequest, Principal, Locale | the request itself | ServletRequestMethodArgumentResolver | — | — |
| Step | What happens | Class |
|---|---|---|
| 1 | Resolver supports @RequestBody; reads Content-Type of the request | RequestResponseBodyMethodProcessor |
| 2 | Finds the first converter whose canRead(targetClass, contentType) is true | MappingJackson2HttpMessageConverter |
| 3 | No converter matches (e.g. text/plain body for a record) | HttpMediaTypeNotSupportedException to 415 |
| 4 | Jackson parses and builds the object (records via canonical constructor) | ObjectMapper.readValue |
| 5 | Syntax error / wrong type / empty body | HttpMessageNotReadableException to 400 |
| 6 | If the parameter has @Valid / @Validated, run the validator | DataBinder.validate to MethodArgumentNotValidException to 400 |
| 7 | Value injected into your method | — |
- Type mismatch in path or query:
GET /orders/abcthrowsMethodArgumentTypeMismatchException: 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 characterorRequired request body is missing(empty body withrequired=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 onspring.jackson.deserialization.fail-on-null-for-primitives=true. - Unknown JSON properties are ignored by default in Boot (Boot sets
FAIL_ON_UNKNOWN_PROPERTIESto 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
@RequestBodyenables mass assignment: a client sends"role":"ADMIN"or"id":1and overwrites fields you never meant to expose. Always bind to a purpose-built DTO and map explicitly. List<String>query params:?tag=a,bis split by the conversion service but?tag=a&tag=bis the canonical form; a comma inside a value ("Doe, John") is then ambiguous.
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.
| Situation | Status | Body | Notes |
|---|---|---|---|
| Read succeeded | 200 OK | resource | idempotent, cacheable |
| Created a resource | 201 Created | resource | add Location header pointing at the new URI |
| Accepted for async processing | 202 Accepted | job id / status URL | client polls the status resource |
| Success, nothing to return | 204 No Content | none | typical for DELETE and PUT that does not echo |
| Conditional GET unchanged | 304 Not Modified | none | driven by ETag / If-None-Match |
| Malformed syntax / bad types | 400 Bad Request | error body | unreadable JSON, type mismatch, failed validation |
| Unauthenticated | 401 Unauthorized | error body | actually means "unauthenticated"; send WWW-Authenticate |
| Authenticated but forbidden | 403 Forbidden | error body | permission denied; do not use 401 |
| No such resource | 404 Not Found | error body | also used to hide existence from unauthorised callers |
| Wrong verb for resource | 405 Method Not Allowed | error body | automatic; includes Allow |
| State conflict / duplicate | 409 Conflict | error body | unique key violation, optimistic-lock failure |
| Precondition failed | 412 / 428 | error body | If-Match mismatch / required but missing |
| Semantically invalid | 422 Unprocessable Content | error body | optional alternative to 400 for business-rule failures |
| Throttled | 429 Too Many Requests | error body | add Retry-After |
| Server bug | 500 Internal Server Error | generic error body | never include stack trace |
| Dependency down | 502 / 503 / 504 | error body | gateway/overload; clients may retry |
| Verb | Semantics | Idempotent | Typical body | Pitfall |
|---|---|---|---|---|
POST /orders | create (server picks id) or trigger an action | no | new resource | client retry duplicates; protect with an Idempotency-Key header |
PUT /orders/42 | replace the whole resource | yes | full representation | omitted fields are reset to null — surprising for partial clients |
PATCH /orders/42 | partial update | depends on the patch | JSON Merge Patch (application/merge-patch+json) or JSON Patch | Merge Patch cannot distinguish "set null" from "absent" without extra work |
DELETE /orders/42 | remove | yes | none | second call returns 404 — still idempotent in effect |
- 200 with an empty body for "not found": returning
null(orOptional.empty()mapped lazily) from a@RestControlleryields 200 and zero bytes. Clients then crash parsing JSON. Throw a domain exception or returnResponseEntity.notFound(). - Using 200 for errors (
{"success":false}) defeats retries, monitoring dashboards and every HTTP client library. Use real statuses. @ResponseStatus(CREATED)but noLocation: clients cannot navigate to the new resource without guessing. Build the URI from the request withServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")so proxies and context paths stay right.voidmethod returns 200: a@DeleteMappingreturningvoidgives 200 with no body; add@ResponseStatus(NO_CONTENT)if you promise 204.- Setting headers after the body was written (in an interceptor
postHandleor afterresponse.getOutputStream().write) silently does nothing because the response is committed. Diagnose by checkingresponse.isCommitted().
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.
| Request | Handler produces | Chosen type | Result |
|---|---|---|---|
Accept: application/json | any (default) | application/json | 200 JSON via Jackson |
Accept: application/xml | only JSON converter on classpath | none | 406 HttpMediaTypeNotAcceptableException |
Accept: text/html, */*;q=0.1 | any | application/json (via */*) | 200 JSON |
no Accept | any | first producible type | 200 JSON |
Accept: application/json | produces = "application/pdf" | none | 406 |
Content-Type: text/plain on @RequestBody Book | — | no reader | 415 HttpMediaTypeNotSupportedException |
Content-Type: application/json;charset=UTF-8 | — | Jackson | body parsed |
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.
| Setting | Jackson default | Boot default | Property |
|---|---|---|---|
| Dates as numbers | timestamps (1735689600000) | ISO-8601 strings (2025-01-01T00:00:00Z) | spring.jackson.serialization.write-dates-as-timestamps=false |
| Unknown JSON fields on read | fail | ignore | spring.jackson.deserialization.fail-on-unknown-properties |
| Default view inclusion | on | off (DEFAULT_VIEW_INCLUSION disabled) | spring.jackson.mapper.default-view-inclusion |
| Null inclusion | always | always (unchanged) | spring.jackson.default-property-inclusion=non_null |
| Naming strategy | camelCase | camelCase (unchanged) | spring.jackson.property-naming-strategy=SNAKE_CASE |
- Defining your own
ObjectMapperbean withnew ObjectMapper()replaces Boot's configured one (@ConditionalOnMissingBean). Dates suddenly serialise as arrays or fail withInvalidDefinitionException: Java 8 date/time type java.time.Instant not supported by default. Customise throughJackson2ObjectMapperBuilderCustomizeror 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 triggersLazyInitializationExceptionor an N+1 query storm. Return DTOs. - Bidirectional entity graphs (
OrdertoItemtoOrder) causeStackOverflowError/JsonMappingException: Infinite recursion— another reason not to expose entities. Longids above 2^53 are corrupted by JavaScript clients (9007199254740993becomes9007199254740992). Serialise snowflake/64-bit ids as strings.- 406 after adding a converter:
configureMessageConvertersreplaces the default list. UseextendMessageConvertersunless replacing everything on purpose. - Mixed
BigDecimalformatting:new BigDecimal("1.10")serialises as1.10but1.1if it went throughdouble. Keep money asBigDecimalend to end and fix the scale in the domain.
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.
| Servlet Filter | HandlerInterceptor | |
|---|---|---|
| Defined by | Jakarta Servlet spec | Spring MVC |
| Sees | every request the container routes to it | only requests mapped to a handler |
| Knows the target method | no | yes — handler is a HandlerMethod (annotations, bean type) |
| Can wrap request / response | yes (HttpServletRequestWrapper) | no |
| Can short-circuit | don't call chain.doFilter | return false from preHandle |
| Access to exceptions | only by try/catch around doFilter | afterCompletion(ex) receives the handler's exception |
| Exception handling by @ControllerAdvice | no | preHandle exceptions yes (thrown inside doDispatch) |
| Spring bean injection | yes when registered as a bean / FilterRegistrationBean | yes (@Bean or @Component) |
| Typical use | security, CORS, request logging, correlation id, compression, rate limiting at the edge | per-handler auth checks, audit logging with handler info, locale / tenant resolution |
- Reading the request body in a filter consumes the stream; the controller then fails with
HttpMessageNotReadableException: Required request body is missing. Wrap withContentCachingRequestWrapperand read the cached bytes afterchain.doFilter, or useCommonsRequestLoggingFilter. - Filter bean registered twice: a
@ComponentFilteris auto-registered for/*. Adding it also to aFilterRegistrationBeanor to the Security chain runs it twice. ExtendOncePerRequestFilterto make double execution harmless, or disable the auto-registration with aFilterRegistrationBeanwhosesetEnabled(false). - Exceptions thrown in a filter bypass
@ControllerAdviceand show up as Tomcat's/errorpage or an empty 500. Catch them in the filter and write the response, or delegate to aHandlerExceptionResolverbean. preHandlereturningfalsewithout writing a response produces a blank 200. Always set a status orsendErrorbefore returning false.- MDC leaks: forgetting
MDC.clear()infinallyleaves the previous request's id on a reused Tomcat thread — logs for unrelated requests share an id. Also remember MDC does not follow@Asyncor reactive hops automatically. - Ordering confusion:
@Orderon a filter orders it among filters only; interceptororder()is separate. Spring Security's filter default order is-100; run before it with a lower number.
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.
| Strategy | How it works | Cost | Stable under writes? | Use when |
|---|---|---|---|---|
Offset (Page) | LIMIT n OFFSET k + a count(*) query | DB scans and discards k rows; deep pages are slow | no — rows shift between pages | back-office tables, small data, "page 5 of 20" UI |
| Slice | fetch n+1 rows to know if there is a next page; no count | cheaper than Page | no | infinite scroll, huge tables |
| Keyset / cursor | WHERE (created_at, id) < (:ts, :id) ORDER BY ... LIMIT n | index seek, constant per page | yes | feeds, exports, large data. Spring Data 3.1+ has a scroll API (Window, ScrollPosition) |
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.
- Unbounded
size:?size=1000000is accepted up tomax-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 BYon any column — slow queries and information leak. Whitelist sortable properties; an unknown property givesPropertyReferenceException: 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. UseSliceor cache the total.
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.
| Style | Example | Pros | Cons |
|---|---|---|---|
| URI path | /api/v2/orders | visible, cacheable, trivial to route and test with curl | URL changes; whole-API bumps encourage big-bang versions |
| Header | X-Api-Version: 2 (use headers = ...) | clean URLs | invisible in browser/logs unless captured; caches must Vary |
| Media type | Accept: application/vnd.acme.v2+json (produces) | true content negotiation, per-resource versions | harder for clients and tooling |
| Query param | ?version=2 | easy | pollutes cache keys; easy to forget |
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).
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 piece | Real Spring counterpart |
|---|---|
register(bean) scanning annotations into Handler records | RequestMappingHandlerMapping.afterPropertiesSet() building RequestMappingInfo to HandlerMethod |
regex from /books/{id} | PathPattern / PathPatternParser |
loop over Parameters with @PathVar/@Param/@Body | HandlerMethodArgumentResolverComposite |
convert(String, Class) | ConversionService / TypeConverter |
toJson / fromJson | MappingJackson2HttpMessageConverter |
dispatch(Request) | DispatcherServlet.doDispatch |
Interceptor list and the ran counter | HandlerExecutionChain.applyPreHandle / triggerAfterCompletion with interceptorIndex |
catch (Exception e) to 500 | processHandlerException to HandlerExceptionResolver chain |
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/bookswas 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.postHandleandafterCompletionrun 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.
- Make
BookController.onethrow an exception: observepostHandledisappears but bothafterCompletionlines still print withex != null— exactly how real interceptors behave. - Change interceptor B to return
falsefrompreHandle: A.afterCompletion still runs, B does not, and the controller is never invoked (rancounts how many interceptors passed). - POST a body without
pages: theintcomponent receivesnulland the reflective constructor call fails — the same primitive trap described in Chapter 4.
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.doDispatchasksHandlerMappingfor aHandlerExecutionChain, picks aHandlerAdapter, runs interceptorpreHandle, resolves each argument withHandlerMethodArgumentResolvers, invokes the method, passes the return value to aHandlerMethodReturnValueHandlerthat uses anHttpMessageConverter, then runspostHandle/afterCompletion. Exceptions go to theHandlerExceptionResolverchain. @Controllervs@RestController?@RestController=@Controller+@ResponseBody: return values are written to the body through message converters instead of being resolved as view names.@RequestParamvs@PathVariablevs@RequestBody?
Query/form value vs URI-template segment vs entire request body. The first two use the ConversionService; the body uses anHttpMessageConverterchosen byContent-Type.- What does 415 vs 406 mean?
415: server cannot read the requestContent-Type(no converter). 406: server cannot produce anything matching the requestAccept. - 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 matchedHandlerMethodor 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); theLocationheader 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/booksonly, or configurePathMatchConfigurer/ 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 throughDefaultHandlerExceptionResolver; your own exceptions need@ResponseStatusor an@ExceptionHandler. - Follow-up trap: can you change headers in
postHandleof a@ResponseBodymethod?
Not reliably — the converter has already written the body and the response is typically committed. UseResponseBodyAdvice.
Cheat Sheet & Next Steps
| Need | Use | Remember |
|---|---|---|
| Route by verb + path | @GetMapping etc. | literal beats variable; no trailing slash match in Spring 6 |
| URL segment / query / header | @PathVariable / @RequestParam / @RequestHeader | needs -parameters for implicit names |
| JSON body | @RequestBody + record DTO + @Valid | boxed types so absent != 0 |
| Custom status / headers | ResponseEntity | 201 + Location, 204, ETag / 304 |
| JSON shape | Jackson properties and annotations | Boot: dates as ISO, unknown properties ignored |
| Per-handler hook | HandlerInterceptor | post/after run in reverse; skipped on early false |
| Container-level hook | OncePerRequestFilter | outside @ControllerAdvice; clear MDC |
| Large lists | Pageable / keyset | cap size, stable sort, DTO page |
| Evolve an API | additive changes first, then URI or media-type versions | one service, many thin controllers |
- One front controller.
DispatcherServlet.doDispatchis 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,
Locationheaders, 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
- AOP & Proxies — how
@Transactionalcontrollers and services are wrapped. - Spring Security Architecture — the filter chain that runs before
DispatcherServlet. - Testing —
@WebMvcTestandMockMvcexercise exactly this request path. - Interview Q&A — more questions across all topics.