Spring Boot request validation and error responses with ProblemDetail
Validate incoming requests with Bean Validation and return consistent, standards-based error responses using ProblemDetail (RFC 9457) and @RestControllerAdvice.
Why error responses deserve design
Every API eventually rejects a request: a required field is missing, an ID does not exist, or a business rule is violated. If each controller invents its own error format, clients end up writing special cases for every endpoint. A better approach is to validate input in one consistent way and return errors in a single, predictable shape.
Spring Framework 6 and later (used by Spring Boot 3 and newer) supports ProblemDetail, an implementation of RFC 9457 "Problem Details for HTTP APIs". It defines a standard JSON body for errors with the fields type, title, status, detail, and instance, plus any custom properties you need.
The examples in this article target Spring Boot 3.x or later with Java 17+.
Adding Bean Validation
Add the Validation dependency (spring-boot-starter-validation) from Spring Initializr, or to your build file:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Now describe the rules directly on the request DTO. Java records work very well for this:
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank @Email String customerEmail,
@NotBlank @Size(max = 120) String productName,
@Positive int quantity
) {}
Trigger validation with @Valid on the controller parameter:
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
@GetMapping("/{id}")
OrderResponse find(@PathVariable long id) {
return orderService.find(id);
}
}
If validation fails, Spring throws MethodArgumentNotValidException before your method runs, so your service code only ever sees valid data.
Turning on ProblemDetail for built-in errors
Spring Boot can render its own MVC exceptions (unsupported media type, missing parameter, invalid body, and so on) as ProblemDetail automatically:
spring.mvc.problemdetails.enabled=true
A request with an invalid body now returns application/problem+json:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/orders"
}
That is a good start, but clients usually want to know which field failed. Let's customize it.
A global handler with @RestControllerAdvice
Extending ResponseEntityExceptionHandler gives you ProblemDetail handling for all standard Spring MVC exceptions, and lets you override the ones you care about:
import java.net.URI;
import java.util.Map;
import java.util.stream.Collectors;
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.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
error -> error.getField(),
error -> error.getDefaultMessage(),
(first, second) -> first));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, "One or more fields are invalid.");
problem.setTitle("Validation failed");
problem.setType(URI.create("https://example.com/problems/validation"));
problem.setProperty("errors", errors);
return ResponseEntity.status(status).body(problem);
}
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order not found");
problem.setType(URI.create("https://example.com/problems/order-not-found"));
problem.setProperty("orderId", ex.getOrderId());
return problem;
}
}
Returning a ProblemDetail from an @ExceptionHandler is enough: Spring sets the status code and the application/problem+json content type for you. The instance field is filled in with the request path automatically.
A failed validation now produces:
{
"type": "https://example.com/problems/validation",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/orders",
"errors": {
"customerEmail": "must be a well-formed email address",
"quantity": "must be greater than 0"
}
}
Domain exceptions that carry their own status
For exceptions you own, you can also extend ErrorResponseException. The exception then describes its own problem, and Spring renders it without any extra handler:
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class OrderNotFoundException extends ErrorResponseException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super(HttpStatus.NOT_FOUND, problem(orderId), null);
this.orderId = orderId;
}
private static ProblemDetail problem(long orderId) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, "Order " + orderId + " does not exist.");
problem.setType(URI.create("https://example.com/problems/order-not-found"));
return problem;
}
public long getOrderId() {
return orderId;
}
}
Pick one style per project. A central @RestControllerAdvice keeps all error formatting in one file, while ErrorResponseException keeps the error description next to the domain concept.
Validating path variables and query parameters
Constraints are not limited to request bodies. Annotate method parameters directly:
@GetMapping
List<OrderResponse> search(@RequestParam @Size(min = 3) String customer,
@RequestParam(defaultValue = "20") @Max(100) int limit) {
return orderService.search(customer, limit);
}
With Spring Framework 6.1 and later, these method parameter constraints are applied by Spring MVC's built-in method validation, and failures surface as HandlerMethodValidationException, which ResponseEntityExceptionHandler also renders as a 400 problem response.
Custom constraints for business rules
When a rule is reused across DTOs, create your own annotation:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface ValidSku {
String message() default "must be a valid SKU such as ABC-12345";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private static final Pattern SKU = Pattern.compile("^[A-Z]{3}-\\d{5}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || SKU.matcher(value).matches();
}
}
Returning true for null is intentional: combine it with @NotNull when the field is required, so each annotation has a single responsibility.
Practical tips
- Never leak internals. Do not put stack traces or SQL messages in
detail. Log them on the server instead. - Keep
typeURIs stable. Clients can switch on them, so treat them as part of your API contract. - Document errors. If you use springdoc-openapi, describe the problem responses for each endpoint so client teams know what to expect.
- Test the format. A
@WebMvcTestthat posts an invalid body and asserts on$.errors.quantityprotects the contract from accidental changes.
Exercise
Add a ConflictException that is thrown when an order with the same customerEmail and productName already exists today. Map it to HTTP 409 with a dedicated type URI and a conflictingOrderId property, then write a MockMvc test that checks the response body.
