The idea: one front controller for every request
In a Spring MVC app you write small methods like @GetMapping("/orders/{id}") Order get(@PathVariable Long id). You never parse a URL, read a request body or write JSON yourself. One servlet, the DispatcherServlet, receives every request and does that work for you. This pattern is called a front controller. Its doDispatch() method runs the same steps every time: find the handler, run the interceptors, build the method's arguments, call your method, and turn what it returns into a response.
The canvas shows one app: a @RestController OrderApi that returns JSON, a @Controller OrderPage that returns an HTML page, and a @RestControllerAdvice ApiErrors for validation errors. On the left is curl. On the right is Tomcat: the filters in the top row, and the parts of doDispatch() below. The yellow box is the one working now, and red rows are the ones being used. Demo 1: GET JSON from a @RestController.
Filters first, then the servlet
Tomcat gives each request to one worker thread from its pool. That thread parses the bytes into an HttpServletRequest and runs the filter chain. Each filter does its job and calls chain.doFilter() to pass the request on. The last link of the chain is the servlet. A filter can also stop the request by writing a response and not calling doFilter().
Spring Security is a filter (FilterChainProxy, which holds about 15 security filters of its own). So when a request has no token, Security answers 401 and the DispatcherServlet never sees the request. No controller, interceptor or @ExceptionHandler runs. Demo 5: no token, a filter answers 401.
| Servlet filter | HandlerInterceptor | |
|---|---|---|
| Belongs to | the servlet container | Spring MVC, inside doDispatch() |
| Runs for | every request to the app, even static files and 401s | requests that found a handler |
| Knows the handler method? | no | yes (the handler argument) |
| Hooks | doFilter() around the rest of the chain | preHandle, postHandle, afterCompletion |
| Typical use | security, encoding, CORS, compression | timing, locale, per-handler checks |
Step 1: the handler mapping
At startup, RequestMappingHandlerMapping scans every @GetMapping, @PostMapping and @RequestMapping and builds a table. For each request, getHandler() looks up the method and the path. When several patterns match, the most specific one wins, so /** only gets what nothing else takes. The match also pulls out the URI variables: for /orders/42 it is id = "42", still a String.
The answer is a HandlerExecutionChain: the handler method plus the interceptors whose paths match. A typo in the URL matches only the static-resource handler /**. It finds no file, throws NoResourceFoundException (Spring 6.1 and later), and the default exception resolver turns that into 404. Demo 6: typo in the URL, 404.
Steps 2–4: interceptors, argument binding, the call
Each interceptor's preHandle() runs first. If one returns false, the request ends there. Then a HandlerAdapter calls the handler. For annotated methods it is RequestMappingHandlerAdapter, and its main job is to build the arguments. Each parameter gets an argument resolver:
@PathVariable Long idtakes the URI variable"42", and the ConversionService turns it into42L. If the text wereabc, the conversion would fail with 400.@RequestBody OrderForm fasks the HttpMessageConverters to read the body. TheContent-Type: application/jsonheader picks Jackson, which turns the JSON into anOrderForm.@Validruns Bean Validation (Hibernate Validator) on the object. If a rule fails, the resolver throwsMethodArgumentNotValidException, and your method is never called.Model mgets a new, empty model map.
Only then does the adapter call your method, as a plain Java call through reflection. Demo 3: POST a JSON body, 201 Created.
Step 5: what the method returns decides the rest
A return value handler looks at the return type and the annotations. In a @RestController every method is @ResponseBody: the returned object is the body. The message converters pick a writer by content negotiation: the client's Accept header against what each converter can write. Jackson writes JSON straight into the response. A ResponseEntity also sets the status and headers, such as 201 and Location.
In a plain @Controller, a returned String is a view name. A ViewResolver turns it into a View. ThymeleafViewResolver adds a prefix and a suffix, so "order" becomes templates/order.html. The View then renders the model into HTML. Demo 2: GET HTML through a ViewResolver and View.
| The method returns | Handled by | Response |
|---|---|---|
an object, in a @RestController | RequestResponseBodyMethodProcessor + a converter | JSON body, 200 |
ResponseEntity<T> | HttpEntityMethodProcessor + a converter | the status and headers you set, plus the body |
a String, in a @Controller | ViewNameMethodReturnValueHandler, then a ViewResolver | an HTML page rendered by the View |
Step 6, and when something throws
After the method returns, postHandle() runs. With @ResponseBody the body is already written by then, so postHandle can no longer change it. That is a common surprise. With a view, postHandle runs before rendering and can still add to the model. Last, afterCompletion() runs. It runs whenever preHandle returned true, even if the handler threw, so it is the place for timing logs and clean-up.
If anything between the mapping and the return value throws, doDispatch() catches it and asks the HandlerExceptionResolvers, in order. First come @ExceptionHandler methods, in the controller or in a @ControllerAdvice. Then a @ResponseStatus annotation on the exception class. Last, DefaultHandlerExceptionResolver maps Spring's own exceptions to status codes, such as 404, 405 and 415. Demo 4: invalid body, @ExceptionHandler, 400.
See also Spring Core: IoC Container, Bean Lifecycle, Scopes, AOP Proxies (how the controllers become beans), How Tomcat Handles an HTTP Request (the connector and thread pool in front of the filters) and JWT vs Session Cookies (the bearer token the Security filter checks).
What the page leaves out
Spring Boot sends errors to /error, which goes through the DispatcherServlet a second time; the page shows the final body only. It also leaves out async requests (DeferredResult, Callable, SseEmitter), WebFlux (a different, non-blocking stack with a DispatcherHandler), @InitBinder and data binding for form posts, HandlerMethodArgumentResolvers you write yourself, the multipart resolver, locale and theme resolvers, CORS handling, the many other Security filters (CSRF, sessions), and the HandlerMapping for functional routes (RouterFunction).
See where this page fits on the Java & the JVM mind map.