Request validation and error handling in Spring Boot 3
Bean Validation on DTOs, one @RestControllerAdvice, and ProblemDetail responses that frontends can rely on.
3 min read
On this page
One place for errors
A typical Spring Boot service grows a few controllers, and each one handles bad input slightly
differently. Spring Boot 3 (Spring Framework 6) includes ProblemDetail, an implementation of the
application/problem+json standard (RFC 9457). Combined with Bean Validation and a single
@RestControllerAdvice, every error the API returns has the same shape.
Validate at the edge with records
Request DTOs as Java records keep validation rules next to the fields:
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
public record CreatePaymentRequest(
@NotBlank @Size(max = 20) String policyNumber,
@NotNull @DecimalMin(value = "0.01") @Digits(integer = 13, fraction = 2) BigDecimal amount,
@NotNull @FutureOrPresent LocalDate dueDate,
@NotNull @Valid PayerRequest payer) {
}
public record PayerRequest(
@NotBlank @Size(max = 100) String name,
@NotBlank @Email String email) {
}@Valid on the nested record makes Spring validate payer as well. Add the starter if it is not
there yet:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>The controller only needs @Valid on the body:
@RestController
@RequestMapping("/api/payments")
class PaymentController {
private final PaymentService payments;
PaymentController(PaymentService payments) {
this.payments = payments;
}
@PostMapping
ResponseEntity<PaymentResponse> create(@Valid @RequestBody CreatePaymentRequest request) {
var payment = payments.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}
}Turn on ProblemDetail
Spring can already render its own exceptions (wrong method, unreadable JSON, and so on) as
ProblemDetail. Enable it in application.yml:
spring:
mvc:
problemdetails:
enabled: trueThat property registers Spring Boot's own ResponseEntityExceptionHandler. It is enough for a
service that does not need custom errors.
One advice for everything else
As soon as you need your own exceptions or a different validation response, write one advice that
extends ResponseEntityExceptionHandler. Spring Boot then backs off and uses yours instead, and you
keep the ProblemDetail rendering for all of Spring's built-in exceptions:
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
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) {
var problem = ProblemDetail.forStatusAndDetail(status, "One or more fields are invalid.");
problem.setTitle("Validation failed");
// { "amount": "must be greater than or equal to 0.01", ... }
var errors = new java.util.LinkedHashMap<String, String>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
problem.setProperty("errors", errors);
return ResponseEntity.status(status).body(problem);
}
@ExceptionHandler(NotFoundException.class)
ProblemDetail handleNotFound(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(BusinessRuleException.class)
ProblemDetail handleBusinessRule(BusinessRuleException ex) {
var problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setProperty("code", ex.getCode());
return problem;
}
}A failed request now returns:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/payments",
"errors": {
"amount": "must be greater than or equal to 0.01",
"payer.email": "must be a well-formed email address"
}
}The errors keys use the field path, so a frontend can attach each message to the matching input,
including nested ones such as payer.email.
Business rules are not validation
Annotations are good for shape: required, length, format. Rules that need the database, such as "this policy is already lapsed", belong in the service layer. Throw a dedicated exception there and let the advice translate it:
public class BusinessRuleException extends RuntimeException {
private final String code;
public BusinessRuleException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}A stable code such as POLICY_LAPSED lets clients react programmatically, while detail stays a
human-readable message.
Keep 500s boring
Do not add a catch-all @ExceptionHandler(Exception.class) that returns ex.getMessage(). Internal
messages can contain SQL or class names. Let unexpected errors become a generic 500, log them with
a correlation id, and return that id to the client instead.
Summary
- Validate request records with Bean Validation and
@Valid. - Enable
spring.mvc.problemdetails.enabledfor Spring's own errors. - Extend
ResponseEntityExceptionHandleronce to shape validation and business errors. - Expose stable error codes and field paths; never expose internal messages.