Skip to content
All posts

Spring Boot REST Errors with ProblemDetail and Validation

By 8 min read

Ask three Spring Boot APIs for something invalid and you often get three error formats: a Whitelabel page, a JSON blob with a stack trace, and a hand-rolled { "error": "..." }. Every client then needs its own parsing code, and nobody can tell which field was wrong.

RFC 9457 (Problem Details for HTTP APIs, which replaced RFC 7807) defines one JSON shape for errors, and Spring Framework 6 supports it out of the box with the ProblemDetail class. This guide wires Bean Validation and your own exceptions into that format, tests it, and documents it in OpenAPI. It targets Spring Boot 3.

What a problem detail looks like

A request fails @Valid validation, a @RestControllerAdvice builds a ProblemDetail, and the client receives 400 application/problem+json with type, title, status, detail, instance and an errors list
A failed validation becomes a 400 response in the RFC 9457 format, with an errors list added as an extension member.

The response uses the content type application/problem+json and five standard members:

  • type: a URI that identifies the kind of problem. Ideally it points to documentation; the default, about:blank, means “nothing more specific than the status code”.
  • title: a short, human-readable summary of that type.
  • status: the HTTP status code, repeated in the body.
  • detail: an explanation of this particular occurrence.
  • instance: a URI for this occurrence, usually the request path.

Anything else is an extension member. Clients that don’t know an extension ignore it, so adding an errors list for field-level messages is safe.

Validate the request

Put Bean Validation constraints on the request records. Nested records need @Valid so their constraints run too:

CreateOrderRequest.java
public record CreateOrderRequest(
        @Valid @NotNull OrderItem item,
        @Valid @NotNull Customer customer) {}

public record OrderItem(
        @NotBlank String code,
        @NotNull @Positive BigDecimal price,
        @NotNull @Min(value = 1, message = "must be at least 1") Integer quantity) {}

public record Customer(
        @NotBlank String name,
        @NotBlank @Email(message = "must be a valid email") String email) {}

Then ask Spring to validate the body. When validation fails, Spring throws MethodArgumentNotValidException before your method runs:

OrderController.java
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
CreateOrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.createOrder(request);
}

Turn on problem details

The quickest start is one property:

application.properties
spring.mvc.problemdetails.enabled=true

Spring Boot then registers a handler that renders all of Spring MVC’s own exceptions, such as unreadable JSON, a missing parameter, an unsupported media type or failed validation, as problem details. For validation the result is correct but not very useful: "detail": "Invalid request content.", with no word on which fields failed.

Add the field errors

To shape the responses yourself, extend ResponseEntityExceptionHandler in a @RestControllerAdvice. Spring Boot’s built-in handler backs off when it finds yours, so the property above is no longer needed. Override the validation hook and add an errors extension:

ApiExceptionHandler.java
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    record InvalidField(String field, String message) {}

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {

        List<InvalidField> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> new InvalidField(e.getField(), e.getDefaultMessage()))
                .toList();

        ProblemDetail problem = ex.getBody();
        problem.setType(URI.create("https://shop.dev/problems/invalid"));
        problem.setTitle("Invalid request");
        problem.setDetail(errors.size() + " fields are invalid");
        problem.setProperty("errors", errors);

        return handleExceptionInternal(ex, problem, headers, status, request);
    }
}

Field names follow the record structure, so a bad quantity is reported as item.quantity. A client can map that straight onto its form.

Your own exceptions

Domain errors belong in the same format. An @ExceptionHandler can simply return a ProblemDetail; Spring takes the status from it and sets the problem content type:

ApiExceptionHandler.java
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail orderNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, "No order with number " + ex.orderNumber());
    problem.setType(URI.create("https://shop.dev/problems/order-not-found"));
    problem.setTitle("Order not found");
    problem.setProperty("orderNumber", ex.orderNumber());
    return problem;
}

Alternatively, have the exception extend ErrorResponseException and carry its own ProblemDetail; the base handler renders it with no extra code.

Query parameters and path variables

Since Spring Framework 6.1, constraints directly on controller method parameters are validated without @Validated on the class:

OrderController.java
@GetMapping("/api/orders")
List<OrderSummary> list(@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
    return orderService.recent(size);
}

A violation raises HandlerMethodValidationException, which ResponseEntityExceptionHandler also turns into a 400 problem detail. Override handleHandlerMethodValidationException to add the same errors list there.

Never leak a stack trace

Anything you don’t handle should still come back as a problem detail, with the details in the log rather than the response:

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

@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex) {
    log.error("Unhandled exception", ex);
    return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "Something went wrong on our side. Please try again later.");
}

Test the contract

Error responses are part of your API, so test them like any other response:

OrderControllerTest.java
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService;

    @Test
    void rejectsInvalidOrderWithFieldErrors() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                            {"item": {"code": "P100", "price": 34.00, "quantity": 0},
                             "customer": {"name": "Ayesha", "email": "not-an-email"}}
                            """))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.title").value("Invalid request"))
                .andExpect(jsonPath("$.errors[*].field",
                        containsInAnyOrder("item.quantity", "customer.email")));
    }
}

Document it in OpenAPI

With springdoc-openapi, declare the error responses on the operation so clients see the problem format in Swagger UI and in generated clients:

OrderController.java
@Operation(summary = "Place an order")
@ApiResponse(responseCode = "201", description = "Order created")
@ApiResponse(responseCode = "400", description = "Invalid request",
        content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = ProblemDetail.class)))
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
CreateOrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }

Checklist

  • Constraints on request records, with @Valid on nested ones.
  • One @RestControllerAdvice extending ResponseEntityExceptionHandler.
  • An errors extension with field paths for validation failures, in both body and parameter validation.
  • Stable type URIs and titles for your own exceptions.
  • A catch-all that logs and returns a generic 500, never a stack trace.
  • MockMvc tests and OpenAPI annotations for the error responses.