Returning Field Errors to Clients
Turn validation failures into a Problem Details response with a stable list of field errors that front ends can map to their inputs.
@RestControllerAdvice
class Handler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, ...) { ... }
}A 400 that says only "Bad Request" makes every client guess. A front end needs to know which field failed and why, in a format that does not change between releases. Spring gives you the raw material; the response shape is yours to define.
What you get without doing anything
By default a failed @Valid body produces Spring Boot's standard error JSON with status, error and path, and no field details. With spring.mvc.problemdetails.enabled=true the response becomes an RFC 9457 Problem Details document instead, still without the list of fields. server.error.include-binding-errors can add the raw Spring error objects, but those expose internal object names and message codes and are not a contract you want clients to depend on.
Defining the shape
Keep it small and stable: the field path, a machine-readable code, and a human message.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/users",
"errors": [
{ "field": "email", "code": "Email", "message": "must be a well-formed email address" },
{ "field": "items[2].quantity", "code": "Positive", "message": "must be greater than 0" }
]
}The code is the constraint's simple name. Clients can switch on it to show their own translated text, while message is a sensible fallback.
Where to build it
Extend ResponseEntityExceptionHandler in a @RestControllerAdvice and override two methods: handleMethodArgumentNotValid for @Valid bodies and records, and handleHandlerMethodValidationException for constraints on path, query and header parameters, which is also where body errors land when a method mixes both. Overriding only the first is the usual gap. With your own subclass in place, Spring Boot's built-in problem details handler steps aside.
Things that go wrong
- Field names follow Java, not JSON. If a record component is renamed with
@JsonProperty("first_name"), the error still saysfirstName. Keep JSON and Java names aligned in request DTOs, or translate the path. - Do not echo rejected values.
FieldError.getRejectedValue()may be a password or card number, and it ends up in logs and browser tools. - Do not log every 400 at error level. Bad input is the client's problem; logging it loudly buries real failures.
Example
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.method.ParameterErrors;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
record FieldProblem(String field, String code, String message) {
}
public class ValidationErrorHandler extends ResponseEntityExceptionHandler {
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<FieldProblem> errors = new ArrayList<>();
ex.getBindingResult().getFieldErrors().forEach(e -> errors.add(fromField(e)));
for (ObjectError e : ex.getBindingResult().getGlobalErrors()) {
errors.add(new FieldProblem(null, e.getCode(), e.getDefaultMessage()));
}
return respond(ex, ex.getBody(), errors, headers, status, request);
}
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<FieldProblem> errors = new ArrayList<>();
for (ParameterValidationResult result : ex.getParameterValidationResults()) {
if (result instanceof ParameterErrors bodyErrors) {
// A @Valid @RequestBody or record validated in the same pass.
bodyErrors.getFieldErrors().forEach(e -> errors.add(fromField(e)));
} else {
String name = result.getMethodParameter().getParameterName();
for (MessageSourceResolvable e : result.getResolvableErrors()) {
errors.add(new FieldProblem(name, lastCode(e), e.getDefaultMessage()));
}
}
}
return respond(ex, ex.getBody(), errors, headers, status, request);
}
private ResponseEntity<Object> respond(Exception ex, ProblemDetail body, List<FieldProblem> errors,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
body.setDetail("One or more fields are invalid.");
body.setProperty("errors", errors);
return handleExceptionInternal(ex, body, headers, status, request);
}
private static FieldProblem fromField(FieldError e) {
// Rejected value deliberately left out: it may be a password.
return new FieldProblem(e.getField(), e.getCode(), e.getDefaultMessage());
}
private static String lastCode(MessageSourceResolvable e) {
String[] codes = e.getCodes();
return codes == null || codes.length == 0 ? null : codes[codes.length - 1];
}
}
When to use it
- Let a sign-up form highlight every invalid input from a single response.
- Give mobile clients a stable error code they can translate without parsing English text.
- Report a bad page size from a query parameter in the same format as a bad request body.
More examples
What the client sees
Every violation is reported at once, so a form can mark all bad inputs in one round trip rather than one per submit. The order of the errors array is not guaranteed, so clients should look errors up by field rather than by position. The content type is application/problem+json, which lets generic clients recognise the format.
POST /api/users HTTP/1.1
Content-Type: application/json
{"name": " ", "email": "not-an-email"}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/users",
"errors": [
{"field": "name", "code": "NotBlank", "message": "must not be blank"},
{"field": "email", "code": "Email", "message": "must be a well-formed email address"}
]
}
Locking the format in with a test
The error format is part of your API, so test it like one. Front ends break when field turns into fieldName in a refactor, and a test like this catches it in the build. Naming the controller in @WebMvcTest keeps the slice small; @RestControllerAdvice classes are loaded by the slice either way, so the error format under test is the real one. The test sends one invalid field so the position in the array is predictable.
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
record SignUp(@NotBlank String name, @NotBlank @Email String email) {
}
@RestController
class SignUpController {
@PostMapping("/api/users")
void create(@Valid @RequestBody SignUp request) {
}
}
@RestControllerAdvice
class SignUpErrors {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException ex) {
ProblemDetail body = ex.getBody();
body.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "code", String.valueOf(e.getCode())))
.toList());
return body;
}
}
@WebMvcTest({SignUpController.class, SignUpErrors.class})
class SignUpValidationTest {
@Autowired
MockMvcTester mvc;
@Test
void reportsTheBadEmail() {
assertThat(mvc.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Ada", "email": "nope"}
"""))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.extractingPath("$.errors[0].field").isEqualTo("email");
}
}
Mapping service-layer violations too
A @Validated service or a manual Validator call throws jakarta.validation.ConstraintViolationException, which would otherwise be a 500. If spring.validation.method.adapt-constraint-violations=true, the @Validated proxy throws MethodValidationException instead (also a 500 unless handled), so this handler would no longer catch those calls. The property path of a method argument looks like transfer.amount, so the handler keeps only the last node. Only map it to 400 if the input really came from the client; a violation on data your own code built is a bug and deserves a 500.
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Path;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ConstraintViolationHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail onViolation(ConstraintViolationException ex) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"One or more fields are invalid.");
List<Map<String, String>> errors = ex.getConstraintViolations().stream()
.map(v -> Map.of(
"field", leaf(v.getPropertyPath()),
"code", v.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(),
"message", v.getMessage()))
.toList();
body.setProperty("errors", errors);
return body;
}
private static String leaf(Path path) {
String name = "";
for (Path.Node node : path) {
name = node.getName() == null ? name : node.getName();
}
return name;
}
}
Discussion