DispatcherServlet Internals: A Request's Full Journey
Type http://localhost:8080/users/42 into the address bar and press Enter; a lot happens in a few hundred milliseconds. The request first reaches Tomcat, which parses the HTTP message and wraps the method, path, headers and body into an HttpServletRequest.
Then comes an interesting question: Tomcat only understands the Servlet interface — service(request, response). But we write plain methods in a @Controller, a method annotated with @GetMapping("/users/{id}"), and Tomcat knows nothing about it. So who translates "a Servlet call" into "a call to the method you wrote"?
The answer is DispatcherServlet — the front controller of Spring MVC. It is itself a Servlet registered on every request path (mapped to /), so every HTTP request lands in its hands first and is then dispatched to a specific handler. That is the front-controller pattern: one entry point, centralized dispatching.
In Spring Boot you do not even see the registration code, because auto-configuration does it:
// The essence is this one line: mapping DispatcherServlet to "/"// Completed automatically when ServletWebServerApplicationContext startsServletRegistrationBean<DispatcherServlet> registration = new ServletRegistrationBean<>(new DispatcherServlet(), "/");registration.setLoadOnStartup(1); // initialize at startupDispatcherServletis anHttpServletoverridingservice()/doGet()/doPost(), all converging on the core methoddoDispatch- The mapping
/means "take over every request", which is why it is the single entry point loadOnStartup = 1initializes it when the container starts, triggering the assembly of the nine components (next section)
Tip: in the old days you declared DispatcherServlet by hand in web.xml; now Spring Boot's DispatcherServletAutoConfiguration does it. So "why does Spring MVC have a DispatcherServlet by default" is once again answered by — auto-configuration registering it for you.
Walking up DispatcherServlet's inheritance chain, the logic is spread across several layers:
HttpServlet └─ HttpServletBean # binds init-params to bean properties └─ FrameworkServlet # bridges Servlet and Spring container; overrides service └─ DispatcherServlet # the front controller itself; doDispatch lives hereThe key is that at startup it calls initStrategies(), initializing nine "helper components":
| Component | Responsibility | Default implementation |
|---|---|---|
HandlerMapping | request → handler (plus interceptor chain) | RequestMappingHandlerMapping |
HandlerAdapter | adapter that invokes the handler | RequestMappingHandlerAdapter |
HandlerExceptionResolver | exception → view / status | ExceptionHandlerExceptionResolver and peers |
ViewResolver | view name → concrete view | ContentNegotiatingViewResolver |
LocaleResolver | decide the request locale | AcceptHeaderLocaleResolver |
ThemeResolver | theme resolution | FixedThemeResolver |
MultipartResolver | file-upload parsing | StandardServletMultipartResolver |
RequestToViewNameTranslator | derive a default view name when none is given | DefaultRequestToViewNameTranslator |
FlashMapManager | flash attributes across redirects | SessionFlashMapManager |
- The nine components are strategies:
DispatcherServletonly dispatches, delegating "how to match", "how to invoke" and "how to render" HandlerMappingandHandlerAdapterare the stars of the main flow, covered in later sections- To customize any link, just register your own bean in the container;
initStrategies()prefers the container's implementation
The whole dispatch skeleton lives in doDispatch. Stripped of details, the core structure is:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception { HttpServletRequest processedRequest = request; HandlerExecutionChain mappedHandler = null; ModelAndView mv = null; Exception dispatchException = null; try { processedRequest = checkMultipart(request); // 1) find the handler for this request (with its interceptor chain) mappedHandler = getHandler(processedRequest); if (mappedHandler == null) { noHandlerFound(processedRequest, response); return; // → 404 is produced on this line } // 2) find the adapter that can run the handler HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // 3) pre-interceptors if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; // returning false aborts the whole chain } // 4) the real execution: resolve arguments + invoke + handle the return value mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); applyDefaultViewName(processedRequest, mv); // 5) post-interceptors mappedHandler.applyPostHandle(processedRequest, response, mv); } catch (Exception ex) { dispatchException = ex; // not handled here; deferred below } // 6) handle the result: exception resolution, view rendering, response writing processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);}getHandler/getHandlerAdapter: answer "who handles it" and "what executes it", corresponding to Sections 4 and 5applyPreHandle: the pre-interceptors. Note it returnsboolean— when it returnsfalsethe method simplyreturns andha.handleis never calledha.handle(...): this one line hides three big steps — argument resolution, method invocation and return-value handling — the star of Section 5- The
catchonly stashes the exception; actual handling is deferred toprocessDispatchResult, so success and failure share one exit processDispatchResult: first runs aHandlerExceptionResolver(on exception), then decides whether to render a view or write a response body

That animation is the "reading" version. Reading and being able to debug differ by exactly one thing: whether you can name the line you are on. So here is the same skeleton as a step-through bench — eight lines of doDispatch on the left, live variables and a call stack on the right. Press next and watch mappedHandler and dispatchException; step ⑦ (the exception is only stashed) is where most readers get it backwards.
mappedHandler = getHandler(request); // 1 registration: look the table upif (mappedHandler == null) { noHandlerFound(request, response); return; } // 2 the only place 404 is bornHandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // 3 triageif (!mappedHandler.applyPreHandle(request, response)) { return; } // 4 pre-handle interceptorsmv = ha.handle(request, response, mappedHandler.getHandler()); // 5 resolve args, invoke, handle returnmappedHandler.applyPostHandle(request, response, mv); // 6 post-handle interceptors} catch (Exception ex) { dispatchException = ex; } // 7 stash only, no handlingprocessDispatchResult(request, response, mappedHandler, mv, dispatchException); // 8 the single exit| thread | http-nio-8080-exec-3 |
| request | GET /users/42 |
| mappingRegistry | 12 registered mappings |
DispatcherServlet.doDispatchgetHandlergetHandler iterates over all HandlerMappings and uses the first one that returns non-null. The most common is RequestMappingHandlerMapping, which extends AbstractHandlerMethodMapping and whose core is a "request → method" registry:
public abstract class AbstractHandlerMethodMapping<T> implements HandlerMapping { // registry: each RequestMappingInfo (path, method, params, ...) maps to a HandlerMethod private final MappingRegistry mappingRegistry = new MappingRegistry(); @Override public HandlerExecutionChain getHandler(HttpServletRequest request) { // iterate all registered mappings, take the first that matches this request return this.mappingRegistry.getMappings().stream() .filter(m -> m.getKey().matches(request)) // match by path/method/header/params .findFirst() .map(m -> new HandlerExecutionChain(m.getValue(), /* interceptors */)) .orElse(null); }}- This registry is built at startup: when the container starts it scans all
@Controllers, parses each@RequestMapping/@GetMappinginto aRequestMappingInfo, and puts it together with itsHandlerMethodinto themappingRegistry - So when a request arrives there is no reflective scanning, only a table lookup — the foundation of Spring MVC's efficiency
HandlerMethodencapsulates "which method of which bean", not the method itself; the adapter uses it to invoke
Key point: the HandlerMapping family has more than one member. RequestMappingHandlerMapping handles annotated controllers, SimpleUrlHandlerMapping handles URL→bean mappings configured by hand in XML, and BeanNameUrlHandlerMapping maps by bean name. By default RequestMappingHandlerMapping comes first, and almost every @Controller is managed by it.
A natural question: DispatcherServlet already has "the method to call", so why another layer, HandlerAdapter?
Because "handlers" can be implemented in very different ways:
- A
@Controllermethod (HandlerMethod) needs argument resolution + reflective invocation + return-value handling - A bean implementing
HttpRequestHandleronly needshandleRequest(request, response) - A plain
Controllerinterface implementation (old-style Spring MVC) has yet another signature
DispatcherServlet cannot write an if-else for each handler. The adapter pattern exists precisely for this: DispatcherServlet only says "I need an adapter that can run this handler" and lets the adapter handle the concrete invocation.
// DispatcherServlet does not care what type the handler isprotected HandlerAdapter getHandlerAdapter(Object handler) { for (HandlerAdapter adapter : this.handlerAdapters) { if (adapter.supports(handler)) { // can this adapter handle it? return adapter; } } throw new ServletException("No adapter for handler [" + handler + "]");}// RequestMappingHandlerAdapter ultimately calls InvocableHandlerMethodprotected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception { // 1. resolve arguments: turn request values into method arguments Object[] args = getMethodArgumentValues(request, response, handlerMethod); // 2. invoke your method reflectively Object returnValue = handlerMethod.invokeForRequest(request, response, args); // 3. handle the return value: delegated to HandlerMethodReturnValueHandler return ...; // see Section 7}supports(handler): each adapter declares "which kind of handler I can handle", so DispatcherServlet only needs a linear searchgetMethodArgumentValues: argument resolution happens here (expanded in Section 6)invokeForRequest: reflective invocation of the target method — your business code runs here- Return value: handed to a set of
HandlerMethodReturnValueHandlers, where message converters step in (Section 7)
Tip: this layer answers the common interview question "does DispatcherServlet know @Controller?" — no. It only knows the HandlerMethod returned by a HandlerMapping and the HandlerAdapter that can execute it. @Controller is what RequestMappingHandlerMapping recognizes during startup scanning, unrelated to DispatcherServlet.
Inside getMethodArgumentValues lives a "library of argument resolvers". Whatever annotation an argument carries, the matching resolver fetches the value from the request and converts the type:
| Annotation | Resolver | Data source |
|---|---|---|
@RequestParam | RequestParamMethodArgumentResolver | query string / form params |
@PathVariable | PathVariableMethodArgumentResolver | URI template variable {id} |
@RequestBody | RequestResponseBodyMethodProcessor | request body (deserialized by HttpMessageConverter) |
@RequestHeader | RequestHeaderMethodArgumentResolver | request headers |
@ModelAttribute | ModelAttributeMethodProcessor | form fields bound to an object |
@SessionAttribute | SessionAttributeMethodArgumentResolver | HttpSession scope |
@RequestAttribute | RequestAttributeMethodArgumentResolver | request scope |
unannotated User | ServletModelAttributeMethodProcessor | whole-object data binding |
@GetMapping("/users/{id}")public User getUser( @PathVariable Long id, // from the URI: /users/42 @RequestParam(defaultValue = "false") boolean detail, // from ?detail=true @RequestHeader("X-Trace-Id") String traceId) { // from a header // the three arguments come from three different resolvers return userService.findById(id, detail, traceId);}- Resolvers are tried in order; the first whose
supportsParameterreturns true owns that argument - Type conversion is done by
WebDataBinder/ConversionService;"42"→Longhappens right here @RequestBodyis special: it does not use request parameters but hands the entire body to a message converter for deserialization
The table above has nine rows, but what a table cannot show is order: the resolvers are a chain, and every argument is asked down it from the front. Tap through it — especially node ⑤, which is the source of the most confusing English sentence beginners meet:
@RequestBody and @RequestParam cannot both read the request body in the same scenario — the body can be read only once. Getting form fields and a JSON body at the same time is essentially impossible because request.getInputStream() is consumed on first read; that is why after @RequestBody, @RequestParam often fails to retrieve form values.
After the method runs, its return value still has to be "translated" into an HTTP response. A set of HandlerMethodReturnValueHandlers plus HttpMessageConverters does this together:
| Return type | Handler | How it is written out |
|---|---|---|
object with @ResponseBody | RequestResponseBodyMethodProcessor | message converter serializes to JSON |
String | ViewNameMethodReturnValueHandler | treated as a view name, handed to ViewResolver |
ModelAndView | ModelAndViewMethodReturnValueHandler | view + model |
ResponseEntity<T> | HttpEntityMethodProcessor | status + headers + body |
Map / List (with @RestController) | RequestResponseBodyMethodProcessor | JSON |
Callable / DeferredResult | async handlers | async return, MVC async support |
@RestController // = @Controller + @ResponseBodypublic class UserController { @GetMapping("/users/{id}") public User getUser(@PathVariable Long id) { return userService.findById(id); // User object → Jackson → JSON }}@RestControlleris@Controller+@ResponseBody, so every return value goes through "serialize to JSON"- Serialization is done by an
HttpMessageConverter:MappingJackson2HttpMessageConverterfor JSON,StringHttpMessageConverterfor plain text - Content negotiation (the
Acceptheader) picks the converter: a browser wants HTML,Accept: application/jsonwants JSON, and the rules are configurable
Tip: the String return value is the easiest to get wrong — when a method returns "users/list", that text is not written to the browser but treated as a view name to look up a template. To make a String be the response body, you must add @ResponseBody (or use @RestController). This fork between "view name or response body" has left countless people staring at a blank page.
Two demos cover this section, and both are about watching a fork happen in real time. First, the way out: how a return value becomes bytes. Tap the four arguments in order — accept explains negotiation, fail is the birth scene of a 406:
Then back to that "one String, two fates" trap. The same return statement, only the class-level annotation swapped, and the exit is somewhere else entirely:
doDispatch stashes the exception in a catch, and processDispatchResult handles it. It consults a set of HandlerExceptionResolvers in order, and the first to return non-null owns it:
| Order | Resolver | What it handles |
|---|---|---|
| 1 | ExceptionHandlerExceptionResolver | methods annotated with @ExceptionHandler (most common) |
| 2 | ResponseStatusExceptionResolver | exceptions carrying @ResponseStatus |
| 3 | DefaultHandlerExceptionResolver | Spring MVC built-in exceptions (see Section 9) |
@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BizException.class) // handled by ExceptionHandlerExceptionResolver @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResult handle(BizException e) { return new ErrorResult(e.getCode(), e.getMessage()); }}- The three resolvers form a chain: the next is tried only when the previous returns null
@ExceptionHandlerinside@ControllerAdvice/@RestControllerAdviceis taken over by the first resolver — the most commonly customized link in business code- If none can handle it (all return null), the exception propagates to the container and becomes a 500
This relay has six steps and deserves its own animation — step 2 (stash, do not handle) and step 6 (nobody caught it) are what tell you which line to fix:

processDispatchResult handles not only exceptions but success too — it decides from the ModelAndView whether to "render a view" or "write the response body directly". It is therefore the single exit for all branches, which is exactly why doDispatch catches the exception first and handles it uniformly.
Mapping errors onto the main flow removes all guesswork when troubleshooting. A table cannot teach this, though — six codes, six positions, so play it: click a status code, then the stop you think it died at, and a wrong pair tells you on the spot what you mixed up.
the difference between 404 and 405 is worth remembering. A 404 means getHandler found no match at all (wrong URL); a 405 means a mapping was found but the HTTP method is not allowed — it is rejected by RequestMappingInfo.matches during matching and ends up as a 405 via DefaultHandlerExceptionResolver. Many people read "POST gives 405" as a wrong path when the annotation was simply @GetMapping.
garbled Chinese from @ResponseBody is a legacy issue. Early StringHttpMessageConverter defaulted to ISO-8859-1, so Chinese turned into mojibake; Spring Boot now defaults to UTF-8, but if you hand-construct a StringHttpMessageConverter without setting the charset, the garbling returns. When debugging mojibake, first check whether the response's Content-Type contains charset=UTF-8.
The demo below turns the doDispatch main flow into switchable paths. Try /users/42, /users and /nope in turn, and watch which component is working at each step and when the 404 appears:
Set the jargon aside for a moment. Picture walking into a public hospital knowing nothing about the process — yet never getting lost, because every counter revolves around one central service desk.
the HTTP request your browser sends is a patient who has never registered. DispatcherServlet is the one-stop service desk in the middle of the hall (the hospital has exactly one entrance — that is the front-controller pattern); HandlerMapping is the registration window, holding a "symptom → department" table and answering "which doctor does this belong to"; HandlerAdapter is the triage nurse, who never treats anyone but decides "how this doctor takes patients — outpatient, specialist or emergency"; your @Controller method is the doctor, where the actual diagnosis happens; and HttpMessageConverter is the lab-report printer, turning the doctor's structured conclusion into the paper report you walk out with (JSON). The health-code check at the door is the Filter (it runs even before the desk and has no idea who the doctors are), and the escorts waiting in the corridor are Interceptors (only someone who has registered gets an escort).
Every role on this chain does exactly one job, so you can always locate a problem with a single question: "which stop did my request die at?"
why go through all this? Taste the alternative of writing a raw Servlet: it means being patient, registrar, triage nurse, doctor and report printer at once — slicing request.getRequestURI() by hand to get the id, Long.valueOf yourself, if-else for every action, StringBuilder to hand-assemble JSON, response.setContentType yourself, try-catch around everything. All Spring MVC does is hand those six jobs to six dedicated counters. What you write shrinks to one line of medical judgement.


When you finish this article you should answer three questions without looking anything up:
- Why can Spring MVC take over the whole site with a single Servlet? (It is mapped to
/, the only entry point) - A 404 versus a 405 — which stop does each one blame? (Registration desk versus triage desk)
- Why can't a Filter tell you "which Controller method is executing"? (
getHandlerhas not run yet)
Section 11 walked the trunk with dispatch, but doDispatch hides two more "ask everyone in turn" chains that confuse beginners most: one on the way in (who fills my method arguments) and one on the way out (who turns my exception into a response). Both follow the same script — ask in order; the first one that says "that's mine" wins; if all say no, you get an error.
The argument side first. Inside ha.handle(), every single parameter of your method signature runs down the resolver chain:
@GetMapping("/users/{id}")public User getUser( @PathVariable Long id, // taken by PathVariableMethodArgumentResolver @RequestParam(required = false) String tag, // taken by RequestParamMethodArgumentResolver HttpServletRequest request) { // taken by ServletRequestMethodArgumentResolver return userService.findById(id, tag, request.getRemoteAddr());}- Three parameters arrive by three different routes, yet one chain claims them one at a time: a resolver first answers
supportsParameter(parameter)— "is this one mine?" - Only after it claims the parameter does fetching and conversion happen:
"42"→Longis done byConversionService - When no resolver claims it, you get
IllegalStateException: Could not resolve parameter [0] ... No suitable resolver— one of the most common sentences a beginner ever sees
Now the exception side. The chain inside processDispatchResult is the same string of ifs:
// simplified from DispatcherServlet#processHandlerExceptionfor (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) { ModelAndView exMv = resolver.resolveException(request, response, handler, ex); if (exMv != null) { break; // the first non-null answer wins; nobody after it is even asked }}// all three resolvers return null -> the exception keeps propagating -> container fallback -> 500 pageThe two demos below turn each chain into a switchable scene. Click through every parameter button in order, especially the last one, "no resolver" — that is precisely the error sitting in your own project:
Section 9 pinned status codes onto stops, but real incidents ask a different set of questions: the log line printed but the traceId did not propagate, CORS broke, postHandle never ran. All of that lives in the few metres of corridor between the service desk and the front door.
One sentence each, so the two never blur together:
- Filter: part of the Servlet spec, not of Spring. It runs before
DispatcherServlet, so it cannot even know which method this request will call - Interceptor: part of Spring MVC, registered inside the
HandlerExecutionChain, travelling with one specific handler chain — and therefore able to see theHandlerMethod
@Component@Order(1) // filter ordering: @Order or FilterRegistrationBeanpublic class TraceIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { MDC.put("traceId", UUID.randomUUID().toString().replace("-", "")); chain.doFilter(request, response); // <- DispatcherServlet only starts after this line } finally { log.info("{} {} cost={}ms", request.getMethod(), request.getRequestURI(), System.currentTimeMillis() - start); MDC.remove("traceId"); // in finally, or pooled threads leak the id } }}@Componentpublic class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // here handler already IS a HandlerMethod — something a filter can never see if (!(handler instanceof HandlerMethod hm)) { return true; // static resources etc. are not methods: let them pass } return LoginUserHolder.get() != null || !hm.hasMethodAnnotation(NeedLogin.class); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // the only guaranteed callback: also invoked when preHandle returned false // (and only for the interceptors whose preHandle actually ran) }}The three differences people forget, easiest to remember alongside the animation:
| Capability | Filter | Interceptor |
|---|---|---|
Can it see the HandlerMethod (which class, which method)? | No | Yes (third parameter of preHandle) |
Does postHandle still run after an exception? | Not applicable | No — control goes straight to the exception resolver chain |
CORS preflight OPTIONS requests | Must be let through here | preHandle easily blocks the preflight as a 403 |

Open all five parameters of the demo below one by one; pay special attention to "preHandle returns false", "does postHandle run on error" and "CORS preflight" — they map onto three real ticket categories:
Once you have clicked through the demos, switch to a command line. This console talks to the same kernel inside your browser and every reply is computed, not scripted — run boot first, then type the six claims of this article one by one:
lab dispatch /nope and curl /api/kernel/beans are two different kinds of action — the first dissects the dispatch step by step, the second really hits the virtual 8080. Confusing the dissection table with the switchboard is the most common beginner mix-up.
The four columns are fixed: "error snippet / real cause / thirty-second fix / where to dig deeper". Copy the error verbatim into a search engine — do not paraphrase. Search understands fully-qualified class names; it does not understand your description.
| Error snippet | Real cause | Thirty-second fix | Dig deeper in |
|---|---|---|---|
404 plus No mapping for GET /api/users in the log | The path string does not line up: the class-level @RequestMapping prefix is missing, context-path was not counted, or the package is outside @ComponentScan | Start with logging.level.org.springframework.web=DEBUG, read which mappings got registered, compare character by character | Section 4 |
404 with no No mapping line at all | A filter or interceptor cut the request short (chain.doFilter never called / preHandle returned false) | Log one line in the filter's finally to confirm it really reached chain.doFilter | Section 14 |
404 plus Circular view path [index]: would dispatch back to the current handler URL [/index] too many times | The return value was treated as a view name, no template engine exists, so it dispatched back to the same URL | Add @ResponseBody to the method or class (or switch to @RestController); if you really want templates, add spring-boot-starter-thymeleaf | #23 return-value semantics |
404 only for static files (/favicon.ico, /js/app.js missing) | An interceptor matched /**, or @EnableWebMvc killed Boot's default resource handling, or the folder is not classpath:/static/ | Check spring.web.resources.static-locations and make the interceptor exclude /static/** | End of Section 15 |
javax.servlet.ServletException: No adapter for handler [...] | The handler type falls outside every HandlerAdapter's capability (usually a hand-registered exotic handler) | Use a standard @Controller method, or register your own HandlerAdapter | Section 5 |
IllegalStateException: Could not resolve parameter [0] ... No suitable resolver | The parameter carries no supported annotation and is not a built-in supported type | Delete the mistyped annotation (e.g. @PathParam, which is JAX-RS); state explicitly where the data comes from | #23 |
| HTTP 405 — "POST returns 405" | The path matched, but the annotation is @GetMapping, so the verb is not allowed | Change it to @PostMapping, or @RequestMapping(method = {GET, POST}) | Section 9 |
HTTP 415 plus HttpMediaTypeNotSupportedException | The Content-Type header clashes with consumes / the message converters | Add -H "Content-Type: application/json" | #23 Section 6 |
The row about "POST returns 405" is the one that sends beginners hunting in the wrong file. Here is that stack for real — do not read the analysis first; click the line you think is guilty:
A form submit comes back 405. You stare at @GetMapping("/users/{id}") next to the browser's /users/42 three times and they look identical.
static resources getting blocked is the classic side effect of @EnableWebMvc. The moment you write it, Spring Boot's default MVC configuration (static resource mapping, the /webjars/** resource handler, message-converter customization) is switched off wholesale, and your pages collapse into blank HTML. Two fixes: ① delete @EnableWebMvc and customize by implementing WebMvcConfigurer instead (the Boot way); ② keep it and re-add resource handling by hand:
@Configuration@EnableWebMvc // do not write this unless you truly want to own all of MVCpublic class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**") .addResourceLocations("classpath:/static/"); registry.addResourceHandler("/webjars/**") .addResourceLocations("classpath:/META-INF/resources/webjars/"); } @Override public void addInterceptors(InterceptorRegistry registry) { // let the interceptor spare static assets, or your CSS/JS get login-checked too registry.addInterceptor(authInterceptor).addPathPatterns("/**") .excludePathPatterns("/static/**", "/favicon.ico", "/error"); }}Last step: tick the handful of configuration lines this article actually uses. Do not copy somebody else's yml — every checkbox maps onto a symptom described above: logging produces the registered-mapping list Section 15 told you to read, server produces context-path (the number-one source of 404s), upload corresponds to the checkMultipart call on the first line of doDispatch, and profile lets dev and prod use different prefixes without a code change:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
This sandbox computes nothing — it performs incident reconstruction: disabling one component simulates one real production shape. Pick a combination and the output gives you "symptom + where to look first".
200 OKHandlerMapping ok, HandlerAdapter ok, resolvers ok, converter ok#baseline: every stop connected
Goal: run "one request through nine stops" from scratch, and watch four different status codes come out of four different stops.
package com.example.lab.mvc;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.util.List;import java.util.Map;@RestController // = @Controller + @ResponseBody@RequestMapping("/api/users") // class prefix: forgetting it is the #1 cause of 404public class UserLabController { record UserVO(Long id, String name) {} // stop 3 registration + stop 7 doctor: @PathVariable via PathVariableMethodArgumentResolver @GetMapping("/{id}") public UserVO get(@PathVariable Long id) { // "42" -> Long via ConversionService return new UserVO(id, "user-" + id); } // collection query: @RequestParam via RequestParamMethodArgumentResolver @GetMapping public List<UserVO> list(@RequestParam(defaultValue = "10") int size) { return List.of(new UserVO(1L, "alice"), new UserVO(2L, "bob")).subList(0, Math.min(size, 2)); } // deliberately producing a 405: GET is not allowed here, so "right path, wrong verb" @PutMapping("/{id}") @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody Map<String, Object> body) { return ResponseEntity.noContent().build(); // a normal PUT -> 204 }}Verification commands (paste each curl into a terminal):
# 1) happy path variable -> 200curl -i http://localhost:8080/api/users/42# 2) collection query -> 200curl -i "http://localhost:8080/api/users?size=1"# 3) path exists but the verb does not -> 405 (not 404!)curl -i -X POST http://localhost:8080/api/users/42# 4) path never registered -> 404curl -i http://localhost:8080/nope# 5) id is not a number -> 400 MethodArgumentTypeMismatchExceptioncurl -i http://localhost:8080/api/users/abcExpected response (command 1):
{ "id": 42, "name": "user-42"}For command 3 expect the status line HTTP/1.1 405 and — importantly — no No mapping in the console: the mapping was found, only the verb was refused. For command 4 expect HTTP/1.1 404 plus one log line No mapping for GET /nope. For command 5 expect HTTP/1.1 400 plus the fully-qualified org.springframework.web.method.annotation.MethodArgumentTypeMismatchException.
Three small edits, each one or two lines. The point is to watch the error text change:
- Delete the class-level
@RequestMapping("/api/users")and rerun command 1 → you observe a 404 whose log now readsNo mapping for GET /api/users/42: a URL is the class prefix concatenated with the method path, and half of it matches nothing - Change
@RestControllerto@Controller, touch nothing else → you observejavax.servlet.ServletException: Circular view path [get](or a template 404), because the returnedUserVOis no longer serialized but interpreted as view information — exactly the two fates described in Section 7 - Change
@GetMapping("/{id}")to@GetMapping("/{id:\\d+}")and rerun command 5 → you observe the status change from 400 to 404: the regex constraint rejects/abcat the registration desk, so type conversion never even gets a turn. Trading "fail later" for "fail cleaner" is a design decision, not a syntax trick
Build a "request journey recorder": for any endpoint, print which stops it passed and how long each took.
Requirements:
- A
TraceIdFilter(extendingOncePerRequestFilter) that mints a traceId into MDC and logs total cost in afinally - A
JourneyInterceptor(implementingHandlerInterceptor) whosepreHandlerecords the entry time and stores theHandlerMethod's "Class#method" as a request attribute - A
@RestControllerAdvicethat mapsMethodArgumentTypeMismatchExceptionto{"code":40000,...}while keeping HTTP 400
Acceptance checklist:
- [ ]
curl -i localhost:8080/api/users/42shows anX-Trace-Idresponse header (proof the filter is alive) - [ ] The log also contains the matched controller method name (proof the interceptor saw the
HandlerMethod, which a filter cannot) - [ ]
/api/users/abcreturns 400 with your unified error body, not the Whitelabel page - [ ] Flip the interceptor's
preHandletoreturn false, rerun the same command and confirm the response becomes "empty body + 200" — reproducing Section 16's first trap with your own hands - [ ] Comment out
@Componenton the filter and confirm the timing log disappears while endpoints keep working: the outer ring is removable, the inner one is not
Check: without scrolling up, can you name the nine stops in order and say where 404 / 405 / 406 / 500 each die? (404 at registration, 405 at the triage condition, 406 at report collection, and an unclaimed-parameter 500 just before seeing the doctor)
Check: can you explain why "DispatcherServlet does not know @Controller"? (It only consumes the `HandlerMethod` handed over by a HandlerMapping plus a HandlerAdapter; the annotation scanning belongs to `RequestMappingHandlerMapping` — two different components doing two different jobs)
Check: what separates `postHandle` from `afterCompletion`, and why does instrumentation belong in the latter? (An exception skips `postHandle`; `afterCompletion` always runs — ThreadLocal/MDC cleanup can only live there)
Check: why does `@RequestParam` often come up empty right after a `@RequestBody`? (The body is a one-shot byte stream — `getInputStream()` is drained once read)
Mantra: **one desk, one entrance (`/`); registration says WHO, triage says HOW, the doctor says WHAT, the printer says IN-FORM; 404 means never registered, 405 means right department wrong verb, 406 means the report could not be printed.**
a request's full journey is Tomcat → DispatcherServlet → getHandler (HandlerMapping table lookup) → getHandlerAdapter (adapter) → applyPreHandle (pre-interceptors) → ha.handle (argument resolution + reflective invocation + return handling) → applyPostHandle (post-interceptors) → processDispatchResult (exception resolution / view rendering / response writing). Remember four key insights: DispatcherServlet does not know @Controller, only HandlerMethod and HandlerAdapter; HandlerMapping's "request→method" registry is built at startup, so a request is only a table lookup; 404 means no handler matched, 405 means the path matched but the method did not, 415 means no message converter supports the body; and when an interceptor must abort and return a standard error, throwing beats returning false.