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));
}
  • @Valid cascades into nested objects; @Validated on 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.
💡
Never let a @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;
  }
}
ExceptionStatusTypical cause
MethodArgumentNotValidException400Body failed Bean Validation
ConstraintViolationException400Path variable or query param violated a constraint
HttpMessageNotReadableException400Malformed JSON or wrong content type
BookNotFoundException404Domain lookup returned nothing
OptimisticLockingFailureException409Row 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?
Both, with different jobs. DTO constraints reject malformed requests at the edge; the service enforces business invariants such as uniqueness or state transitions, because those depend on data the DTO cannot see.
Should the response include the rejected value?
Usually not. Echoing input back into error payloads is a small data-leak and log-noise risk; return the field name and message instead, and keep the raw value in server logs.

REST controllers and dependency injection JPA relationships, queries and pagination

Last refreshed 2026-09-18.