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.

Syntax@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 says firstName. 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

Example · java
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) {
}

@RestControllerAdvice
public class ValidationErrorHandler extends ResponseEntityExceptionHandler {

    @Override
    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);
    }

    @Override
    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.

Example · http
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.