Lesson 1 of 5
The Request Path: DispatcherServlet in Miniature
"When you set out on your journey to Ithaka, pray that the road is long." — C. P. Cavafy, Ithaka (1911)
A browser sends some bytes. A method on your class runs with an int already parsed out of the URL and an object already deserialised from JSON. Something returns, and the browser receives JSON with a status code and a content type.
Between those two points is the DispatcherServlet, and the distance is longer than it looks: matching a path against a table of patterns, deciding between 404 and 405, resolving every parameter from a different part of the request, choosing a representation from the Accept header, and routing exceptions to a handler that may be in another class entirely.
None of it is magic and all of it is modellable. This chapter builds a dispatcher small enough to read in one sitting, and the result is that @GetMapping("/orders/{id}") stops being an incantation — along with the 404s, 405s, 406s and 415s that it produces when something is slightly off.
You POST to a URL that only has a @GetMapping. Why is that a 405 and not a 404 — and what had to happen for the dispatcher to know the difference?
What actually arrives
Before the framework, the wire. An HTTP request is text with a shape.
Click Run to compile and execute the code.
Nothing exotic: a request line, headers, a blank line, a body. What matters is that each parameter annotation names a different part of that text. @PathVariable reads the path, @RequestParam reads the query string, @RequestHeader reads the headers, @RequestBody reads the body. Once you see the parsed request as a record with five fields, argument resolution stops being framework behaviour and becomes a lookup.
The two content headers are worth separating now, because they cause different errors. Content-Type describes what the client is sending and selects a reader — no reader means 415 Unsupported Media Type. Accept describes what the client wants back and selects a writer — no writer means 406 Not Acceptable. They point in opposite directions.
Routing, and the difference between 404 and 405
The handler mapping is a table of patterns. Matching one is not quite enough, which is the answer to the opening question.
Click Run to compile and execute the code.
The two-pass match is the answer to the opening question. Match the path first and collect every mapping whose pattern fits, then filter by method. If the first pass is empty it is a 404; if the first pass found something and the second did not, it is a 405 — and the methods that did match the path are exactly what belongs in the Allow header.
A single combined lookup cannot distinguish those cases. Every mismatch would be a 404, and the response could not tell the client which methods are available. That is why the dispatcher does it in two stages.
Specificity handles the overlap. GET /orders/latest matches both /orders/latest and /orders/{id}, and the literal wins because it has more static segments. Spring's real comparator considers variable count, wildcards and pattern length as well, but the principle is the one above: the more concrete pattern beats the more general one, so you can add a special case without reordering anything.
A hotel's front desk takes every arrival and decides where it goes: the restaurant, a room, the conference suite. That is the front controller pattern, and the alternative is a separate door for every destination, each with its own receptionist who has independently decided how to check identification and what to do with lost luggage. The desk is better not because routing is hard but because everything around the routing — authentication, logging, error handling, "we are closed" — gets decided once, consistently, in a place you can inspect. Which is exactly why a DispatcherServlet exists rather than a servlet per endpoint, and why the interceptor chain is the valuable part rather than the dispatch itself.
Resolving the arguments
A controller method's parameters come from different places. The dispatcher inspects each one and asks a resolver to supply it.
Click Run to compile and execute the code.
Each resolver answers two questions: can I supply this parameter? and what is its value? The dispatcher walks the list, finds the one that supports the annotation, and calls it. That is the whole of HandlerMethodArgumentResolver.
Two behaviours are worth noticing because they shape how controllers are written. Conversion happens before your method runs, so a parameter declared int is an int or the request has already failed with a 400 — your controller never sees a half-parsed value. And a missing required parameter is also a 400, raised by the resolver, which is why you rarely null-check a @RequestParam.
The extensibility is the real payoff. Spring ships around thirty resolvers, which is why a controller method can accept a Principal, a Pageable or a Locale without any of those being part of HTTP. Registering your own resolver lets a controller take a parameter type the framework has never heard of.
Spring MVC's controllers used to be classes, not methods. Until Spring 2.5 you implemented a Controller interface with a single handleRequest(HttpServletRequest, HttpServletResponse), or extended one of a family of base classes — SimpleFormController, MultiActionController, AbstractWizardFormController — and pulled your own parameters out of the request by hand, with your own parsing and your own error handling. Spring 2.5 introduced @RequestMapping on plain methods, and the change was larger than it looks: once the framework inspects a method signature, it can supply any parameter it knows how to produce and accept any return value it knows how to render. The base-class hierarchy disappeared, because inheritance had only ever been a way to say "this class handles requests" — a thing an annotation says better. It is the same pattern as the container itself: stop requiring your classes to implement framework types, inspect them instead, and the framework can grow new capabilities without anyone changing a superclass.
Return values, and choosing a representation
What a handler returns is as interpreted as what it accepts, and the Accept header decides how.
Click Run to compile and execute the code.
One handler, one return value, five different responses — chosen by the client. That is content negotiation, and the mechanism is a list of converters asked in the order the client prefers.
Quality values are the part people misread. text/html;q=0.8, application/json prefers JSON, because an unqualified type has an implicit q=1.0. The order in the header is not the preference order; the q value is.
The last section is the trap worth memorising. A @Controller method returning String is returning a view name, so return "ok" looks for a template called ok and 404s when there is none. A @RestController — which is @Controller plus @ResponseBody — returns the string itself. Same code, different annotation, entirely different meaning.
The chain around it all
Dispatch is wrapped in layers, and knowing which layer you are in determines what you can see and change.
Click Run to compile and execute the code.
The ordering rules are the part to internalise, and all three appear in that output. preHandle runs in order; postHandle and afterCompletion run in reverse, which makes the chain a set of nested wrappers exactly like the advice chain from Part V. When an interceptor rejects, the handler never runs and only the interceptors that were actually entered get an afterCompletion — which is why audit is silent in the second run. And when the handler throws, postHandle is skipped but afterCompletion still runs, because it is the cleanup hook.
The layer table answers a question that comes up constantly. A filter is servlet-level and sits outside the dispatcher entirely — it sees every request, including ones that will 404, but it has no idea which handler was chosen. An interceptor is inside the dispatcher and knows the handler, but never sees a request that matched no mapping. @ControllerAdvice is closest to the method and sees typed exceptions and model binding. Choose by what you need to see: cross-cutting HTTP concerns go in a filter, handler-aware concerns in an interceptor, and error mapping in advice.
I built the routing block as a single lookup first — one loop over the table checking method and path together, returning the handler or null. It worked for every request I had written down, and it was wrong in a way the tests could not show: with one combined check, POST /orders/42 and GET /customers are indistinguishable, because both simply fail to match. Splitting it into two passes was four extra lines and changed what the dispatcher is able to know — the set of mappings that matched the path is exactly the Allow header, and it only exists if you compute it. I had used 405s for years without noticing they require the matcher to be structured a particular way. It is a small reminder that a status code is not a label applied at the end; it is a claim about what the server determined, and the server can only determine what its algorithm was shaped to find out.
Most Spring MVC confusion traces back to not knowing which stage failed. A 404 on a URL you are certain exists is usually a context path or a missing @RequestMapping at class level, not a wrong method path. A 415 means your Content-Type did not select a reader — commonly a client posting JSON without the header. A 406 means the reverse: nothing could satisfy Accept. A 400 with a cryptic message is argument resolution, before your code ran. A 500 from a method that clearly handles its errors is often the serialiser failing after the method returned, on a lazy Hibernate association, which is a stage most people do not know exists. The quickest way to stop guessing is to turn the web log level up — logging.level.org.springframework.web=DEBUG prints the mapping chosen, the arguments resolved and the converter used, which names the stage for you instead of leaving you to infer it from a status code.
Common Mistakes
Returning a
Stringfrom a@Controllerand expecting a body. It is a view name; use@RestControlleror@ResponseBody.Confusing
Content-TypewithAccept. The first describes the request body (415 if unsupported), the second the desired response (406).Reading the
Acceptheader in written order. Theqvalue decides, and an unqualified type isq=1.0.Expecting a 404 when the method is wrong. A matched path with an unmatched method is a 405, with an
Allowheader.Null-checking a required
@RequestParam. A missing one is already a 400 raised before your method runs.Using a filter when you need to know the handler. Filters run outside the dispatcher and cannot see which mapping matched.
Expecting
postHandleafter an exception. It is skipped; onlyafterCompletionruns.Assuming every interceptor's
afterCompletionruns. Only those whosepreHandlewas reached.Adding a mapping that silently shadows another. The more specific pattern wins, so a new
/{id}route may be unreachable behind a literal — or shadow one.Debugging status codes by guesswork.
org.springframework.webat DEBUG names the stage that failed.
Interview Corner
DispatcherServlet, which is a front controller — one servlet for the whole application rather than one per endpoint. It asks the handler mappings which handler matches the path and method, which gives it your controller method. Then a handler adapter resolves each parameter: @PathVariable from the matched path, @RequestParam from the query string, @RequestHeader from headers, @RequestBody deserialised from the body using the Content-Type. Those values are converted to the declared types first, so a missing or unparseable one is a 400 before your code runs. Your method is invoked, and what it returns is rendered — for @RestController a message converter writes the body, chosen from the Accept header. Interceptors wrap the whole thing, and if anything throws, an exception resolver turns it into a response.@ControllerAdvice?DispatcherServlet, so it sees every request including ones that will 404, and it can wrap the request and response objects themselves — right for CORS, compression, request IDs and anything that must apply even when no mapping matches. It cannot see which handler was chosen, because routing has not happened yet. An interceptor is inside the dispatcher and is handed the chosen handler, so it can make decisions based on the controller method and its annotations, and preHandle can stop the handler running; it never sees unmatched requests. preHandle runs in order and postHandle and afterCompletion in reverse, with afterCompletion running only for interceptors whose preHandle was reached, and running even when the handler threw. @ControllerAdvice is closest in: it works in terms of typed exceptions, model attributes and binding, which is where error-to-status mapping belongs.Allow header, not a 404, because the dispatcher matches path first and method second precisely so it can distinguish them. So a 404 on a URL that "exists" is usually the path not being what I think: a context path or servlet prefix, a missing class-level @RequestMapping, a trailing-slash difference, or a more specific pattern shadowing mine. A 406 is much later — routing succeeded, the handler ran and returned, and then no message converter could produce anything in the client's Accept header. Common causes are a client sending a specific type the application does not produce, or the converter for that type not being registered because a dependency is missing. Its mirror image is 415, where the request body's Content-Type matched no reader. To stop guessing I would set org.springframework.web to DEBUG, which logs the mapping chosen, the resolved arguments and the converter selected; that names the stage directly. I would also check the actuator mappings endpoint to see the routing table as the application actually built it, rather than as I believe I annotated it.@RestController changes.Hands-on
Encode the dispatcher's decisions. Implement statusFor, the 404/405 rule; specificity, which pattern wins; sourceOf, where each annotation reads from; negotiate, picking a representation; and chainOrder, the interceptor callbacks. A stub scores zero, so a passing run means you encoded the rules rather than guessed.
Click Run to compile and execute the code.
Summary
- The
DispatcherServletis a front controller: one servlet routes everything, so cross-cutting concerns are decided once. - Each parameter annotation names a part of the request — path, query, headers, body — which is all argument resolution is.
- Match path first, method second. An empty first pass is a 404; an empty second pass is a 405, and the first pass is the
Allowheader. - The more specific pattern wins, so a literal segment beats a variable and special cases can be added safely.
- Conversion and required-ness are checked before your method runs, which is why a controller parameter is never half-parsed.
Content-Typeselects a reader (415);Acceptselects a writer (406). Opposite directions, different failures.qvalues, not written order, rank theAcceptheader, and an unqualified type isq=1.0.- A
@ControllerreturningStringreturns a view name;@RestControllerreturns the body. preHandleruns in order;postHandleandafterCompletionin reverse, and only entered interceptors complete.- Filter, interceptor and advice see different things — every request, the chosen handler, and typed exceptions respectively.
The dispatcher now gets a request to a method with its arguments ready. The next chapter stays at that boundary and makes it trustworthy: binding complex objects, validating them before any logic runs, and turning the resulting failures into error responses a client can act on.
Writing the dispatcher out in full, what strikes me is how much of it is pipeline — a list of small, replaceable components asked in order until one says yes. Handler mappings, argument resolvers, message converters, exception resolvers, interceptors: five lists, all extensible, all consulted the same way. That is why Spring MVC has absorbed twenty years of changes nobody anticipated — JSON instead of JSP, reactive types, Optional, Kotlin coroutines — without rewriting its core. None of those required a new dispatcher; each was a new entry in one of the lists. I think this is the most transferable idea in the chapter, well beyond Spring: when a framework has to accommodate things its authors cannot foresee, the shape that survives is almost never a clever abstraction. It is an ordered list of handlers with a supports() method, which is barely an abstraction at all, and is the one thing a stranger can extend five years later without understanding the rest.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.