π§ 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)