Request validation and global error handling
Bean Validation on DTOs, custom constraints, one consistent error shape with @RestControllerAdvice, and ProblemDetail responses.
Validating the boundary
Annotation-based validation runs on the object that arrives from the network, not on your entities. Put the rules on a request DTO, annotate the parameter with @Valid, and let Spring throw before your service code ever runs.
public record CreateBookRequest(
@NotBlank String title,
@NotBlank @Size(max = 64) String author,
@ISBN String isbn,
@PositiveOrZero Integer copies,
@Email String contactEmail,
@Valid AddressRequest address) {}
@PostMapping("/books")
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
}@Validcascades into nested objects;@Validatedon the class is needed for method-level validation of path variables and service arguments.- Constraints belong on the record components of the DTO - the compiler puts them on the field, and Hibernate Validator validates fields by default.
- Group sequences let you enforce one set of rules on create and another on update without duplicating DTOs.
@Valid failure reach the client as a 500. Without a handler it becomes a MethodArgumentNotValidException, and the default Spring error page leaks stack details in development.One error shape with ProblemDetail
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onInvalid(MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setType(URI.create("https://example.com/problems/validation"));
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList());
return pd;
}
@ExceptionHandler(BookNotFoundException.class)
ProblemDetail onMissing(BookNotFoundException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Book not found");
pd.setDetail(ex.getMessage());
return pd;
}
}| Exception | Status | Typical cause |
|---|---|---|
| MethodArgumentNotValidException | 400 | Body failed Bean Validation |
| ConstraintViolationException | 400 | Path variable or query param violated a constraint |
| HttpMessageNotReadableException | 400 | Malformed JSON or wrong content type |
| BookNotFoundException | 404 | Domain lookup returned nothing |
| OptimisticLockingFailureException | 409 | Row changed between read and write |
Set spring.mvc.problemdetails.enabled=true so built-in Spring exceptions also render as RFC 7807 payloads, then extend the same shape for your own exceptions.
Custom constraints
@Documented
@Constraint(validatedBy = SlugValidator.class)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface Slug {
String message() default "must be a lower-case kebab-case slug";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class SlugValidator implements ConstraintValidator<Slug, String> {
private static final Pattern P = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
public boolean isValid(String v, ConstraintValidatorContext ctx) {
return v == null || P.matcher(v).matches();
}
}Return true for null values and let @NotNull decide whether absence is allowed. Mixing the two responsibilities makes constraints you cannot reuse.
FAQ
Where should validation live - controller or service?
Should the response include the rejected value?
Related
REST controllers and dependency injection JPA relationships, queries and pagination
Last refreshed 2026-09-18.