DispatcherServlet Internals: A Request's Full Journey

bee2026-10-0852 min read0 views
Nine components relay one request: handler mapping, adapter invocation, argument resolution, return handling and message conversion — every step runnable in the WASM kernel.
1 / 132
Section
1. It starts with a URL in the browser
2 / 132

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.

3 / 132

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"?

4 / 132

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.

5 / 132

In Spring Boot you do not even see the registration code, because auto-configuration does it:

6 / 132
Code
Codejava
// 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 startup
Notes
  • DispatcherServlet is an HttpServlet overriding service() / doGet() / doPost(), all converging on the core method doDispatch
  • The mapping / means "take over every request", which is why it is the single entry point
  • loadOnStartup = 1 initializes 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.

7 / 132
Section
2. The hierarchy and the nine components
8 / 132

Walking up DispatcherServlet's inheritance chain, the logic is spread across several layers:

9 / 132
text
HttpServlet  └─ HttpServletBean                 # binds init-params to bean properties      └─ FrameworkServlet            # bridges Servlet and Spring container; overrides service          └─ DispatcherServlet       # the front controller itself; doDispatch lives here
10 / 132

The key is that at startup it calls initStrategies(), initializing nine "helper components":

11 / 132
Table
ComponentResponsibilityDefault implementation
HandlerMappingrequest → handler (plus interceptor chain)RequestMappingHandlerMapping
HandlerAdapteradapter that invokes the handlerRequestMappingHandlerAdapter
HandlerExceptionResolverexception → view / statusExceptionHandlerExceptionResolver and peers
ViewResolverview name → concrete viewContentNegotiatingViewResolver
LocaleResolverdecide the request localeAcceptHeaderLocaleResolver
ThemeResolvertheme resolutionFixedThemeResolver
MultipartResolverfile-upload parsingStandardServletMultipartResolver
RequestToViewNameTranslatorderive a default view name when none is givenDefaultRequestToViewNameTranslator
FlashMapManagerflash attributes across redirectsSessionFlashMapManager
12 / 132
  • The nine components are strategies: DispatcherServlet only dispatches, delegating "how to match", "how to invoke" and "how to render"
  • HandlerMapping and HandlerAdapter are 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
13 / 132
Section
3. The doDispatch main flow, line by line
14 / 132

The whole dispatch skeleton lives in doDispatch. Stripped of details, the core structure is:

15 / 132
Diagram
Figure 1 · Nine stops of a request
Figure 1 · Nine stops of a request
16 / 132
Code
Codejava
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);}
Notes
  • getHandler / getHandlerAdapter: answer "who handles it" and "what executes it", corresponding to Sections 4 and 5
  • applyPreHandle: the pre-interceptors. Note it returns boolean — when it returns false the method simply returns and ha.handle is never called
  • ha.handle(...): this one line hides three big steps — argument resolution, method invocation and return-value handling — the star of Section 5
  • The catch only stashes the exception; actual handling is deferred to processDispatchResult, so success and failure share one exit
  • processDispatchResult: first runs a HandlerExceptionResolver (on exception), then decides whether to render a view or write a response body
17 / 132
Animation
Animation · Inside doDispatch
Animation · Inside doDispatch
18 / 132

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.

19 / 132
Stepper
StepperStep-through bench: walking doDispatch line by line1 / 8
Press next and track two things: when mappedHandler becomes null at ②, and why the catch at ⑦ does nothing but store
Code under debug
1mappedHandler = getHandler(request); // 1 registration: look the table up
2if (mappedHandler == null) { noHandlerFound(request, response); return; } // 2 the only place 404 is born
3HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // 3 triage
4if (!mappedHandler.applyPreHandle(request, response)) { return; } // 4 pre-handle interceptors
5mv = ha.handle(request, response, mappedHandler.getHandler()); // 5 resolve args, invoke, handle return
6mappedHandler.applyPostHandle(request, response, mv); // 6 post-handle interceptors
7} catch (Exception ex) { dispatchException = ex; } // 7 stash only, no handling
8processDispatchResult(request, response, mappedHandler, mv, dispatchException); // 8 the single exit
Variables now
threadhttp-nio-8080-exec-3
requestGET /users/42
mappingRegistry12 registered mappings
Call stack
1DispatcherServlet.doDispatch
2getHandler
1This is a table lookup, nothing else. The registry was built while the container scanned @Controller at startup, so there is no reflective sweep here: twelve RequestMappingInfo entries get compared by path pattern plus HTTP method, and UserController#getUser wins.
20 / 132
Section
4. HandlerMapping: how a request finds a method
21 / 132

getHandler 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:

22 / 132
Code
Codejava
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);    }}
Notes
  • This registry is built at startup: when the container starts it scans all @Controllers, parses each @RequestMapping / @GetMapping into a RequestMappingInfo, and puts it together with its HandlerMethod into the mappingRegistry
  • So when a request arrives there is no reflective scanning, only a table lookup — the foundation of Spring MVC's efficiency
  • HandlerMethod encapsulates "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.

23 / 132
Section
5. HandlerAdapter: why the adapter pattern
24 / 132

A natural question: DispatcherServlet already has "the method to call", so why another layer, HandlerAdapter?

25 / 132

Because "handlers" can be implemented in very different ways:

26 / 132
  • A @Controller method (HandlerMethod) needs argument resolution + reflective invocation + return-value handling
  • A bean implementing HttpRequestHandler only needs handleRequest(request, response)
  • A plain Controller interface implementation (old-style Spring MVC) has yet another signature
27 / 132

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.

28 / 132
java
// 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 + "]");}
29 / 132
Code
Codejava
// 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}
Notes
  • supports(handler): each adapter declares "which kind of handler I can handle", so DispatcherServlet only needs a linear search
  • getMethodArgumentValues: 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.

30 / 132
Section
6. The argument resolver matrix
31 / 132

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:

32 / 132
Table
AnnotationResolverData source
@RequestParamRequestParamMethodArgumentResolverquery string / form params
@PathVariablePathVariableMethodArgumentResolverURI template variable {id}
@RequestBodyRequestResponseBodyMethodProcessorrequest body (deserialized by HttpMessageConverter)
@RequestHeaderRequestHeaderMethodArgumentResolverrequest headers
@ModelAttributeModelAttributeMethodProcessorform fields bound to an object
@SessionAttributeSessionAttributeMethodArgumentResolverHttpSession scope
@RequestAttributeRequestAttributeMethodArgumentResolverrequest scope
unannotated UserServletModelAttributeMethodProcessorwhole-object data binding
33 / 132
Code
Codejava
@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);}
Notes
  • Resolvers are tried in order; the first whose supportsParameter returns true owns that argument
  • Type conversion is done by WebDataBinder / ConversionService; "42" → Long happens right here
  • @RequestBody is special: it does not use request parameters but hands the entire body to a message converter for deserialization
34 / 132

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:

35 / 132
Diagram
FlowThe argument-resolver chain, one node at a time1 / 5
Tap ① to ⑤; nodes ③ and ⑤ matter most — one decides who wins, the other decides whether you get a 500 or a 400
→
→
→
→
① Take one argument
InvocableHandlerMethod walks the method signature and holds two facts per parameter: its annotations and its type. Nothing here says whether the value lives in the URL or in the body — which is exactly why the annotation exists.
All clearNobody claiming it is your bug (500); a bad value is the caller's bug (400) — the two failures are fixed by different people.
36 / 132
Trap

@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.

37 / 132
Section
7. Return-value handlers and HttpMessageConverter
38 / 132

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:

39 / 132
Table
Return typeHandlerHow it is written out
object with @ResponseBodyRequestResponseBodyMethodProcessormessage converter serializes to JSON
StringViewNameMethodReturnValueHandlertreated as a view name, handed to ViewResolver
ModelAndViewModelAndViewMethodReturnValueHandlerview + model
ResponseEntity<T>HttpEntityMethodProcessorstatus + headers + body
Map / List (with @RestController)RequestResponseBodyMethodProcessorJSON
Callable / DeferredResultasync handlersasync return, MVC async support
40 / 132
Code
Codejava
@RestController                       // = @Controller + @ResponseBodypublic class UserController {    @GetMapping("/users/{id}")    public User getUser(@PathVariable Long id) {        return userService.findById(id);   // User object → Jackson → JSON    }}
Notes
  • @RestController is @Controller + @ResponseBody, so every return value goes through "serialize to JSON"
  • Serialization is done by an HttpMessageConverter: MappingJackson2HttpMessageConverter for JSON, StringHttpMessageConverter for plain text
  • Content negotiation (the Accept header) picks the converter: a browser wants HTML, Accept: application/json wants 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.

41 / 132

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:

42 / 132
Kernel lab
TeaVMMessage converters and content negotiation: how a return value becomes bytesidle
Step through json / accept / string / fail and see which step raises the HttpMediaTypeNotAcceptableException in the fail scene
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
43 / 132

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:

44 / 132
Kernel lab
TeaVM@Controller or @RestController: four endings for one return statementidle
Start with view to watch a String become a template name, then compare with json; missing is the whitelabel 404 scene, and string shows why returning ok gives the browser three bare letters
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
45 / 132
Section
8. The exception handling chain: order of the three resolvers
46 / 132

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:

47 / 132
Table
OrderResolverWhat it handles
1ExceptionHandlerExceptionResolvermethods annotated with @ExceptionHandler (most common)
2ResponseStatusExceptionResolverexceptions carrying @ResponseStatus
3DefaultHandlerExceptionResolverSpring MVC built-in exceptions (see Section 9)
48 / 132
Code
Codejava
@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());    }}
Notes
  • The three resolvers form a chain: the next is tried only when the previous returns null
  • @ExceptionHandler inside @ControllerAdvice / @RestControllerAdvice is 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
49 / 132

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:

50 / 132
Animation
Animation · The relay of an exception
Animation · The relay of an exception
51 / 132
Note

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.

52 / 132
Section
9. Where 404 / 405 / 415 actually happen
53 / 132

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.

54 / 132
Match
MatchMatch the status code to the stop it died atMatched 0/6 · Missed 0
Every code here comes from one of the eight lines in Section 3; pairing them correctly is what reading doDispatch actually buys you
Pick a card on the left first
55 / 132
Trap

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.

56 / 132
Trap

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.

57 / 132
Section
10. Hands-on: dispatch a request yourself
58 / 132

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:

59 / 132
Kernel lab
TeaVMDispatch a request yourselfidle
Try the paths /users/42, /users and /nope and watch what each component does
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
60 / 132
Section
11. Decision: preHandle returns false, or throw
61 / 132
Decision
Decisiona login-check interceptor finds the user is not authenticated. How should it abort the request?
62 / 132
Section
12. Thirty seconds: Spring MVC is a hospital one-stop service desk
63 / 132

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.

64 / 132
类比

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).

65 / 132

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?"

66 / 132
类比

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.

67 / 132
Diagram
Figure 4 · Raw Servlet vs DispatcherServlet
Figure 4 · Raw Servlet vs DispatcherServlet
68 / 132
Diagram
Figure 3 · Nine stops of one request through MVC
Figure 3 · Nine stops of one request through MVC
69 / 132

When you finish this article you should answer three questions without looking anything up:

70 / 132
  • 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"? (getHandler has not run yet)
71 / 132
Section
13. Hands-on lab two: the argument resolver and exception resolver chains
72 / 132

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.

73 / 132

The argument side first. Inside ha.handle(), every single parameter of your method signature runs down the resolver chain:

74 / 132
Code
Codejava
@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());}
Notes
  • 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" → Long is done by ConversionService
  • 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
75 / 132

Now the exception side. The chain inside processDispatchResult is the same string of ifs:

76 / 132
java
// 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 page
77 / 132

The 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:

78 / 132
Kernel lab
TeaVMInside the argument resolver chainidle
Switch through @PathVariable / @RequestParam / @RequestBody / built-in types, then watch "no resolver" blow up
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
79 / 132
Kernel lab
TeaVMInside the exception resolver chainidle
Go from "validation 400" to "nobody handles it -> 500" and see who caught the exception
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
80 / 132
Section
14. Hands-on lab three: filters versus interceptors, who runs first
81 / 132

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.

82 / 132

One sentence each, so the two never blur together:

83 / 132
  • 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 the HandlerMethod
84 / 132
java
@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)    }}
85 / 132

The three differences people forget, easiest to remember alongside the animation:

86 / 132
Table
CapabilityFilterInterceptor
Can it see the HandlerMethod (which class, which method)?NoYes (third parameter of preHandle)
Does postHandle still run after an exception?Not applicableNo — control goes straight to the exception resolver chain
CORS preflight OPTIONS requestsMust be let through herepreHandle easily blocks the preflight as a 403
87 / 132
Animation
Animation · Filter versus interceptor order
Animation · Filter versus interceptor order
88 / 132

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:

89 / 132
Kernel lab
TeaVMFilters vs interceptors, executed for realidle
Step through order / pre / ex / cors / scope and watch what becomes of postHandle in pre and ex
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 132

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:

91 / 132
Console
92 / 132
Note

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.

93 / 132
Section
15. Common errors at a glance
94 / 132

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.

95 / 132
Table
Error snippetReal causeThirty-second fixDig deeper in
404 plus No mapping for GET /api/users in the logThe path string does not line up: the class-level @RequestMapping prefix is missing, context-path was not counted, or the package is outside @ComponentScanStart with logging.level.org.springframework.web=DEBUG, read which mappings got registered, compare character by characterSection 4
404 with no No mapping line at allA 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.doFilterSection 14
404 plus Circular view path [index]: would dispatch back to the current handler URL [/index] too many timesThe return value was treated as a view name, no template engine exists, so it dispatched back to the same URLAdd @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 HandlerAdapterSection 5
IllegalStateException: Could not resolve parameter [0] ... No suitable resolverThe parameter carries no supported annotation and is not a built-in supported typeDelete 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 allowedChange it to @PostMapping, or @RequestMapping(method = {GET, POST})Section 9
HTTP 415 plus HttpMediaTypeNotSupportedExceptionThe Content-Type header clashes with consumes / the message convertersAdd -H "Content-Type: application/json"#23 Section 6
96 / 132

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:

97 / 132
Triage
Error triageHttpRequestMethodNotSupportedException: Request method 'POST' not supported
The path is clearly right, so why does POST get a 405

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.

org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported
at org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.handleNoMatch(RequestMappingInfoHandlerMapping.java:253)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.lookupHandlerMethod(AbstractHandlerMethodMapping.java:422)
at org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.getHandlerInternal(AbstractHandlerMethodMapping.java:365)
at org.springframework.web.servlet.DispatcherServlet.getHandler(DispatcherServlet.java:1261)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1043)
supported methods = [GET]
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
98 / 132
Trap

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:

99 / 132
java
@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");    }}
100 / 132

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:

101 / 132
Generator
GeneratorThe four config groups this article needsapplication.yml2 / 4
Tick only server and logging first and start the app: the boot log now prints the full path of every registered mapping, so the 404 hunting in Section 9 stops being guesswork. Add upload afterwards and see which stop enforces the multipart ceiling; use profile to split dev and prod context paths
Output
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 }
Why each choice matters
serverserver.port loses to --server.port=8081 on the command line and to the SERVER_PORT env var.
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
102 / 132
Section
16. Quick checks
103 / 132
Quiz
Check yourselfA request arrives. DispatcherServlet wants to call your @Controller method, yet it has never heard of the @Controller annotation. Who answers the question "who can execute my method"?
Pick one — you get feedback right away
104 / 132
Quiz
Check yourselfAn interceptor's preHandle finds the user is not logged in and returns false. What does the client most likely receive?
Pick one — you get feedback right away
105 / 132
Section
17. Sandbox: switch components off one by one and see where the request dies
106 / 132

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".

107 / 132
Sandbox
SandboxDisable one component and see what happens
Result
200 OK
HandlerMapping ok, HandlerAdapter ok, resolvers ok, converter ok
#baseline: every stop connected
All clear — this is what your laptop looks like
108 / 132
Section
18. Hands-on exercises
109 / 132
Section
Tier one · Follow along
110 / 132

Goal: run "one request through nine stops" from scratch, and watch four different status codes come out of four different stops.

111 / 132
java
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    }}
112 / 132

Verification commands (paste each curl into a terminal):

113 / 132
bash
# 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/abc
114 / 132

Expected response (command 1):

115 / 132
json
{  "id": 42,  "name": "user-42"}
116 / 132

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.

117 / 132
Section
Tier two · Variants
118 / 132

Three small edits, each one or two lines. The point is to watch the error text change:

119 / 132
  1. Delete the class-level @RequestMapping("/api/users") and rerun command 1 → you observe a 404 whose log now reads No mapping for GET /api/users/42: a URL is the class prefix concatenated with the method path, and half of it matches nothing
  2. Change @RestController to @Controller, touch nothing else → you observe javax.servlet.ServletException: Circular view path [get] (or a template 404), because the returned UserVO is no longer serialized but interpreted as view information — exactly the two fates described in Section 7
  3. Change @GetMapping("/{id}") to @GetMapping("/{id:\\d+}") and rerun command 5 → you observe the status change from 400 to 404: the regex constraint rejects /abc at 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
120 / 132
Section
Tier three · Build one
121 / 132

Build a "request journey recorder": for any endpoint, print which stops it passed and how long each took.

122 / 132

Requirements:

123 / 132
  • A TraceIdFilter (extending OncePerRequestFilter) that mints a traceId into MDC and logs total cost in a finally
  • A JourneyInterceptor (implementing HandlerInterceptor) whose preHandle records the entry time and stores the HandlerMethod's "Class#method" as a request attribute
  • A @RestControllerAdvice that maps MethodArgumentTypeMismatchException to {"code":40000,...} while keeping HTTP 400
124 / 132

Acceptance checklist:

125 / 132
  • [ ] curl -i localhost:8080/api/users/42 shows an X-Trace-Id response 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/abc returns 400 with your unified error body, not the Whitelabel page
  • [ ] Flip the interceptor's preHandle to return 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 @Component on the filter and confirm the timing log disappears while endpoints keep working: the outer ring is removable, the inner one is not
126 / 132
Section
19. Self-check
127 / 132

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)

128 / 132

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)

129 / 132

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)

130 / 132

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)

131 / 132

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.**

132 / 132
Summary

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.