namalzi

← All posts

Digging into Spring's Source 23: Exception Handling, Interceptors, Views — Before, After, and the End of a Request

Spring · Published

How HandlerInterceptors that run around a controller method are lined up and why only the ones whose preHandle passed get afterCompletion, in what order an exception thrown by a controller travels through the HandlerExceptionResolvers until it reaches an @ExceptionHandler method, and how a single view name goes through ViewResolver and View to end in a forward to a JSP — all checked against the real source and the output of small examples.

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.

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.

Where interceptors, exception handling and views sit in doDispatch. After the handler execution chain is obtained, preHandle is called front to back, and if all return true the handler runs. On a normal return postHandle is called back to front; on an exception processHandlerException asks the list of exception resolvers. Both paths meet in processDispatchResult, which renders the view if there is a ModelAndView, and finally afterCompletion is called back to front. 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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java, lines 681–706

Two things are worth noticing.

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

Interceptors A, B and C when B's preHandle returns false. When A returns true, interceptorIndex becomes 0. When B returns false, triggerAfterCompletion walks backwards from interceptorIndex, which is 0, and calls only A's afterCompletion. B's afterCompletion is not called, and C never even gets preHandle. 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

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

spring-webmvc/src/main/resources/org/springframework/web/servlet/DispatcherServlet.properties, lines 17–19

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());
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java, lines 1033–1063

The order in which an exception passes through the resolvers. processHandlerException walks the resolver list; inside it HandlerExceptionResolverComposite asks ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver and DefaultHandlerExceptionResolver in that order. It stops at the first non-null answer, and if all three return null the exception is thrown again. 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

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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java, lines 509–564

The order in which an @ExceptionHandler method is searched for. First the resolver for the controller class where the exception occurred is taken from a cache and asked by exception type, then again by the cause. If it finds one, the method inside the controller wins. Otherwise the @ControllerAdvice beans are visited in sorted order; for each one that applies to this controller the same lookup is done. If nothing is found, null is returned and the next exception resolver takes over. The yellow boxes are the two caches. Once the green box on the left is reached, the right side is never looked at.

  1. The controller class itself. @ExceptionHandler methods are looked for in the class of the handler method that threw. One ExceptionHandlerMethodResolver is built per class and kept in exceptionHandlerCache for reuse (lines 119–120). If something is found here, it is returned immediately.
  2. The @ControllerAdvice beans. Only reached when step 1 found nothing. exceptionHandlerAdviceCache is filled once, when the resolver is initialized: every bean annotated with @ControllerAdvice is found and sorted by order (ControllerAdviceBean.findAnnotatedBeans, lines 248–266), and those that have @ExceptionHandler methods are put in (lines 303–321). The map is a LinkedHashMap, so it is walked in insertion order, which is the sorted order (lines 122–123). With several advice classes, the one whose @Order comes first gets the first chance.
  3. For each advice, isApplicableToBeanType checks whether it applies to this controller. This filters out advice whose target was narrowed with @ControllerAdvice(basePackages = …), assignableTypes or annotations (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;
		}
	}

spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java, lines 191–246

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

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

How a view name becomes a page. If the ModelAndView is null, render is skipped. Otherwise DispatcherServlet.render asks the view resolvers in turn through resolveViewName; InternalResourceViewResolver adds the prefix and suffix, creates an InternalResourceView and keeps it in a cache. AbstractView.render merges the model, copies the model into request attributes and hands over to the JSP with RequestDispatcher.forward. 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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/view/UrlBasedViewResolver.java, lines 449–582

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);
		}
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/view/InternalResourceView.java, lines 138–173

  1. The model is copied into request attributes. A value added with model.addAttribute("name", …) becomes request.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.
  2. It asks the servlet container for the RequestDispatcher of that address.
  3. 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, include is 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.

UrlBasedViewResolver.createView(viewName), the parent of InternalResourceViewResolver, splits view names three ways. A name starting with redirect: becomes a RedirectView with the rest as the URL — in Example 3, GET /old's redirect:/hello became redirectedUrl = /hello. A name starting with forward: becomes an InternalResourceView with no prefix or suffix added. Any other name goes through buildView, which uses prefix + name + suffix as the URL — GET /hello's hello became forwardedUrl = /WEB-INF/views/hello.jsp, and InternalResourceView.render copies the model into request attributes (name = namalzi) and forwards. With no prefix or suffix, the URL hello resolves to the request URL /hello, giving the ServletException 'Circular view path [hello]' of Example 3, part 2) 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

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.

More posts