@RestControllerAdvice
Declares a class whose @ExceptionHandler methods (and other advice) apply across many controllers, writing results as JSON.
@RestControllerAdvice(basePackages = "com.example.api")- Added in Spring Boot Spring Framework 4.3
Parameters
| Parameter | Type | Description |
|---|---|---|
$valueoptional | String[]= {} | Alias for basePackages. |
$basePackagesoptional | String[]= {} | Only advise controllers in these packages and their sub-packages. |
$basePackageClassesoptional | Class<?>[]= {} | Type-safe alternative to basePackages: the packages of these classes. |
$assignableTypesoptional | Class<?>[]= {} | Only advise controllers assignable to these types. |
$annotationsoptional | Class<? extends Annotation>[]= {} | Only advise controllers carrying one of these annotations. |
$nameoptional | String= "" | 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
Exceptionhandler without the base class. It turns Spring's 400s and 405s into 500s. ExtendingResponseEntityExceptionHandlerkeeps those, because their specific handlers are closer matches. The fallback still catches@ResponseStatusexceptions and method security'sAccessDeniedException, 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
@ControllerAdviceand returning objects. Without@ResponseBody, return values are treated as view names.
Example
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);
}
}
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.
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);
}
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.
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.
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.
# 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.
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