@RestControllerAdvice

Declares a class whose @ExceptionHandler methods (and other advice) apply across many controllers, writing results as JSON.

Signature
@RestControllerAdvice(basePackages = "com.example.api")
  • Added in Spring Boot Spring Framework 4.3

Parameters

ParameterTypeDescription
$valueoptionalString[]= {}Alias for basePackages.
$basePackagesoptionalString[]= {}Only advise controllers in these packages and their sub-packages.
$basePackageClassesoptionalClass<?>[]= {}Type-safe alternative to basePackages: the packages of these classes.
$assignableTypesoptionalClass<?>[]= {}Only advise controllers assignable to these types.
$annotationsoptionalClass<? extends Annotation>[]= {}Only advise controllers carrying one of these annotations.
$nameoptionalString= ""The bean name for the advice.

Return value

n/aCombines @ControllerAdvice and @ResponseBody. The class is a bean whose @ExceptionHandler, @InitBinder and @ModelAttribute methods apply to the selected controllers, with return values written to the body.

@RestControllerAdvice is where an API's error handling lives. It is @ControllerAdvice plus @ResponseBody: a component whose @ExceptionHandler methods apply to all controllers (or a chosen subset), with return values written as JSON. One class gives every endpoint the same error format.

Start from ResponseEntityExceptionHandler

Spring MVC throws its own exceptions for missing parameters, unreadable bodies, failed validation, unsupported media types and wrong methods. ResponseEntityExceptionHandler already handles all of them, returning RFC 9457 problem details with the right status. Extend it and override only what you want to change, such as adding field errors to validation failures. Then add handlers for your domain exceptions.

@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler
    ProblemDetail notFound(NotFoundException ex) { ... }
}

If you do not need custom behaviour, spring.mvc.problemdetails.enabled=true makes Spring Boot register such a handler for you. Boot's version backs off as soon as you declare your own ResponseEntityExceptionHandler bean.

Scope and order

Without attributes the advice applies everywhere. basePackages, assignableTypes and annotations narrow it; several selectors are combined with OR. When more than one advice can handle an exception, @Order decides which is asked first, and a controller's own @ExceptionHandler methods always come before any advice.

Mistakes people make

  • A catch-all Exception handler without the base class. It turns Spring's 400s and 405s into 500s. Extending ResponseEntityExceptionHandler keeps those, because their specific handlers are closer matches. The fallback still catches @ResponseStatus exceptions and method security's AccessDeniedException, so rethrow those, as the example does.
  • Two global advice classes with overlapping handlers and no order. Which one wins is then down to bean ordering. Keep one global advice, or order them explicitly.
  • Leaking internals. The fallback handler must log the exception and return a fixed message, never ex.getMessage().
  • Using @ControllerAdvice and returning objects. Without @ResponseBody, return values are treated as view names.

Example

Example · java
import java.net.URI;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

class NotFoundException extends RuntimeException {
    NotFoundException(String message) {
        super(message);
    }
}

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

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

    // Spring MVC's own exceptions (400, 405, 415, ...) are already handled by
    // the base class as problem details. Override one to add field errors.
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
            HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        ProblemDetail problem = ex.getBody();
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> Map.of("field", e.getField(), "message", String.valueOf(e.getDefaultMessage())))
                .toList();
        problem.setProperty("errors", errors);
        return handleExceptionInternal(ex, problem, headers, status, request);
    }

    @ExceptionHandler
    ProblemDetail notFound(NotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setType(URI.create("https://api.example.com/problems/not-found"));
        return problem;
    }

    // Last resort: log everything, tell the client nothing internal.
    @ExceptionHandler
    ProblemDetail unexpected(Exception ex) throws Exception {
        // Rethrowing hands these back to Spring Security (401/403) and to the
        // @ResponseStatus resolver instead of turning them into 500s.
        if (ex instanceof AccessDeniedException || ex instanceof AuthenticationException
                || AnnotatedElementUtils.hasAnnotation(ex.getClass(), ResponseStatus.class)) {
            throw ex;
        }
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong.");
    }
}

When to use it

  • Giving every endpoint the same problem-detail error format
  • Adding field-level validation errors to 400 responses
  • Handling partner API exceptions differently from public API ones

Frequently asked questions

What is the difference between @ControllerAdvice and @RestControllerAdvice?

@RestControllerAdvice adds @ResponseBody, so handler return values are written to the body as JSON. With @ControllerAdvice they would be view names unless each method has @ResponseBody.

Do I need to extend ResponseEntityExceptionHandler?

No, but it is the easiest way to keep correct statuses and problem details for Spring MVC's own exceptions while adding your own handlers.

Does @WebMvcTest load my advice?

Yes. The web slice includes @ControllerAdvice beans, so error handling can be tested alongside the controller.

More examples

Scoping and ordering an advice

basePackages limits the advice to partner controllers, and @Order(HIGHEST_PRECEDENCE) makes it run before a general advice for the same exception types. Several selectors are combined with OR, not AND, so add one only when it should widen the scope.

Example · java
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

class PartnerQuotaExceededException extends RuntimeException {
}

// Only controllers in this package and below, checked before any other advice.
// Selectors are ORed: adding annotations = RestController.class here would
// widen the advice to every @RestController in the application.
@RestControllerAdvice(basePackages = "com.example.partner")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PartnerApiAdvice {

    @ExceptionHandler
    ProblemDetail quota(PartnerQuotaExceededException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
        problem.setDetail("Partner quota exceeded for this hour.");
        return problem;
    }
}

Letting Spring Boot provide the handler

With this property Boot registers a ResponseEntityExceptionHandler bean that renders Spring MVC exceptions as problem details. The error properties keep the fallback /error output free of internals.

Example · properties
# Let Spring Boot register its own ResponseEntityExceptionHandler so
# Spring MVC exceptions are rendered as application/problem+json.
# It backs off if you declare your own subclass, as in the main example.
spring.mvc.problemdetails.enabled=true

# Keep internal details out of the fallback /error responses too.
# These are already the defaults; pinning them stops a profile turning them on.
spring.web.error.include-message=never
spring.web.error.include-stacktrace=never

Checking the advice in a slice test

@WebMvcTest includes @ControllerAdvice beans, so the test exercises the real error path: a blank name fails validation and the response is application/problem+json with 400.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

record NewLabel(@NotBlank String name) {}

@RestController
class LabelController {
    @PostMapping("/api/labels")
    NewLabel create(@Valid @RequestBody NewLabel label) {
        return label;
    }
}

@RestControllerAdvice
class GlobalErrors extends ResponseEntityExceptionHandler {
}

// @WebMvcTest picks up @ControllerAdvice beans automatically.
@WebMvcTest(LabelController.class)
class GlobalErrorsTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void blankNameIsAProblemDetail() {
        assertThat(mvc.post().uri("/api/labels")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"name\":\"\"}"))
                .hasStatus(HttpStatus.BAD_REQUEST)
                .hasContentType(MediaType.APPLICATION_PROBLEM_JSON);
    }
}

Discussion

  • Be the first to comment on this lesson.