@RestControllerAdvice

Handle exceptions once for every controller with @RestControllerAdvice, scope it when needed, and avoid the catch-all handler traps.

Syntax@RestControllerAdvice(basePackages = "com.example.api") @Order(Ordered.HIGHEST_PRECEDENCE) // tried before other advice class ApiExceptionHandler { @ExceptionHandler ProblemDetail handle(SomeException ex) { ... } }

@RestControllerAdvice marks a bean whose @ExceptionHandler methods apply to every controller, not just the one they sit in. It is @ControllerAdvice plus @ResponseBody, so return values are written as JSON. This is where most of an API's error handling belongs: services throw meaningful exceptions, and one class decides what each one looks like over HTTP.

Order of lookup

When a controller throws, Spring first tries the @ExceptionHandler methods in that controller. Only if none match does it walk the advice beans, in @Order order. The first advice that has any matching handler wins, even if a later advice has a closer match. So a global advice with an Exception handler at high precedence will swallow everything before a narrower advice gets a look in. Give scoped advice a higher precedence than global advice, not the other way round.

Scoping

By default an advice covers all controllers. The attributes narrow it:

  • basePackages or basePackageClasses: controllers in those packages, useful when a public API and an internal admin API need different error formats.
  • assignableTypes: specific controller classes.
  • annotations: controllers carrying a marker annotation of your own.

The catch-all trap

Adding @ExceptionHandler(Exception.class) to guarantee a JSON 500 is reasonable, but it also catches Spring MVC's own exceptions. Malformed JSON, a missing query parameter and a wrong HTTP method all become 500s, and your error rate alarm fires for client mistakes. The fix is to extend ResponseEntityExceptionHandler: its handlers are declared for those exact types, which are closer matches than Exception, so they keep their 4xx codes. The catch-all also wins over @ResponseStatus on your exception classes, because @ExceptionHandler methods are tried before Spring reads that annotation, so an annotated 404 becomes a 500 too. It also catches AccessDeniedException from method security and turns a 403 into a 500; rethrow it so Spring Security answers.

What advice cannot see

Advice only handles exceptions raised inside DispatcherServlet: while it looks up the handler, runs interceptors, binds arguments or calls your method. An exception thrown in a servlet filter, including Spring Security's authentication filters, happens before any controller is chosen and never reaches it. Those failures go to Security's entry point and access denied handler, or to Boot's /error endpoint.

Exceptions that an advice handles are not logged by Spring. If a handler covers unexpected failures, it must log them itself, or a production bug leaves no trace.

Example

Example · java
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 ResourceNotFoundException extends RuntimeException {
    ResourceNotFoundException(String resource, Object id) {
        super(resource + " " + id + " not found");
    }
}

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

// Applies to every @Controller and @RestController in the application.
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler
    ProblemDetail notFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler
    ProblemDetail duplicate(DuplicateResourceException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    }
}

When to use it

  • Map ResourceNotFoundException to 404 once for every controller instead of repeating a handler in each.
  • Give the partner API its own error fields while the public API keeps the standard format.
  • Guarantee a JSON 500 for unexpected failures without turning bad request bodies into 500s.

More examples

Scoping an advice to one package

Partner endpoints report quota errors with an extra <code>resetsAt</code> field; the rest of the API does not need it. <code>basePackages</code> limits the advice to controllers in that package, and <code>HIGHEST_PRECEDENCE</code> makes Spring try it before the global advice, which, without an explicit order, could be tried first and answer with its <code>Exception</code> handler.

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 {
    PartnerQuotaExceededException(String partnerId) {
        super("Partner " + partnerId + " has used its daily quota");
    }
}

// Only controllers under this package get these handlers.
// HIGHEST_PRECEDENCE makes Spring try this advice before the global one.
@RestControllerAdvice(basePackages = "com.example.partners.api")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PartnerApiExceptionHandler {

    @ExceptionHandler
    ProblemDetail quota(PartnerQuotaExceededException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getMessage());
        problem.setProperty("resetsAt", "00:00Z");
        return problem;
    }
}

A catch-all that keeps 4xx codes

Extending <code>ResponseEntityExceptionHandler</code> means a broken JSON body is still a 400 and a <code>DELETE</code> on a read-only route is still a 405. The <code>Exception</code> handler then only sees truly unexpected failures, logs them with the stack trace, and returns a fixed message. Rethrowing <code>AccessDeniedException</code> leaves the request unresolved, so Spring Security's exception filter can answer 401 for anonymous users and 403 for signed-in ones.

Example · java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// Extending ResponseEntityExceptionHandler keeps Spring MVC's own exceptions
// (bad JSON, wrong method, missing parameter) on their correct 4xx status,
// because its handlers are more specific than Exception.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

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

    // Let Spring Security's filters decide between 401 and 403.
    @ExceptionHandler
    void accessDenied(AccessDeniedException ex) {
        throw ex;
    }

    @ExceptionHandler
    ProblemDetail unexpected(Exception ex) {
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "The request could not be completed.");
    }
}

Testing an advice without a Spring context

The standalone builder needs the advice registered explicitly with <code>setControllerAdvice</code>; it does not scan for beans. This keeps the test fast and focused on the mapping. Use <code>@WebMvcTest</code> instead when the advice depends on other beans or on configuration.

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

import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

class CustomerNotFoundException extends RuntimeException {
    CustomerNotFoundException(long id) {
        super("Customer " + id + " not found");
    }
}

@RestController
class CustomerController {
    @GetMapping("/api/customers/{id}")
    String find(@PathVariable long id) {
        throw new CustomerNotFoundException(id);
    }
}

@RestControllerAdvice
class CustomerAdvice {
    @ExceptionHandler
    ProblemDetail notFound(CustomerNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

class CustomerAdviceTest {

    private final MockMvcTester mvc = MockMvcTester.of(List.of(new CustomerController()),
            builder -> builder.setControllerAdvice(new CustomerAdvice()).build());

    @Test
    void adviceTurnsMissingCustomerInto404() {
        assertThat(mvc.get().uri("/api/customers/12"))
                .hasStatus(HttpStatus.NOT_FOUND)
                .bodyJson()
                .extractingPath("$.detail")
                .isEqualTo("Customer 12 not found");
    }
}

Discussion

  • Be the first to comment on this lesson.