In this example, we will replace Spring's default HandlerExceptionResolvers with a custom one.
Prerequisite
Ordering and customization of default HandlerExceptionResolvers.
Example
Steps
- In WebApplicationInitializer, we will set the
detectAllHandlerExceptionResolvers property of DispatcherServlet to false. Doing so will register just one single bean of type HandlerExceptionResolver and will replace the default ones.
- Create a custom HandlerExceptionResolverComposite which will add ExceptionHandlerExceptionResolver and SimpleMappingExceptionResolver to its list. The desired functionality, in an exception scenario, is: if an @ExceptionHandler method is defined for an exception, then that will process the exception; otherwise a default page will be used (set by SimpleMappingExceptionResolver).
- In an @EnableWebMvc class, register the custom HandlerExceptionResolver with the name DispatcherServlet#HANDLER_EXCEPTION_RESOLVER_BEAN_NAME.
- Create default-error.jsp
- Create a controller class.
WebApplicationInitializer implementation
package com.logicbig.example;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
public class AppInitializer extends
AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return null;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[]{AppConfig.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
@Override
protected DispatcherServlet createDispatcherServlet(WebApplicationContext wac) {
DispatcherServlet ds = new DispatcherServlet(wac);
ds.setDetectAllHandlerExceptionResolvers(false);
return ds;
}
}
Creating a custom HandlerExceptionResolverComposite and registering it as a bean
package com.logicbig.example;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.handler.HandlerExceptionResolverComposite;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import java.util.Arrays;
@EnableWebMvc
@ComponentScan
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Bean(name = DispatcherServlet.HANDLER_EXCEPTION_RESOLVER_BEAN_NAME)
HandlerExceptionResolver customExceptionResolver(ApplicationContext ac) {
ExceptionHandlerExceptionResolver e = new ExceptionHandlerExceptionResolver();
e.setApplicationContext(ac);
e.afterPropertiesSet();
SimpleMappingExceptionResolver s = new SimpleMappingExceptionResolver();
s.setDefaultErrorView("default-error");
s.setDefaultStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value());
HandlerExceptionResolverComposite c = new HandlerExceptionResolverComposite();
c.setExceptionResolvers(Arrays.asList(e, s));
return c;
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/views/", ".jsp");
}
}
src/main/webapp/WEB-INF/views/default-error.jsp<%@ page language="java"
contentType="text/html; charset=ISO-8859-1"
pageEncoding="ISO-8859-1" %>
<html>
<body>
<h3>This is default exception page</h3>
<p>Message: <b>${exception.message}</b></p>
<p>Exception: <b>${exception['class'].name}</b></p>
</body>
</html>
The Controller
package com.logicbig.example;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class ExampleController {
@RequestMapping("/test")
public String handleRequest() throws Exception {
throw new IllegalAccessException();
}
/**
* This will throw MissingPathVariableException with response code 500
* because id != testId
*/
@RequestMapping("/test/{id}")
@ResponseBody
public String handleRequest2(@PathVariable("testId") String id) throws Exception {
return "testId: " + id;
}
@RequestMapping("/test2")
public void handleRequest3() throws Exception {
throw new Exception("test exception 2");
}
@ExceptionHandler
@ResponseBody
public String handleException(IllegalAccessException b) {
return "from @ExceptionHandler: " + b;
}
}
Running the example
To try examples, run embedded Jetty (configured in pom.xml of example project below):
mvn jetty:run
Output
URI: /test/{id}
This should throw a MissingPathVariableException, which will be mapped to default-error.jsp.
URI: /test
This should invoke the handleException(..) method of our controller. This method is annotated with @ExceptionHandler and has a parameter of the matching exception type.
This shows that ExceptionHandlerExceptionResolver has processed the exception as expected.
URI: /test2
The exception thrown in the corresponding controller method is not handled by any @ExceptionHandler, so default-error.jsp will be processed:
Other URIs
On Spring Framework versions before 6.1, /others returns a plain 404 Not Found, since DispatcherServlet#throwExceptionIfNoHandlerFound defaulted to false and an unmatched request was simply handled by the servlet container's own 404 handling, without ever going through a HandlerExceptionResolver. On the current version shown on this page, that flag is always effectively true (the setter was removed as of 7+), so a NoHandlerFoundException is now always raised and flows into the HandlerExceptionResolverComposite above, where it falls through to the SimpleMappingExceptionResolver's default error view — resulting in a 500 response instead.
Integration Test
package com.logicbig.example;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
@SpringJUnitWebConfig(AppConfig.class)
public class ExampleControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvcTester mockMvcTester;
@BeforeEach
public void setup() {
mockMvcTester = MockMvcTester.from(webApplicationContext);
}
@Test
public void test_endpoint() {
assertThat(mockMvcTester.get().uri("/test"))
.hasStatusOk()
.bodyText().isEqualTo(
"from @ExceptionHandler: java.lang.IllegalAccessException");
}
@Test
public void testResolvesToDefaultErrorView() {
assertThat(mockMvcTester.get().uri("/test/4"))
.hasStatus5xxServerError()
.hasViewName("default-error")
.model().extractingByKey("exception")
.asInstanceOf(type(MissingPathVariableException.class))
.extracting(Throwable::getMessage).asString()
.contains("testId");
}
@Test
public void test2_endpoint() {
assertThat(mockMvcTester.get().uri("/test2"))
.hasStatus5xxServerError()
.hasViewName("default-error")
.model().extractingByKey("exception")
.asInstanceOf(type(Exception.class))
.extracting(Throwable::getMessage).asString()
.isEqualTo("test exception 2");
}
@Test
public void others_endpoint() {
assertThat(mockMvcTester.get().uri("/others"))
.hasStatus5xxServerError();
}
}
Output$ mvn clean test -Dtest="ExampleControllerTest" [INFO] Scanning for projects... [INFO] [INFO] -----< com.logicbig.example:replacing-default-exception-resolvers >----- [INFO] Building replacing-default-exception-resolvers 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ war ]--------------------------------- [INFO] [INFO] --- clean:3.2.0:clean (default-clean) @ replacing-default-exception-resolvers --- [INFO] Deleting D:\example-projects\spring-mvc\replacing-default-exception-resolvers\target [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ replacing-default-exception-resolvers --- [INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\replacing-default-exception-resolvers\src\main\resources [INFO] [INFO] --- compiler:3.16.0:compile (default-compile) @ replacing-default-exception-resolvers --- [INFO] Recompiling the module because of changed source code. [INFO] Compiling 3 source files with javac [debug target 25] to target\classes [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ replacing-default-exception-resolvers --- [INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\replacing-default-exception-resolvers\src\test\resources [INFO] [INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ replacing-default-exception-resolvers --- [INFO] Recompiling the module because of changed dependency. [INFO] Compiling 2 source files with javac [debug target 25] to target\test-classes [INFO] [INFO] --- surefire:3.2.5:test (default-test) @ replacing-default-exception-resolvers --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.logicbig.example.ExampleControllerTest [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.195 s -- in com.logicbig.example.ExampleControllerTest [INFO] [INFO] Results: [INFO] [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.435 s [INFO] Finished at: 2026-09-16T02:36:22-05:00 [INFO] ------------------------------------------------------------------------ INFO: Completed initialization in 2 ms INFO: Completed initialization in 1 ms WARNING: No mapping for GET /others INFO: Completed initialization in 3 ms INFO: Completed initialization in 1 ms
Another way to replace default resolvers
Instead of turning off detectAllHandlerExceptionResolvers, there's another way to swap out the defaults: just define your own HandlerExceptionResolver bean and name it exactly "handlerExceptionResolver". That name isn't arbitrary — it's the same bean name Spring internally uses for its own default set of exception resolvers (registered by WebMvcConfigurationSupport). So when your bean uses that name too, it takes the place of the default one instead of being added alongside it.
The advantage of this approach: since detectAllHandlerExceptionResolvers is left at its default (true), Spring is still scanning for every HandlerExceptionResolver bean in the context — not just one specially-named one. So you're free to define several separate resolver beans, and they'll all be picked up, while still having fully replaced Spring's defaults.
Example ProjectDependencies and Technologies Used: - spring-webmvc 7.0.6 (Spring Web MVC)
Version Compatibility: 4.2.0.RELEASE - 7.0.6 Version compatibilities of spring-webmvc with this example: Versions in green have been tested.
- spring-test 7.0.6 (Spring TestContext Framework)
- jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
- junit-jupiter-engine 6.0.3 (Module "junit-jupiter-engine" of JUnit)
- hamcrest 3.0 (Core API and libraries of hamcrest matcher framework)
- assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
- JDK 25
- Maven 3.9.11
|