🧠 The big idea in one line
Spring finds the handler, prepares its arguments, calls it, and handles the result.
- Why this exists — an HTTP request contains paths, headers, and bytes. Your Java method needs typed arguments.
- Spring bridges that gap using the parameter and return types it supports.
- When you meet it — a route fails, a parameter cannot be converted, or you want to supply a custom method argument.
-
What you already have — one servlet catches every request, and
@GetMappingmarks a method. This lesson is everything that happens in between.
🗺️ Three questions, asked in order
The DispatcherServlet does not contain your logic and does not know your URLs. On each request it asks three questions and hands each one to a different object:
- Which method should run? — answered by an object whose only job is matching a request to a method. That object is a handler mapping.
- How do I actually call that method? — answered by an object that knows one calling style. That is a handler adapter.
- What value goes in each parameter? — answered by small single-purpose objects called argument resolvers.
request
|
v
+------------------+
| DispatcherServlet|
+------------------+
|
| 1. "who owns /users/42?"
+---------------------------> HandlerMapping --> HandlerMethod
|
| 2. "who can call that?"
+---------------------------> HandlerAdapter
|
| 3. adapter fills the args
| @PathVariable Long id <-- ArgumentResolver
| @RequestBody User u <-- ArgumentResolver
|
v
your method runs
Each answer is looked up, never hard-coded. That is what makes the whole thing extensible.
📇 The lookup table is built at startup
Matching a URL by scanning every controller on every request would be slow. So Spring does the scanning once, at startup.
- At boot, Spring walks every bean marked
@Controlleror@RestController. - For each annotated method it reads the mapping annotations and builds a RequestMappingInfo — a small object holding everything that must match: path, HTTP method,
params,headers,consumes,produces. - It pairs that with a HandlerMethod — just the controller bean plus a
java.lang.reflect.Methodhandle. That pair is "the handler". - Both go into one map, the mapping registry, held by
RequestMappingHandlerMapping.
So this method:
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping(path = "/{id}", produces = "application/json")
public User getUser(@PathVariable Long id) { ... }
}
becomes, at startup, roughly one registry entry:
RequestMappingInfo{ GET /users/{id}, produces=application/json }
-> HandlerMethod{ userController, getUser(Long) }
What this means in practice:
- Lookup at request time is a map read plus a little matching — fast.
- Creating an object with
newdoes not register a new route. - The registry is not frozen. Spring exposes
registerMapping()andunregisterMapping()for deliberate runtime changes. - Ordinary applications let Spring discover their routes at startup.
🔍 Step 1 — the handler mapping picks the method
The DispatcherServlet holds an ordered list of handler mappings, not just one. It asks each in turn and takes the first non-null answer.
// Conceptually, inside DispatcherServlet
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain chain = mapping.getHandler(request);
if (chain != null) return chain; // first one to claim it wins
}
return null; // nobody claimed it -> 404
- In the usual Spring MVC configuration, annotated controllers are checked before static resources by
RequestMappingHandlerMapping. - Other mappings and custom ordering can change this list.
- Static resources (
/style.css) are checked by a later mapping in the list. - That ordering matters — a controller mapped to a very broad path answers first, and the static file is never reached.
What comes back is not the method alone. It is a HandlerExecutionChain — the handler plus the list of interceptors that should run around it. We'll use those at the end.
How a match is actually decided
- A route must satisfy its path and mapping conditions.
- If no full match exists, Spring checks partial matches to explain common failures.
- These are typical default responses for mapping failures. Other processing stages can produce the same codes.
| Stage | Checks | If nothing matches |
|---|---|---|
| Path | URL against the patterns | 404 — no handler at all |
| HTTP method | GET vs POST vs … | 405 Method Not Allowed |
consumes |
request Content-Type
|
415 Unsupported Media Type |
produces |
request Accept header |
406 Not Acceptable |
params |
required request-parameter conditions | 400 Bad Request |
- A failed
headersmapping condition does not automatically mean 400. It can leave the request without a matching controller. - A 404 can mean no route matched, a static file was missing, or application code returned 404.
- A 415 can come from
consumesmatching or from a body converter later. - Check the exception or logs along with the status code.
- If several mappings match, Spring compares specificity. An exact path usually beats a variable pattern.
- If the best matches remain equally specific, Spring reports an ambiguous match.
🔌 Step 2 — the handler adapter, and why there is a middleman
- The mapping returns a handler typed as plain
Object. - It might represent an annotated method, a static-resource handler, or a function-based route.
- The DispatcherServlet needs a different calling strategy for each kind.
❌ The obvious design that fails
// If DispatcherServlet called handlers directly:
if (handler instanceof HandlerMethod hm) { ... }
else if (handler instanceof ResourceHttpRequestHandler r) { ... }
else if (handler instanceof HandlerFunction f) { ... }
// every new handler style = editing DispatcherServlet
- Every new kind of handler forces a change to the core class.
- Third parties could never add a handler style at all.
✅ What Spring does instead
A handler adapter knows how to call one kind of handler. Its two main operations are shown here:
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest req,
HttpServletResponse res,
Object handler) throws Exception;
}
- The DispatcherServlet walks its adapter list and takes the first one whose
supports()returns true. - For your
@GetMappingmethods that isRequestMappingHandlerAdapter. - The DispatcherServlet stays completely unaware of annotations, reflection, or JSON.
The adapter returns a ModelAndView — a small holder for the data plus the name of a page to render. It may also be null. Hold that thought — it matters in step 4.
🧩 Step 3 — argument resolvers fill the parameters
Now the adapter has to call getUser(Long id). It has an HttpServletRequest; it needs a Long. This is where the real work happens.
An argument resolver supplies values for the parameter types or annotations it supports. Here is a simplified outline of its two operations:
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
Object resolveArgument(MethodParameter parameter, ...) throws Exception;
}
The adapter loops over the method's parameters. For each one:
- Walk the resolver list and find the first whose
supportsParameter()says yes. - Call its
resolveArgument()to produce the value. - Put the value in the argument array.
What happens on later requests:
- Spring caches which resolver supports each parameter. It does not repeat that search on every request.
- It still resolves fresh argument values for each request.
- Text values also need type conversion:
"42"becomes aLong;"abc"cannot. - A missing required value or failed conversion normally produces 400 before your method runs.
- An unannotated simple parameter is normally treated as a request parameter.
- A complex parameter can instead be built from form or query fields. This is model-attribute binding, also requested explicitly with
@ModelAttribute. It does not parse a JSON body.
Once every slot is filled, the adapter invokes the method by reflection. This signature:
public User update(@PathVariable Long id,
@RequestBody UserDto body,
@RequestHeader("X-Trace") String trace) { ... }
is not one magic step. It is three independent resolvers, each filling one slot:
| Parameter | Resolver responsible | Where the value comes from |
|---|---|---|
@PathVariable |
PathVariableMethodArgumentResolver |
URL template variables |
@RequestParam |
RequestParamMethodArgumentResolver |
query string or form fields |
@RequestBody |
RequestResponseBodyMethodProcessor |
request body, via message converters |
@RequestHeader |
RequestHeaderMethodArgumentResolver |
a named header |
HttpServletRequest, Principal, Locale
|
ServletRequestMethodArgumentResolver |
the request itself |
That @RequestBody row is a boundary worth naming. The resolver does not parse JSON itself — it hands the body stream to a message converter, which is the thing that knows JSON. The next lesson takes converters apart; here it is enough that the resolver delegates.
Adding your own resolver
Because the list is just a list, you can extend it. Say every request carries a tenant header and you are tired of reading it by hand.
public class TenantResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter p) {
return p.hasParameterAnnotation(CurrentTenant.class);
}
@Override
public Object resolveArgument(MethodParameter p, ModelAndViewContainer m,
NativeWebRequest req, WebDataBinderFactory b) {
return new Tenant(req.getHeader("X-Tenant-Id"));
}
}
-
CurrentTenantis a custom parameter annotation retained at runtime. -
Tenantis your value type with a constructor accepting the ID. - Register the resolver through WebMvcConfigurer, Spring MVC's configuration extension interface:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new TenantResolver());
}
}
Your controller can then ask for that value:
@GetMapping("/orders")
public List<Order> list(@CurrentTenant Tenant tenant) { ... }
-
supportsParameteris the filter: only parameters marked@CurrentTenant. -
resolveArgumentis the factory: build the value from the request. - Controllers no longer repeat the header lookup.
- This small example omits missing-header checks. Reject a missing or invalid ID before using it.
- A client-supplied tenant ID is input, not proof of access. Verify access against the authenticated user.
📤 Step 4 — return value handlers deal with what comes back
Arguments have a mirror image. A return value handler decides what to do with whatever your method returned. Same two-method shape: does it support this return type, and how should it be handled.
The common return-value paths:
| Return type | Handler | Result |
|---|---|---|
@ResponseBody value |
RequestResponseBodyMethodProcessor |
serialized straight into the response body |
ResponseEntity |
HttpEntityMethodProcessor |
body plus status and headers |
plain String on @Controller
|
ViewNameMethodReturnValueHandler |
treated as a view name to render |
Here is the mechanism that connects this back to the DispatcherServlet:
- When a return-value handler handles the response body, it marks the request as handled.
- That flag means no view is needed. It does not promise that network delivery has finished.
- The adapter then returns
nullinstead of aModelAndView. - The DispatcherServlet sees
nulland skips view resolution entirely. -
@RestControllerincludes@ResponseBodybehavior for its methods. - A method in a plain
@Controllercan also use@ResponseBody, or returnResponseEntity. - Both controller styles use the same pipeline. Annotations and return types determine how the result is handled.
🔗 Interceptors wrap the whole thing
Remember the chain carried interceptors alongside the handler. They run at three fixed points:
preHandle -> before the adapter is called
[ handler runs, response may be written ]
postHandle -> after the handler, before any view is rendered
[ view rendering, if any ]
afterCompletion -> cleanup for interceptors whose preHandle returned true
-
preHandlereturningfalsestops the request there — the handler never runs. -
postHandleis skipped if the handler threw an exception. - Cleanup runs only for interceptors whose
preHandlecompleted and returnedtrue. - If your own
preHandlereturnsfalseor throws, release anything it acquired there. -
preHandleruns in list order.postHandleandafterCompletionrun in reverse order. - This diagram shows a synchronous request. Async processing defers completion and may dispatch again.
- If processing throws, the DispatcherServlet asks exception resolvers to handle the failure.
- A resolver may produce an error response or view. An unresolved exception propagates to the servlet container.
- Exception handling gets its own lesson later in this module.
⚠️ Easy to confuse
- Handler mapping vs handler adapter — the mapping answers which method; the adapter answers how to call it. A mapping failure means no method was chosen at all. By the time an adapter is involved, your method has already been picked.
- Argument resolver vs message converter — the resolver decides which part of the request a parameter comes from. The converter turns bytes into an object. Only body-related parameters involve a converter at all.
-
HandlerMethod vs handler — "handler" is the general word for whatever answers a request.
HandlerMethodis the specific kind that wraps one annotated controller method. - 404 vs 405 — a default routing 404 means no matching handler was found. A mapping-stage 405 means a path matched but the HTTP method did not. Application code can also return these statuses.
🕳️ Traps this design creates
-
Do not consume the body before Spring reads it. A filter that drains the stream can leave
@RequestBodywith no bytes. - For logging,
ContentCachingRequestWrapperrecords bytes as downstream code reads them. Read its cache after the filter chain returns. - That wrapper does not automatically make the input stream replayable. Reading the body first requires a wrapper that actually supplies a fresh stream.
- Changing the model in postHandle will not change a JSON response. Body handling already ran inside the adapter.
- Use ResponseBodyAdvice, a hook that can adjust the body before a message converter writes it.
-
The first supporting resolver wins. Resolvers added through
addArgumentResolvers()sit after many standard resolvers, but before fallback resolvers. - An explicit
@RequestParamis normally claimed by its standard resolver first. Use a dedicated annotation for a custom value. -
A broad controller path can claim static-file requests. In the usual ordering, a root mapping like
/{path}can match/favicon.icobefore the resource handler sees it. - Creating a controller object does not register its routes. Dynamic registration is possible through the mapping registry's API. It must be deliberate.
📊 Quick summary
| Stage | Component | Question it answers | Produces |
|---|---|---|---|
| Startup | mapping registry | what routes exist? | RequestMappingInfo → HandlerMethod |
| 1 | HandlerMapping | which method owns this request? | HandlerExecutionChain |
| 2 | HandlerAdapter | how do I call this handler? | calls the handler and returns ModelAndView or null |
| 3 | Argument resolvers | what goes in each parameter? | the argument array |
| 4 | Return value handlers | what do I do with the result? | a written body, or a view name |
🎯 Decision rule
Use the code as a clue, then check the exception and logs:
- 404 → check mappings, class-level prefixes, and static-resource lookup. Also check whether application code returned it.
- 405 → check the request method against the mapped methods.
-
415 → check
Content-Type,consumes, and whether a converter can read the body. -
406 → check
Accept,produces, and whether a converter can write the result. - 400 with a missing-parameter message → step 3, a resolver could not find a required value.
- Parameter is null but the request looks right → step 3, check that the resolver you expect is actually the first one claiming that parameter.
To inject a value of your own, add an argument resolver with your own annotation — never fight the built-in ones.
💡 Remember this
- The DispatcherServlet orchestrates and delegates. It asks which method, how to call it, and what to pass — three questions, three replaceable answers.
- Spring discovers ordinary routes at startup. Creating an object later does not add a route, but explicit runtime registration is possible.
- Status codes help locate a failure. They do not uniquely identify its stage; read the exception too.
- A method signature is filled one parameter at a time by independent resolvers, and the return value is handled by their mirror image. Both lists are open — that is the extension point.
Top comments (0)