Ch. 8 · Spring Boot

Spring Controller Advice and Error Responses

Map Spring Boot exceptions to stable HTTP errors with @RestControllerAdvice and ProblemDetail, without leaking stack traces or internals.

~6 min readintermediateupdated Oct 6, 2026

“How do you handle exceptions in a Spring Boot REST API?” Interviewers ask this to find out whether you design APIs for clients or only for the happy path. The weak answer is “try/catch in each controller.” The better answer describes one central place that turns exceptions into a consistent, documented error format, distinguishes client errors from server errors, and never sends a stack trace to a caller.

Before you start

You should know how @RestController methods map requests, what HTTP 400, 404, 409 and 500 mean, and how Jakarta Bean Validation produces errors for @Valid request bodies. Examples target Spring Boot 3.x, where ProblemDetail is part of Spring Framework 6.

The short answer

Put a @RestControllerAdvice class next to your controllers with @ExceptionHandler methods that map each exception type to a status and a ProblemDetail body (the RFC 9457 application/problem+json format). Throw meaningful domain exceptions from services, map them to 4xx with a stable type and message, and let a final catch-all log unexpected exceptions and return a generic 500. Extending ResponseEntityExceptionHandler makes Spring’s own MVC exceptions (validation, bad JSON, unsupported media type) use the same format.

How it works

When a controller method throws, DispatcherServlet asks its HandlerExceptionResolver chain to handle it. The first resolver, ExceptionHandlerExceptionResolver, looks for an @ExceptionHandler that matches the exception type: first in the controller class itself, then in @ControllerAdvice beans. The closest matching type in the hierarchy wins. The handler’s return value is written exactly like a controller’s return value.

public class OrderNotFoundException extends RuntimeException {
    private final long orderId;
    public OrderNotFoundException(long orderId) {
        super("Order " + orderId + " not found");
        this.orderId = orderId;
    }
    public long orderId() { return orderId; }
}

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

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

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail notFound(OrderNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://api.example.com/problems/order-not-found"));
        pd.setTitle("Order not found");
        pd.setProperty("orderId", ex.orderId());
        return pd;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception ex) {
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
    }
}
java

A request for a missing order now returns:

{
  "type": "https://api.example.com/problems/order-not-found",
  "title": "Order not found",
  "status": 404,
  "detail": "Order 42 not found",
  "instance": "/orders/42",
  "orderId": 42
}
JSON

Spring fills instance from the request path when you leave it unset.

Step-by-step walkthrough

Step 1: Throw domain exceptions, not HTTP exceptions, from services

The service should describe what went wrong in business terms. It should not know about HTTP.

@Service
class OrderService {
    Order cancel(long id) {
        Order order = repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
        if (order.isShipped()) throw new OrderAlreadyShippedException(id);
        return repo.save(order.cancelled());
    }
}
java

Step 2: Map each domain exception to one status

The advice is the single translation table. A conflict with current state is 409; a missing resource is 404.

@ExceptionHandler(OrderAlreadyShippedException.class)
ProblemDetail shipped(OrderAlreadyShippedException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    pd.setType(URI.create("https://api.example.com/problems/order-already-shipped"));
    return pd;
}
java

The type URI is the stable contract clients branch on. Messages may change; types should not.

Step 3: Bring framework errors into the same shape

ResponseEntityExceptionHandler already handles MethodArgumentNotValidException, HttpMessageNotReadableException and others, returning ProblemDetail. Override a hook to add field errors:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request) {
    ProblemDetail pd = ex.getBody();
    pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(e -> Map.of("field", e.getField(), "message", e.getDefaultMessage()))
            .toList());
    return ResponseEntity.status(status).body(pd);
}
java

Alternatively, spring.mvc.problemdetails.enabled=true makes Boot register a default handler that does this for you if you have no advice of your own.

Step 4: Hide the unexpected

The catch-all logs the full exception with a correlation ID and returns a bland 500. Make sure server.error.include-stacktrace and include-message stay at their defaults (never) so Boot’s fallback error page does not leak details either.

Worked scenario

A payments API returns this for a duplicate idempotency key:

{
  "timestamp": "2026-10-06T09:12:44.120+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/payments"
}
JSON

The underlying exception is DataIntegrityViolationException from a unique constraint. Clients retry 500s automatically, so every retry hits the same constraint and the client’s retry budget burns. Worse, a developer once enabled server.error.include-message=always for debugging and the response briefly contained the SQL constraint name and table.

The fix translates the meaningful case and keeps the rest generic:

@ExceptionHandler(DuplicatePaymentException.class)
ProblemDetail duplicate(DuplicatePaymentException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "A payment with this idempotency key already exists");
    pd.setType(URI.create("https://api.example.com/problems/duplicate-payment"));
    pd.setProperty("paymentId", ex.existingPaymentId());
    return pd;
}
java

The service catches the integrity violation for that specific constraint and throws DuplicatePaymentException with the existing ID. Clients receive 409, stop retrying, and can fetch the original payment. Every other DataIntegrityViolationException still becomes a logged 500 with no SQL in the body.

Common mistake

  • Catching Exception in every controller. You duplicate mapping logic and usually return 200 with an error string.
  • Returning 500 for client mistakes. Bad input is 400, missing resources are 404, conflicts are 409. Wrong codes break client retry logic.
  • Leaking ex.getMessage() from unexpected exceptions. Database, file path and library messages are internal detail.
  • Expecting advice to catch filter exceptions. A Spring Security AccessDeniedException thrown in the filter chain is handled by Security’s AuthenticationEntryPoint and AccessDeniedHandler, before DispatcherServlet is involved.
  • Several advice classes with overlapping handlers and no @Order. Which one wins becomes accidental.

Verify the behavior

@WebMvcTest(OrderController.class)
@Import(ApiExceptionHandler.class)
class OrderErrorTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService service;

    @Test
    void missingOrderReturnsProblemDetail() throws Exception {
        when(service.find(42L)).thenThrow(new OrderNotFoundException(42L));

        mvc.perform(get("/orders/42"))
           .andExpect(status().isNotFound())
           .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.type").value("https://api.example.com/problems/order-not-found"))
           .andExpect(jsonPath("$.orderId").value(42));
    }

    @Test
    void unexpectedErrorHidesDetails() throws Exception {
        when(service.find(1L)).thenThrow(new IllegalStateException("db password rejected"));

        mvc.perform(get("/orders/1"))
           .andExpect(status().isInternalServerError())
           .andExpect(jsonPath("$.detail").value("Unexpected error"));
    }
}
java

@WebMvcTest already picks up @ControllerAdvice beans; the explicit @Import documents the dependency.

Follow-up questions

What is the difference between @ControllerAdvice and @RestControllerAdvice? The latter adds @ResponseBody, so return values are serialized rather than treated as view names.

When would you use ResponseStatusException? For quick, local cases in a controller. For a domain error used in several places, a dedicated exception plus advice is clearer.

Can you limit an advice to certain controllers? Yes, with basePackages, assignableTypes or annotations attributes on @RestControllerAdvice.

What about a 404 for an unknown URL? Spring 6.1+ throws NoResourceFoundException, which ResponseEntityExceptionHandler maps to a 404 ProblemDetail. See also the Whitelabel error page guide.

Interview exercise

Design the error contract for a “transfer money” endpoint that can fail because the account does not exist, the balance is insufficient, the request body is invalid, or the database is down. Give the status, the type, and what the client should do for each.

Answer and reasoning

Unknown account: 404, type .../account-not-found, client fixes the account reference and does not retry. Insufficient funds: 422 or 409 (pick one and document it; 422 suits a semantically invalid request), type .../insufficient-funds, with available as an extra property, and no retry. Invalid body: 400 from MethodArgumentNotValidException with an errors array, client corrects fields. Database down: 503 (or 500) with a generic detail and optionally Retry-After, and the client may retry with backoff and an idempotency key. The reasoning: statuses drive client behaviour, especially retries, so they must be deliberate, and type gives a stable machine-readable key while detail stays human-readable.

Continue learning

More in Spring Boot

read ✓Spring Boot · easy

@Controller vs @RestController in Spring Boot

@RestController is @Controller plus @ResponseBody: return values become the response body, not view names. See both styles and the usual bugs.

~6 min readread →
read ✓Spring Boot · mid

Spring Boot Problem Details for APIs

Return RFC 9457 problem details from Spring Boot 3: turn on the built-in handler, map domain exceptions, add fields, and keep internals out.

~6 min readread →
esc