Spring Boot Global Exception Handling: @ControllerAdvice vs HandlerExceptionResolver

Centralized exception handling is an important part of building a clean Spring Boot REST API. Instead of adding try/catch blocks to every controller, Spring provides mechanisms such as @ControllerAdvice, @ExceptionHandler, and HandlerExceptionResolver.

Problems can appear when an application uses both @ControllerAdvice and a custom exception resolver. A common example is expecting @ControllerAdvice to handle a known exception, only to discover that a generic resolver handles it first and returns HTTP 500.

This article explains why this happens and how to configure the two mechanisms correctly.

Using @ControllerAdvice

A typical global exception handler looks like this:

@ControllerAdvice
public class ApiExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler({
        MissingRequestHeaderException.class,
        MissingPathVariableException.class
    })
    public ResponseEntity<Object> handleBadRequest(Exception exception) {

        log.error("Bad request: {}", exception.getMessage());

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body("Missing parameters in the header or path");
    }
}

If a controller requires a header:

@GetMapping("/data")
public String getData(
        @RequestHeader("X-Request-ID") String requestId) {

    return "OK";
}

and the client does not send X-Request-ID, Spring throws:

MissingRequestHeaderException

The expected flow is:

Request
   ↓
Controller
   ↓
MissingRequestHeaderException
   ↓
ApiExceptionHandler
   ↓
400 Bad Request

This keeps exception handling centralized and controllers clean.

Where Does HandlerExceptionResolver Fit?

An application may also contain a generic resolver:

public class UnexpectedExceptionResolver
        extends AbstractHandlerExceptionResolver {

    @Override
    protected ModelAndView doResolveException(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception exception) {

        response.setStatus(
                HttpStatus.INTERNAL_SERVER_ERROR.value()
        );

        return new ModelAndView();
    }
}

This can be useful as a fallback for unexpected exceptions.

The desired behavior is:

Known exception
      ↓
@ControllerAdvice
      ↓
Specific HTTP response


Unknown exception
      ↓
Fallback resolver
      ↓
500 Internal Server Error

The problem appears when the generic resolver executes first.

Why Can the Generic Resolver Win?

Internally, Spring MVC maintains a chain of HandlerExceptionResolver implementations.

@ExceptionHandler methods, including those defined inside @ControllerAdvice, are processed by Spring’s own ExceptionHandlerExceptionResolver.

If a custom resolver handles the exception before Spring reaches the appropriate handler, processing stops.

For example:

MissingRequestHeaderException
           ↓
UnexpectedExceptionResolver
           ↓
500 Internal Server Error

The exception never reaches the intended:

@ExceptionHandler(MissingRequestHeaderException.class)

Make the Generic Resolver a Fallback

If the custom resolver should only handle exceptions that nobody else understands, give it the lowest priority:

@Override
public void extendHandlerExceptionResolvers(
        List<HandlerExceptionResolver> resolvers) {

    UnexpectedExceptionResolver resolver =
            new UnexpectedExceptionResolver(globalProperties);

    resolver.setOrder(Ordered.LOWEST_PRECEDENCE);

    resolvers.add(resolver);
}

The important part is:

resolver.setOrder(Ordered.LOWEST_PRECEDENCE);

This tells Spring that the resolver should have very low priority.

The intended chain becomes:

Exception
   ↓
@ExceptionHandler / @ControllerAdvice
   ↓
Other Spring resolvers
   ↓
Custom fallback resolver

What If @ControllerAdvice Is Not Called at All?

Another possibility is that the handler was never registered as a Spring bean.

For example:

@ControllerAdvice
public class ApiExceptionHandler {

    public ApiExceptionHandler() {
        System.out.println("ApiExceptionHandler registered");
    }
}

If the message does not appear during application startup, investigate component scanning.

Suppose the application contains:

@SpringBootApplication(
    scanBasePackages = {"com.example"}
)

and the exception handler is located under:

com.example.common.exceptions

It should automatically be discovered because Spring scans all subpackages of com.example.

You do not normally need to list every subpackage separately.

Verify the Bean Directly

A more reliable test than logging is checking the Spring context:

@SpringBootTest
class ExceptionHandlerTest {

    @Autowired
    private ApplicationContext context;

    @Test
    void handlerShouldExist() {

        ApiExceptionHandler handler =
                context.getBean(ApiExceptionHandler.class);

        assertNotNull(handler);
    }
}

If Spring throws:

NoSuchBeanDefinitionException

the problem is registration or component scanning rather than exception ordering.

Can We Force Registration?

Yes. As a diagnostic or for applications with unusual scanning rules, the handler can be registered manually:

@Configuration
public class ExceptionConfiguration {

    @Bean
    public ApiExceptionHandler apiExceptionHandler() {
        return new ApiExceptionHandler();
    }
}

However, avoid registering the same handler manually if component scanning already creates it.

Is the Constructor the Problem?

Usually, no.

This constructor is perfectly valid:

public ApiExceptionHandler() {
    log.info("ApiExceptionHandler registered");
}

Problems can occur when the constructor requires another bean:

public ApiExceptionHandler(SomeService service) {
    this.service = service;
}

Spring must be able to find a SomeService bean. Otherwise, bean creation will fail, normally with an error during application startup.

Don’t Forget Servlet Filters

Filters can also interfere with exception handling.

Consider:

try {
    filterChain.doFilter(request, response);
} catch (Exception exception) {
    log.error("Request failed", exception);
}

The exception is caught and not rethrown.

That changes the normal exception propagation flow.

If the filter is only monitoring the request, it may be more appropriate to rethrow the exception:

try {
    filterChain.doFilter(request, response);
} catch (Exception exception) {

    log.error("Request failed", exception);

    throw exception;
}

Whether this is appropriate depends on the purpose of the filter, but it is an important area to inspect when global exception handling behaves unexpectedly.

@ControllerAdvice or @RestControllerAdvice?

For a REST-only application, consider:

@RestControllerAdvice
public class ApiExceptionHandler {
}

@RestControllerAdvice essentially combines:

@ControllerAdvice
@ResponseBody

It makes the intention clearer when all exception responses are JSON.

Recommended Architecture

A clean REST API can use:

             Exception
                 ↓
       @ControllerAdvice
                 ↓
        Known exception?
          /           \
        YES            NO
         ↓              ↓
   Specific 4xx/5xx   Fallback
                       Resolver
                          ↓
                         500

For example:

Missing header       → 400
Invalid parameter    → 400
Resource not found   → 404
Database failure     → 500/503
Unexpected exception → 500

The generic resolver should act as the safety net, not as the primary exception handler.

Debugging Checklist

When @ControllerAdvice is unexpectedly ignored, check:

  1. Is the ApiExceptionHandler bean created?
  2. Is its package included in component scanning?
  3. Is the runtime exception really the type expected?
  4. Does an appropriate @ExceptionHandler exist?
  5. Is another HandlerExceptionResolver executing first?
  6. Does the custom resolver use Ordered.LOWEST_PRECEDENCE?
  7. Is a servlet filter catching and swallowing the exception?

Conclusion

When combining @ControllerAdvice and a custom HandlerExceptionResolver, the cleanest approach is to give them different responsibilities.

Use @ControllerAdvice for known exceptions and meaningful API responses, while keeping the custom resolver as a final fallback for unexpected errors.

The desired behavior is simple:

Known exception
      ↓
@ControllerAdvice
      ↓
Specific response

Unknown exception
      ↓
Fallback resolver
      ↓
Generic 500 response

Understanding component scanning, exception propagation, and resolver ordering makes this setup much easier to debug and keeps the API’s error handling predictable and maintainable.

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back