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.

curl sends GET /orders/42 to a Tomcat thread, which runs the servlet filter chain (CharacterEncodingFilter, then Spring Security) and then DispatcherServlet. Inside doDispatch the steps are: 1 HandlerMapping, 2 preHandle, 3 HandlerAdapter resolves the arguments, 4 the controller method, 5 the return value goes to a message converter or a view, 6 postHandle, render and afterCompletion. An exception in steps 1 to 5 jumps to the exception resolvers, which answer 400 or 404.
Filters run before Spring MVC; inside doDispatch() the same six steps run for every request, and any exception jumps to the exception resolvers.

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 filterHandlerInterceptor
Belongs tothe servlet containerSpring MVC, inside doDispatch()
Runs forevery request to the app, even static files and 401srequests that found a handler
Knows the handler method?noyes (the handler argument)
HooksdoFilter() around the rest of the chainpreHandle, postHandle, afterCompletion
Typical usesecurity, encoding, CORS, compressiontiming, 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 id takes the URI variable "42", and the ConversionService turns it into 42L. If the text were abc, the conversion would fail with 400.
  • @RequestBody OrderForm f asks the HttpMessageConverters to read the body. The Content-Type: application/json header picks Jackson, which turns the JSON into an OrderForm.
  • @Valid runs Bean Validation (Hibernate Validator) on the object. If a rule fails, the resolver throws MethodArgumentNotValidException, and your method is never called.
  • Model m gets 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.

Left, a @RestController: get(42L) returns an Order, an HttpMessageConverter picked by the Accept header lets Jackson write the JSON body, and postHandle runs after the body is already sent. Right, a @Controller: show returns the view name order, postHandle can still change the model, ThymeleafViewResolver maps order to templates/order.html and View.render writes HTML.
With @ResponseBody the body is written before postHandle runs; with a view name, postHandle still runs before the page is rendered.
The method returnsHandled byResponse
an object, in a @RestControllerRequestResponseBodyMethodProcessor + a converterJSON body, 200
ResponseEntity<T>HttpEntityMethodProcessor + a converterthe status and headers you set, plus the body
a String, in a @ControllerViewNameMethodReturnValueHandler, then a ViewResolveran 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).