Spring Boot REST Errors with ProblemDetail and Validation
By Uddip Acharjee Shuvo8 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
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:
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:
@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:
spring.mvc.problemdetails.enabled=trueSpring 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:
@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:
@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:
@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:
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:
@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:
@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
@Validon nested ones. - One
@RestControllerAdviceextendingResponseEntityExceptionHandler. - An
errorsextension with field paths for validation failures, in both body and parameter validation. - Stable
typeURIs 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.