What this post covers
This is the twenty-third post in the "Digging into Spring's Source" series. Part 21 followed one request through DispatcherServlet.doDispatch() from start to finish, and Part 22 looked at the very middle of it: finding the @RequestMapping method, filling in its arguments and calling it. This post looks at what surrounds that middle: the interceptors that run before and after the controller method, what happens when the method throws, and the last step in which the view name the method returned becomes a page. On the map in Part 1 this is unit U21 of Phase 5 (web), and it closes the Spring MVC part (U19–U21).
There are four questions to answer.
- If the second of three interceptors blocks the request, whose
afterCompletionis called? How doesHandlerExecutionChainremember that? - Who receives an exception thrown by a controller, and in what order (the three
HandlerExceptionResolvers)? - If there is an
@ExceptionHandlermethod inside the controller and another in a@ControllerAdvice, which one wins? If one handles a supertype and another a subtype, which is picked? - How does the string
"hello"returned by a controller become/WEB-INF/views/hello.jsp, and why does@ResponseBodyskip all of that?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-webmvc, spring-web (web.method, web.bind.annotation) |
| Classes read most | HandlerExecutionChain, DispatcherServlet, ExceptionHandlerExceptionResolver, ExceptionHandlerMethodResolver, UrlBasedViewResolver, InternalResourceView |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is listed at the end, under "What this post left out", by name and location only.
Words to know first
A handler is the object that actually processes one request. The kind we usually write is a method such as @GetMapping in a @Controller class, and Spring wraps it in an object called HandlerMethod (controller bean + method). Finding and calling handlers is the subject of Part 22.
DispatcherServlet is the servlet that receives every request first, finds the handler, calls it and turns the result into a response. The order of that work lives in a single method, doDispatch(). Part 21 walked through it.
An interceptor (HandlerInterceptor) is an object that gets a turn before and after the handler runs. Work that many controllers share, such as checking that the user is logged in or measuring how long a request took, goes here. Two other things have similar names and are easy to mix up, but they are different. A servlet filter (Filter) wraps the request even further out than DispatcherServlet (Part 20), and AOP's MethodInterceptor has nothing to do with the web: it wraps a method call intercepted by a proxy (Part 11). The table in section 2 sets them side by side.
A ModelAndView is the package a handler gives back to DispatcherServlet to say "show this data (the model) with this page (the view)". The view slot usually holds not a view object but a view name, a string such as "hello".
A view (View) is an object that takes the model and writes the response body, usually HTML. A view resolver (ViewResolver) turns a view name into a view object.
A forward is a servlet handing the request to another resource on the same server (a JSP file, for example) so that it writes the response. The browser never learns that anything moved. That is different from a redirect, which answers the browser with "request that other address instead".
The word resolver appears many times in this post. In Spring's source it broadly means "an object that takes something and finds the answer for it". If it takes an exception and decides the response, it is a HandlerExceptionResolver; if it takes a view name and gives a view, a ViewResolver.
1. Three things around the handler
Here is the doDispatch() of Part 21 (lines 935–1004) redrawn with only what this post cares about.
Blue boxes are interceptors (section 2), red boxes are the path an exception takes (sections 3 and 4), the green box is the view (section 5). Grey boxes were covered in Parts 21 and 22.
In all three cases DispatcherServlet does not do the work itself. It asks the objects in a list, one after another: a list of interceptors, a list of HandlerExceptionResolvers, a list of ViewResolvers. So every section below comes back to the same two questions: who fills that list, and who goes first inside it.
2. Interceptors — only the ones that passed get cleaned up
Three methods
HandlerInterceptor is an interface with three methods, all of which have default implementations (lines 102–156).
| Method | When it is called | What it returns |
|---|---|---|
preHandle |
after the handler is found, before it runs | true to continue, false to stop here (the interceptor is assumed to have written the response itself) |
postHandle |
after the handler finished normally, before the view is rendered | nothing; it receives the ModelAndView and may add to the model |
afterCompletion |
after the view is rendered, whatever the outcome | nothing; meant for cleaning up resources |
Who lines them up: HandlerExecutionChain
A handler mapping (Part 22) does not return just a handler. It returns a HandlerExecutionChain, the handler bundled with "the interceptors that apply to this request". The bundling happens in AbstractHandlerMapping, the parent of every handler mapping.
protected HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {
HandlerExecutionChain chain = (handler instanceof HandlerExecutionChain handlerExecutionChain ?
handlerExecutionChain : new HandlerExecutionChain(handler));
for (HandlerInterceptor interceptor : this.adaptedInterceptors) {
if (interceptor instanceof MappedInterceptor mappedInterceptor) {
if (mappedInterceptor.matches(request)) {
chain.addInterceptor(mappedInterceptor.getInterceptor());
}
}
else {
chain.addInterceptor(interceptor);
}
}
// ...
return chain;
}
Two things are worth noticing.
- A new chain is created for every request (
new HandlerExecutionChain(handler)). The interceptor objects are singleton beans shared by all requests, but the chain that holds them is thrown away when the request is done. That is why the chain can keep state in a field, theinterceptorIndexwe are about to meet. - A
MappedInterceptoronly goes in when the path matches.MappedInterceptoris a wrapper that attaches "which paths this applies to" to one interceptor (lines 62–69). Itsmatches()checks exclude patterns, excluded HTTP methods, include patterns and included HTTP methods in turn (lines 224–270); if the request passes, the chain receives the inner interceptor, unwrapped. Interceptors without a path condition simply always go in.
So who fills adaptedInterceptors? In an @EnableWebMvc setup it is whatever you registered through WebMvcConfigurer.addInterceptors(). If you add paths, as in registry.addInterceptor(x).addPathPatterns("/admin/**"), that registration is wrapped in a MappedInterceptor; if you do not, the interceptor is used as is (InterceptorRegistration, lines 198–218). The list is sorted by order (default 0; equal values keep their registration order, InterceptorRegistry, lines 69–83), and two interceptors that Spring itself uses are added to the same registry (WebMvcConfigurationSupport, lines 304–316). Any beans of type MappedInterceptor in the container are also found and added (AbstractHandlerMapping, lines 451–455, lines 478–481).
interceptorIndex: a number that records how far we got
The code with which the chain calls its interceptors is quoted in section 5 of Part 21, so here is only the gist (HandlerExecutionChain, lines 142–181).
applyPreHandlecallspreHandlefront to back, and each time one returnstrueit writes that position intointerceptorIndex. The initial value is-1(line 48).applyPostHandlecalls all of them back to front.triggerAfterCompletiongoes frominterceptorIndexdown to 0.
The yellow boxes are the state the chain carries. B never returned true, so the number is still 0.
Why is such a number needed? Think of an interceptor that opens something in preHandle (starts a timer, grabs a resource) and closes it in afterCompletion. You must not be told to close what you never opened, and whatever you opened must get its chance to be closed. Remembering "we opened up to here" in a single number keeps both promises. The Javadoc says the same: afterCompletion is only called if this interceptor's preHandle completed successfully and returned true (lines 137–138).
How this differs from AOP's advice chain
The AOP chain in Part 11 also followed its list with one number (currentInterceptorIndex). But the shape is different. An AOP MethodInterceptor has one method (invoke) in which it calls proceed() and so wraps the next one itself: do the "before" work, call proceed(), do the "after" work when it returns. Cleanup is its own try/finally, so the chain has no need to remember how far things got.
A HandlerInterceptor does not wrap anything. Before, after and end are three separate methods, and the caller (DispatcherServlet) invokes them in three plain loops. The interceptor has no try/finally of its own that ties "my preHandle" to "my afterCompletion", so the chain pairs them up on its behalf with interceptorIndex.
Servlet Filter |
MVC HandlerInterceptor |
AOP MethodInterceptor |
|
|---|---|---|---|
| Where it cuts in | outside DispatcherServlet |
inside DispatcherServlet, around the handler |
a method call intercepted by a proxy |
| Shape | one doFilter wraps the next |
three methods called separately | one invoke wraps the next |
| How it passes control on | chain.doFilter() |
preHandle returns true |
invocation.proceed() |
| Does it know the handler? | no | yes (the handler argument) |
unrelated to the web |
| Explained in | Part 20 | this post | Part 11 |
The Javadoc of HandlerInterceptor makes the comparison with filters too: filters are more powerful because they can exchange the request and response objects handed down the chain, while an interceptor only allows pre-processing with the option of prohibiting the handler's execution, and post-processing (lines 52–57). The table cell saying a filter does not know the handler is not something confirmed in the Spring source; it follows from how servlets are structured. A filter runs before DispatcherServlet has picked a handler, and all doFilter receives is the request, the response and the chain.
Example 1: when B blocks, and when an exception is handled
This is ex/u21/InterceptorOrderExample.java. The example was run with the released Spring Framework 7.0.9 to confirm the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). Instead of starting a server, it feeds fake requests to a DispatcherServlet through MockMvc from spring-test. All three examples in this post also print two Hibernate Validator version lines when run; they have nothing to do with this post and are left out of the output.
// An example written for this post (not Spring's code)
static class Named implements HandlerInterceptor {
// ... a constructor that takes the name
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
boolean pass = !this.name.equals(request.getParameter("block")); // with ?block=B, B blocks
System.out.println(" preHandle " + this.name + " -> " + pass);
if (!pass) {
response.setStatus(403);
}
return pass;
}
// ... postHandle and afterCompletion just print the arguments they receive
}
@RestController
static class DemoController {
@GetMapping("/ok")
String ok() {
System.out.println(" handler ok()");
return "ok";
}
@GetMapping("/boom")
String boom() {
System.out.println(" handler boom() throws");
throw new IllegalStateException("boom");
}
@ExceptionHandler(IllegalStateException.class)
String onIllegalState(IllegalStateException ex) {
System.out.println(" @ExceptionHandler onIllegalState");
return "handled: " + ex.getMessage();
}
}
// in main
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new DemoController())
.addInterceptors(new Named("A"), new Named("B"), new Named("C"))
.build();
1) GET /ok
preHandle A -> true
preHandle B -> true
preHandle C -> true
handler ok()
postHandle C (mv=null)
postHandle B (mv=null)
postHandle A (mv=null)
afterCompletion C (ex=null)
afterCompletion B (ex=null)
afterCompletion A (ex=null)
=> status=200, body="ok"
2) GET /ok?block=B
preHandle A -> true
preHandle B -> false
afterCompletion A (ex=null)
=> status=403, body=""
3) GET /boom
preHandle A -> true
preHandle B -> true
preHandle C -> true
handler boom() throws
@ExceptionHandler onIllegalState
afterCompletion C (ex=null)
afterCompletion B (ex=null)
afterCompletion A (ex=null)
=> status=200, body="handled: boom"
Case 2 is the diagram exactly: only A got afterCompletion. In case 3 postHandle was never called, and although an exception was thrown, the ex passed to afterCompletion is null. The reasons follow.
Easy to get wrong
postHandleis not called when there is an exception.applyPostHandlesits on the line after the handler call inside the innertryofdoDispatch()(lines 963–970), so it is skipped when the handler throws. Cleanup that must always happen belongs inafterCompletion.ex == nullinafterCompletiondoes not mean nothing was thrown. If an exception resolver handled the exception, as in case 3,nullis passed (lines 1058–1061). The parameter's Javadoc says so as well: it "does not include exceptions that have been handled through an exception resolver" (lines 150–151). In the example of Part 21 nobody handled the exception, which is whyexcarried it there.- For a
@ResponseBodyhandler, theModelAndViewgiven topostHandleisnull. That is themv=nullin case 1 (@RestControlleris the same as putting@ResponseBodyon every method). The response body has already been written inside the handler adapter and there is no view to render. The end of section 5 comes back to this. - Exceptions thrown by
afterCompletionare swallowed.triggerAfterCompletionwraps each interceptor intry/catch (Throwable), logs, and moves on to the next one (lines 174–179), because one interceptor failing to clean up must not stop the others from being called. Exceptions thrown bypreHandleandpostHandle, on the other hand, are inside the innertryand so take the same road as an exception thrown by the handler: section 3.
3. When an exception is thrown — three HandlerExceptionResolvers
The exception is stored in a variable, not thrown
When the handler (or an interceptor's preHandle/postHandle, or argument preparation) throws, doDispatch() does not let the exception escape right away. It stores it in a variable called dispatchException (lines 972–974) and hands it to processDispatchResult(), which calls processHandlerException() if there is an exception (lines 1028–1038). The inside of that method is what this section is about.
protected @Nullable ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
@Nullable Object handler, Exception ex) throws Exception {
// ...
// Check registered HandlerExceptionResolvers...
ModelAndView exMv = null;
if (this.handlerExceptionResolvers != null) {
for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
exMv = resolver.resolveException(request, response, handler, ex);
if (exMv != null) {
break;
}
}
}
if (exMv != null) {
if (exMv.isEmpty()) {
request.setAttribute(EXCEPTION_ATTRIBUTE, ex);
return null;
}
// ...
WebUtils.exposeErrorRequestAttributes(request, ex, getServletName());
return exMv;
}
throw ex;
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java, lines 1207–1256
HandlerExceptionResolver is an interface with a single method, resolveException (lines 51–52), and its return value has three meanings.
| Returned value | Meaning | What DispatcherServlet does |
|---|---|---|
null |
not an exception for me | asks the next resolver |
an empty ModelAndView (no view, no model) |
handled, and I already wrote the response | ends without rendering a view (return null) |
a ModelAndView with a view |
handled; please render this error page | renders that view |
If nobody answers, the last line runs: throw ex. The outer catch of doDispatch() receives it, calls the interceptors' afterCompletion (this time with the exception) and throws it again (lines 982–984, lines 1357–1364), and FrameworkServlet.processRequest() wraps it in a ServletException and lets it out to the servlet container (lines 1007–1009).
Who fills the list
At startup DispatcherServlet collects every bean of type HandlerExceptionResolver from the container and sorts them. If there are none, it creates the three defaults named in DispatcherServlet.properties (lines 591–624; this "the beans if there are any, otherwise the defaults" scheme was covered in section 3 of Part 21).
org.springframework.web.servlet.HandlerExceptionResolver=org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver,\
org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver,\
org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver
With @EnableWebMvc the route is slightly different. The configuration class WebMvcConfigurationSupport registers one bean named handlerExceptionResolver, and it is a HandlerExceptionResolverComposite (lines 984–997). As the name says, it is a resolver that holds several resolvers, asks them in turn and returns the first answer (lines 73–85). Inside it are the same three, in the same order.
protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers,
ContentNegotiationManager mvcContentNegotiationManager) {
ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
// ...
exceptionHandlerResolver.afterPropertiesSet();
exceptionResolvers.add(exceptionHandlerResolver);
ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
responseStatusResolver.setMessageSource(this.applicationContext);
exceptionResolvers.add(responseStatusResolver);
exceptionResolvers.add(new DefaultHandlerExceptionResolver());
}
Either way the order is the same: the @ExceptionHandler methods we wrote first, the handling Spring decided in advance last.
| Order | Resolver | Exceptions it takes | What it does |
|---|---|---|---|
| ① | ExceptionHandlerExceptionResolver |
exceptions for which a matching @ExceptionHandler method exists |
finds that method and calls it (section 4) |
| ② | ResponseStatusExceptionResolver |
ResponseStatusException, or exceptions whose class carries @ResponseStatus |
response.sendError() with that status code (lines 69–92, and from line 146) |
| ③ | DefaultHandlerExceptionResolver |
exceptions Spring MVC itself throws: a missing required parameter, a failed type conversion, an unsupported HTTP method, no handler found, and so on | sendError() with the status code fixed for each exception (400, 405, 404 …) (lines 187–262) |
② and ③ write the status code to the response themselves and return an empty ModelAndView: the second row of the earlier table, "handled, nothing to render". The reason for this order shows when you read the table from the bottom up. ③ is the default for when nobody did anything special, ② is a declaration written on the exception class ahead of time, and ① is code this particular application wrote. Whoever has the more specific intent gets the first chance.
Example 2, first and last parts: checking the list, then ② and ③
This is ex/u21/ExceptionHandlerPickExample.java (run with Spring Framework 7.0.9). It first starts a container from an empty configuration that carries only @EnableWebMvc and prints the HandlerExceptionResolver beans, and then sends requests to a controller that has no @ExceptionHandler at all. Parts 2) and 3) in the middle belong to section 4.
// An example written for this post (not Spring's code)
@ResponseStatus(HttpStatus.NOT_FOUND)
static class OrderNotFoundException extends RuntimeException {
}
@RestController
static class NoHandlerController {
@GetMapping("/orders")
String order(@RequestParam int id) {
throw new OrderNotFoundException();
}
@GetMapping("/unknown")
String unknown() {
throw new UnsupportedOperationException("nobody handles this");
}
}
1) HandlerExceptionResolver beans registered by @EnableWebMvc
handlerExceptionResolver = HandlerExceptionResolverComposite
order = 0
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
4) With no @ExceptionHandler at all
GET /orders?id=1 -> status=404, body="", resolvedException=OrderNotFoundException
[main] WARN org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver - COMMONS-LOGGING Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'id' for method parameter type int is not present]
GET /orders -> status=400, body="", resolvedException=MissingServletRequestParameterException
[main] WARN org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver - COMMONS-LOGGING Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: "abc"]
GET /orders?id=abc -> status=400, body="", resolvedException=MethodArgumentTypeMismatchException
GET /unknown -> perform() threw: ServletException, cause=UnsupportedOperationException
The two [main] WARN lines are not printed by the example; they are the log of DefaultHandlerExceptionResolver. (As noted at Example 1, the two Hibernate Validator version lines are left out.) The first request threw an exception carrying @ResponseStatus, so ② turned it into a 404. The second and third failed while the arguments were being prepared, before the controller method was even entered, and ③ turned them into 400s. The last one, UnsupportedOperationException, belongs to none of the three, so it went all the way to throw ex and came out wrapped in a ServletException.
Easy to get wrong
- If an
@ExceptionHandlermethod itself throws, processing continues with the original exception. ① wraps the call to the handler method in atry; on failure it logs a warning and returnsnull(lines 466–477).nullmeans "not mine", so the original exception moves on to ② and ③. The new exception does not become the response. - An exception thrown while rendering the view does not go to the resolvers.
render()is called insideprocessDispatchResult(), and that call is outside the innertrythat fillsdispatchException(line 980). A rendering exception therefore lands in the outercatch, which only callsafterCompletionand throws it on. - The response is tidied a little before the resolvers are asked. At its very top
processHandlerException()clears theContent-TypeandContent-Dispositionheaders and the body buffer of the response (lines 1210–1221). The comment gives the reason: success and error responses may use different content types. If the response has already started going out (IllegalStateException), it just carries on.
4. How an @ExceptionHandler method is picked
ExceptionHandlerExceptionResolver has two jobs: find the method that fits this exception, and call it.
Search order: the controller itself → @ControllerAdvice
@ControllerAdvice marks a class as "a place to collect @ExceptionHandler methods (and a few other things) that apply to many controllers". Its definition carries @Component (lines 75–79), so component scanning picks it up and makes it a bean (how annotations on annotations are read is in Part 13). The name resembles AOP's advice, but no proxy is involved. As the code below shows, the resolver simply searches a list.
protected @Nullable ServletInvocableHandlerMethod getExceptionHandlerMethod(
@Nullable HandlerMethod handlerMethod, Exception exception, ServletWebRequest webRequest) {
// ...
Class<?> handlerType = null;
if (handlerMethod != null) {
// Local exception handler methods on the controller class itself.
// To be invoked through the proxy, even in case of an interface-based proxy.
handlerType = handlerMethod.getBeanType();
ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent(
handlerType, ExceptionHandlerMethodResolver::new);
for (MediaType mediaType : acceptedMediaTypes) {
ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
if (mappingInfo != null) {
// ...
return new ServletInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
}
}
// ...
}
for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
ControllerAdviceBean advice = entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {
ExceptionHandlerMethodResolver resolver = entry.getValue();
for (MediaType mediaType : acceptedMediaTypes) {
ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType);
if (mappingInfo != null) {
// ...
return new ServletInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod(), this.applicationContext);
}
}
}
}
return null;
}
The yellow boxes are the two caches. Once the green box on the left is reached, the right side is never looked at.
- The controller class itself.
@ExceptionHandlermethods are looked for in the class of the handler method that threw. OneExceptionHandlerMethodResolveris built per class and kept inexceptionHandlerCachefor reuse (lines 119–120). If something is found here, it is returned immediately. - The
@ControllerAdvicebeans. Only reached when step 1 found nothing.exceptionHandlerAdviceCacheis filled once, when the resolver is initialized: every bean annotated with@ControllerAdviceis found and sorted by order (ControllerAdviceBean.findAnnotatedBeans, lines 248–266), and those that have@ExceptionHandlermethods are put in (lines 303–321). The map is aLinkedHashMap, so it is walked in insertion order, which is the sorted order (lines 122–123). With several advice classes, the one whose@Ordercomes first gets the first chance. - For each advice,
isApplicableToBeanTypechecks whether it applies to this controller. This filters out advice whose target was narrowed with@ControllerAdvice(basePackages = …),assignableTypesorannotations(lines 203–205, lines 273–280).
Inside one class: the closest type
Choosing a method within a single class is the job of ExceptionHandlerMethodResolver in the spring-web module. Its constructor finds all @ExceptionHandler methods of the class and builds a "(exception type, media type) → method info (ExceptionHandlerMappingInfo)" table (mappedMethods) (lines 93–105). Which exceptions a method handles is read first from the types written in the annotation and, if none are written, from the method parameters of type Throwable (lines 113–125). If two methods claim the same exception type (with the same media type), it fails right there with an IllegalStateException ("Ambiguous @ExceptionHandler method mapped for …") (lines 144–150).
The lookup goes like this.
public @Nullable ExceptionHandlerMappingInfo resolveExceptionMapping(Throwable exception, MediaType mediaType) {
ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exception.getClass(), mediaType);
if (mappingInfo == null) {
Throwable cause = exception.getCause();
if (cause != null) {
mappingInfo = resolveExceptionMapping(cause, mediaType);
}
}
return mappingInfo;
}
// ...
private ExceptionHandlerMappingInfo getMappedMethod(Class<? extends Throwable> exceptionType, MediaType mediaType) {
List<ExceptionMapping> matches = new ArrayList<>();
for (ExceptionMapping mappingInfo : this.mappedMethods.keySet()) {
if (mappingInfo.exceptionType().isAssignableFrom(exceptionType) && mappingInfo.mediaType().isCompatibleWith(mediaType)) {
matches.add(mappingInfo);
}
}
if (!matches.isEmpty()) {
if (matches.size() > 1) {
matches.sort(new ExceptionMappingComparator(exceptionType, mediaType));
}
return Objects.requireNonNull(this.mappedMethods.get(matches.get(0)));
}
else {
return NO_MATCHING_EXCEPTION_HANDLER;
}
}
getMappedMethod scans the table and collects every entry that can accept the thrown exception (isAssignableFrom). If an IllegalStateException was thrown, the entries for IllegalStateException, RuntimeException and Exception are all candidates. With more than one candidate they are sorted and the first is used. The first sort criterion is ExceptionDepthComparator.
private int getDepth(Class<?> declaredException, Class<?> exceptionToMatch, int depth) {
if (exceptionToMatch.equals(declaredException)) {
// Found it!
return depth;
}
// If we've gone as far as we can go and haven't found it...
if (exceptionToMatch == Throwable.class) {
return Integer.MAX_VALUE;
}
return getDepth(declaredException, exceptionToMatch.getSuperclass(), depth + 1);
}
spring-core/src/main/java/org/springframework/core/ExceptionDepthComparator.java, lines 64–74
Starting from the class of the thrown exception, it climbs one superclass at a time and counts the steps until it meets the declared type. Seen from IllegalStateException, the class itself is 0, RuntimeException is 1, Exception is 2. The fewest steps win: the closest ancestor is picked. It gives the same result as writing Java catch blocks from the most specific exception down, without depending on the order in which the methods are written.
Two more things to note.
- The result is cached. So that the table is not scanned and sorted again for every exception, the result goes into
lookupCache(aConcurrentLruCacheholding up to 24 entries) (lines 84–85, lines 221–224). Even "no method matches" is cached, as the marker valueNO_MATCHING_EXCEPTION_HANDLER. - If nothing is found, it tries again with the cause. That is the second half of
resolveExceptionMappingabove. It only goes down togetCause()when no method matches the type of the thrown exception.
The mediaType in the code is there for @ExceptionHandler(produces = …): when a method declares what kind of response it produces, the method matching what the request asks for can be chosen (this method's Javadoc says @since 6.2, lines 183–190). Without produces, a method is registered for every type (MediaType.ALL) and the choice is made on the exception type alone.
Example 2, the middle part: who wins
Parts 2) and 3) of the same example.
// An example written for this post (not Spring's code)
@RestController
static class LocalController { // has a handler for RuntimeException inside the controller
@GetMapping("/local")
String local() { throw new IllegalStateException("from /local"); }
@ExceptionHandler(RuntimeException.class)
String localRuntime(RuntimeException ex) { return "LocalController.localRuntime"; }
}
@RestController
static class PlainController { // no handler methods inside the controller
@GetMapping("/plain/state")
String state() { throw new IllegalStateException("from /plain/state"); }
@GetMapping("/plain/io")
String io() throws IOException { throw new FileNotFoundException("from /plain/io"); }
}
@RestController
static class CauseController { // only handles IOException, but throws an IllegalStateException that wraps one
@GetMapping("/cause")
String cause() { throw new IllegalStateException("wrapper", new IOException("root cause")); }
@ExceptionHandler(IOException.class)
String localIo(IOException ex) { return "CauseController.localIo"; }
}
@ControllerAdvice
static class GlobalAdvice {
@ExceptionHandler(IllegalStateException.class)
@ResponseBody
String adviceIllegalState(IllegalStateException ex) { return "GlobalAdvice.adviceIllegalState"; }
@ExceptionHandler(Exception.class)
@ResponseBody
String adviceException(Exception ex) { return "GlobalAdvice.adviceException"; }
}
2) Picking inside GlobalAdvice (ExceptionHandlerMethodResolver)
IllegalStateException -> adviceIllegalState
FileNotFoundException -> adviceException
RuntimeException(cause=IllegalStateException) -> adviceException
3) Inside the controller vs @ControllerAdvice
GET /local -> status=200, body="LocalController.localRuntime", resolvedException=IllegalStateException
GET /plain/state -> status=200, body="GlobalAdvice.adviceIllegalState", resolvedException=IllegalStateException
GET /plain/io -> status=200, body="GlobalAdvice.adviceException", resolvedException=FileNotFoundException
GET /cause -> status=200, body="CauseController.localIo", resolvedException=IllegalStateException
Part 2) creates new ExceptionHandlerMethodResolver(GlobalAdvice.class) directly and asks its resolveMethod; part 3) puts the three controllers and the advice into MockMvc and sends requests.
Easy to get wrong
- "The closest type" only holds inside one class. Look at
/local: the advice has a method for exactlyIllegalStateException, and yet the controller's ownRuntimeExceptionmethod won. The search order is "controller → advice", and a hit in the controller ends the search. Distance is compared afterwards, among the candidates of a single class only. The same goes for several advice classes: anExceptionmethod in an earlier advice is picked before a more specific method in a later one. - Inside the controller, even the cause is tried before moving on to the advice.
/causeis that case. What was thrown is anIllegalStateException, and the advice has a method for that type, but the controller's own resolver tried again with the cause, anIOException, and pickedlocalIo. One call toresolveExceptionMappingcontains both "by type" and "then by cause". - The cause is only consulted "when nothing was found". In the third line of part 2), the cause of the
RuntimeExceptionis anIllegalStateException, and stilladviceExceptionwas picked.RuntimeExceptionitself matches theExceptionmethod, so the lookup never went down to the cause. Once a class has a method forException, lookup by cause never happens in that class.
Calling it: the same machinery as a controller method
The method that was found is wrapped in a ServletInvocableHandlerMethod and called (lines 430–464). That is the very class Part 22 used to call controller methods, and the lists of argument resolvers and return value handlers are attached the same way. This is why an @ExceptionHandler method can take an HttpServletRequest as an argument, write a body through @ResponseBody or ResponseEntity, or return a view name as a string. The exception object is passed along with its whole chain of causes as "provided arguments" (lines 454–463), and whichever fits the parameter type is used. That is how localIo(IOException ex) in /cause received the inner IOException rather than the outer IllegalStateException.
What happens after the call connects back to the table in section 3. If the method wrote the response itself (mavContainer.isRequestHandled()), an empty ModelAndView is returned; otherwise a ModelAndView carrying the view name and model (lines 479–495).
@RestControllerAdvice is @ControllerAdvice plus @ResponseBody (lines 46–51). It amounts to putting the @ResponseBody that GlobalAdvice above repeats on every method onto the class once.
ResponseEntityExceptionHandler and ProblemDetail (briefly)
Resolver ③ of section 3, DefaultHandlerExceptionResolver, only sets a status code and leaves the body empty. For a REST API you will want errors to come back with a JSON body as well. ResponseEntityExceptionHandler exists for that. It is an abstract class with one @ExceptionHandler method that lists almost the same standard exceptions ③ takes (lines 116–138); extend it, put @ControllerAdvice on your subclass, and those exceptions are caught in ① before they ever reach ③ and turned into a ResponseEntity with a body. The body is a ProblemDetail. The source comment calls it a representation of an RFC 9457 "problem detail", and it is a simple object with the fields type, title, status, detail and instance (ProblemDetail, line 31, lines 54–67). The common interface of exceptions that can offer a status code together with a ProblemDetail is ErrorResponse (lines 47–71).
5. Views — from one name to a page
render(): name to view, then ask the view to draw
Whether the handler finished normally or an exception resolver chose an error page, if there is a ModelAndView, processDispatchResult() calls render() (lines 1040–1046).
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
// ...
View view;
String viewName = mv.getViewName();
if (viewName != null) {
// We need to resolve the view name.
view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
if (view == null) {
throw new ServletException("Could not resolve view with name '" + mv.getViewName() +
"' in servlet with name '" + getServletName() + "'");
}
}
// ...
view.render(mv.getModelInternal(), request, response);
// ...
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java, lines 1267–1313
Two steps. If there is a view name, resolveViewName() turns it into a view object (if a view object was put in directly instead of a name, that is used), and then the model is handed to that view's render(). The name-to-view half has a familiar shape too.
private @Nullable View resolveViewNameInternal(String viewName, Locale locale) throws Exception {
if (this.viewResolvers != null) {
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
return view;
}
}
}
return null;
}
spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java, lines 1345–1355
ViewResolver is an interface with one method, resolveViewName(viewName, locale) (line 55), and the agreement is "return null for a name you do not know, so the next resolver can try" (lines 42–46). The list is filled the same way as the exception resolvers: collect the ViewResolver beans in the container and sort them, and if there are none use the default, a single InternalResourceViewResolver (lines 656–688).
The grey path on the left is the @ResponseBody path. The end of this section explains it.
Three view resolvers
| View resolver | How it turns a name into a view | For an unknown name |
|---|---|---|
InternalResourceViewResolver |
creates an InternalResourceView whose address is prefix + name + suffix; meant for JSPs |
never returns null; it always creates a view |
BeanNameViewResolver |
if a bean with that name exists in the container and is a View, returns that bean (lines 67–82) |
null |
ContentNegotiatingViewResolver |
creates no views itself; it asks the other resolvers for candidates and picks the view that fits the media type the request wants (lines 218–228) | null |
The work of the one you meet most, InternalResourceViewResolver, lives in its parent class UrlBasedViewResolver.
protected @Nullable View createView(String viewName, Locale locale) throws Exception {
// ...
// Check for special "redirect:" prefix.
if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
// ...
}
// Check for special "forward:" prefix.
if (viewName.startsWith(FORWARD_URL_PREFIX)) {
String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
InternalResourceView view = new InternalResourceView(forwardUrl);
return applyLifecycleMethods(FORWARD_URL_PREFIX, view);
}
// Else fall back to superclass implementation: calling loadView.
return super.createView(viewName, locale);
}
// ...
protected AbstractUrlBasedView buildView(String viewName) throws Exception {
AbstractUrlBasedView view = instantiateView();
view.setUrl(getPrefix() + viewName + getSuffix());
// ...
return view;
}
- A name starting with
redirect:yields aRedirectViewfor the rest of the string; one starting withforward:yields anInternalResourceViewwithout prefix or suffix.return "redirect:/list";works in a controller because the view resolver recognizes this prefix. - For any other name,
buildView()creates a view whose address is prefix + view name + suffix. With prefix/WEB-INF/views/and suffix.jsp,"hello"becomes/WEB-INF/views/hello.jsp. - A view, once created, is cached. One level further up,
AbstractCachingViewResolverkeeps it in a map keyed by "view name + locale" (lines 170–200), with a default limit of 1024 entries (line 49). A view object is stateless, it only knows where to hand over, so there is no reason to build a new one per request.
View.render(): copy the model into the request, then forward
AbstractView.render(), in the common parent of the view implementations, merges static attributes and the like into the model and then calls an abstract method, renderMergedOutputModel() (lines 296–308); each kind of view fills that method in differently. InternalResourceView, the one for JSPs, does this.
protected void renderMergedOutputModel(
Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
// Expose the model object as request attributes.
exposeModelAsRequestAttributes(model, request);
// ...
// Determine the path for the request dispatcher.
String dispatcherPath = prepareForRendering(request, response);
// Obtain a RequestDispatcher for the target resource (typically a JSP).
RequestDispatcher rd = getRequestDispatcher(request, dispatcherPath);
// ...
// If already included or response already committed, perform include, else forward.
if (useInclude(request, response)) {
// ...
rd.include(request, response);
}
else {
// ...
rd.forward(request, response);
}
}
- The model is copied into request attributes. A value added with
model.addAttribute("name", …)becomesrequest.setAttribute("name", …)(AbstractView, lines 430–441). That is why a JSP can read it as${name}: a JSP knows nothing of Spring's model, only servlet request attributes. - It asks the servlet container for the
RequestDispatcherof that address. - It forwards. From here on the HTML is written not by Spring but by the servlet container's JSP engine. (If the request is already running inside an include, or the response has already started going out,
includeis used instead.)
In the end, what Spring MVC does for a view is "hand over the model and decide where to go". Switch the template engine and what changes is the view that implements renderMergedOutputModel() differently and the view resolver that creates it; DispatcherServlet.render() stays as it is.
Example 3: a view name becoming a forward address
This is ex/u21/ViewForwardExample.java (run with Spring Framework 7.0.9). The fake response of MockMvc records the address it was forwarded to, so you can see "where it tried to hand over" without a JSP engine.
// An example written for this post (not Spring's code)
@Controller
static class PageController {
@GetMapping("/hello")
String hello(Model model) {
model.addAttribute("name", "namalzi");
return "hello"; // a view name
}
@GetMapping("/api/hello")
@ResponseBody
String apiHello() {
return "hello"; // the same string, but the response body
}
@GetMapping("/old")
String old() {
return "redirect:/hello";
}
}
// in main
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PageController())
.setViewResolvers(new InternalResourceViewResolver("/WEB-INF/views/", ".jsp"))
.build();
// ... part 2) uses new InternalResourceViewResolver() — no prefix, no suffix
1) prefix /WEB-INF/views/ + view name + suffix .jsp
GET /hello
viewName = hello
forwardedUrl = /WEB-INF/views/hello.jsp
redirectedUrl = null
request attribute name = namalzi
body = ""
GET /api/hello
viewName = (no ModelAndView)
forwardedUrl = null
redirectedUrl = null
request attribute name = null
body = "hello"
GET /old
viewName = redirect:/hello
forwardedUrl = null
redirectedUrl = /hello
request attribute name = null
body = ""
2) InternalResourceViewResolver with no prefix and no suffix
GET /hello -> ServletException: Circular view path [hello]: would dispatch back to the current handler URL [/hello] again. Check your ViewResolver setup! (Hint: This may be the result of an unspecified view, due to default view name generation.)
For /hello, the view name hello was forwarded to /WEB-INF/views/hello.jsp and the model's name is in the request attributes. The body is empty because in this fake environment no JSP actually runs.
The three ways a view name turns into an address. The addresses and the exception are the real output of Example 3 above.
Why @ResponseBody never reaches a view
/hello and /api/hello return the very same string "hello", yet one became a view name and the other the body. The fork is the return value handler from Part 22. RequestResponseBodyMethodProcessor, which takes care of @ResponseBody, calls mavContainer.setRequestHandled(true) on its first line, before writing the return value to the response through a message converter (lines 194–199). It is a flag saying "I have taken care of this request's response". After the method call, the handler adapter sees the flag and returns null instead of a ModelAndView (RequestMappingHandlerAdapter, lines 1041–1047). Then if (mv != null && …) in processDispatchResult() is false and render() is never called. The "(no ModelAndView)" in the output above and the postHandle … (mv=null) of Example 1 are the same null. Return value handlers are covered in detail in Part 22.
Easy to get wrong
InternalResourceViewResolverhas to stand at the very end of the line. It has no way of knowing in advance whether the JSP file exists, so it creates and returns a view for any name (the default implementation of the existence check,checkResource, always returnstrue,AbstractUrlBasedView, lines 91–93). Put it first and the resolvers behind it never get a turn. The class Javadoc says it too: when chaining view resolvers, this one always needs to be last (lines 37–39).- "Circular view path". That is part 2) of the example. Without a prefix and suffix, the address of the view
hellois the same as the address of the request being handled,/hello. Forwarding to it would call the same handler again, which would forward again, without end, soInternalResourceViewnotices just before forwarding and throws (lines 201–216; the check is switched on byInternalResourceViewResolverwhen it builds the view, line 113). And since this exception is thrown while rendering, it bypasses the exception resolvers and comes straight out, as section 3 said. - A view name can appear even when you set none. If there is a
ModelAndViewbut its view is empty,DispatcherServletderives a default view name from the request address and puts it in (lines 1009–1016). The "Hint" at the end of the error message above points at this case.
What this post left out
Names and locations only. LocaleResolver, which decides the locale (language and region) of a request, and its implementations (package web.servlet.i18n; the default is AcceptHeaderLocaleResolver), plus LocaleChangeInterceptor, which changes the locale from a request parameter. The first lines of render() write this locale to the response (lines 1268–1271). FlashMapManager and SessionFlashMapManager (package web.servlet.support), which carry values once across a redirect through a flash map, and RedirectView, which produces the redirect response. RequestToViewNameTranslator, which makes up a name from the request address when no view name is given. AsyncHandlerInterceptor.afterConcurrentHandlingStarted, which is called instead of postHandle and afterCompletion for asynchronous requests (HandlerExecutionChain, lines 186–200). The older SimpleMappingExceptionResolver, which maps exception class names to view names. shouldApplyTo, which lets a resolver apply to certain handlers only (AbstractHandlerExceptionResolver, from line 206). ViewResolverComposite, in which @EnableWebMvc bundles the view resolvers (WebMvcConfigurationSupport, lines 1109–1134). SmartView and FragmentsRendering, which render several view fragments into one response (DispatcherServlet, lines 1292–1294). And the view technologies other than JSP (the packages freemarker, groovy, script, json, xml and others under web.servlet.view).
Recap
| Concept | One-line summary |
|---|---|
HandlerInterceptor |
Three methods called before (preHandle), after (postHandle) and at the end (afterCompletion) of the handler. It does not wrap the next one; the three are called separately |
HandlerExecutionChain |
The handler + the interceptors that apply to this request. Created anew for each request |
interceptorIndex |
The last position whose preHandle returned true. afterCompletion runs backwards from here, only for those that passed |
MappedInterceptor |
An interceptor + a path condition. The inner interceptor joins the chain only when the path matches |
HandlerExceptionResolver |
Takes an exception and answers with null (not mine), an empty ModelAndView (response already written) or a ModelAndView with a view (render this page) |
| Order of the three defaults | @ExceptionHandler → @ResponseStatus → standard Spring MVC exceptions. Under @EnableWebMvc they sit inside one HandlerExceptionResolverComposite |
ExceptionHandlerExceptionResolver |
Looks in the controller itself first, then walks the @ControllerAdvice beans in sorted order. The method found is called the same way as a controller method |
ExceptionHandlerMethodResolver |
The "(exception type, media type) → method" table of one class. Among several candidates the closest in the inheritance chain wins; if none, it retries with the cause. Results are cached |
ResponseEntityExceptionHandler |
A parent class for @ControllerAdvice that turns the standard exceptions into a ProblemDetail body |
ViewResolver |
View name → view object. Returns null to pass an unknown name on |
InternalResourceViewResolver |
Prefix + name + suffix. Recognizes the redirect: and forward: prefixes. Always answers, so it goes last |
InternalResourceView |
Copies the model into request attributes and calls RequestDispatcher.forward |
@ResponseBody and views |
The return value handler calls setRequestHandled(true) → the adapter returns null → render() is skipped |
What to look at next
With this post we have seen all of Spring MVC on top of servlets: the DispatcherServlet of Part 21, the handler mappings and adapters of Part 22, and the interceptors, exceptions and views here. One shape kept coming back through all three posts: DispatcherServlet holds only the order, and gets the work done by asking the strategy objects in a list, one after another.
The next post is WebFlux, which solves the same problem on an entirely different foundation. Instead of the servlet model, where one thread stays with one request, it is a reactive model in which a small number of threads take turns serving many requests, and where DispatcherServlet stood there is a DispatcherHandler. The structure of handler mapping, adapter and result handling looks alike, but we will see how it changes in a world where everything returns a Mono, along with the style of declaring routes as functions instead of annotations, and WebClient.
- Next: Digging into Spring's Source 24: WebFlux — DispatcherHandler, Functional Endpoints, WebClient
- Previous: Part 22: Handler Mappings and Adapters — How @RequestMapping Methods Are Found and Called, Part 21: DispatcherServlet — How One Request Becomes a Response, Part 20: The spring-web Foundation — HttpMessageConverter, WebApplicationContext, Filters
- Posts this one builds on: Part 11: How @Aspect Gets Discovered and How the Advice Chain Runs (the chain on the AOP side), Part 13: ResolvableType and MergedAnnotations (annotations on annotations)
- Part 1: How I Read It with AI, and the Map