Validation & Exception Handling

Handling Validation Errors

18 min Lesson 5 of 13

Handling Validation Errors

In the previous lesson you learned how to declare constraints on your request-body objects and activate them with @Valid. But what actually happens when a constraint is violated? Spring throws a MethodArgumentNotValidException, and unless you intercept it you get a raw 400 response with a stack-trace-heavy JSON payload that leaks implementation details and is useless to API consumers. This lesson teaches you to read that exception, extract the per-field messages, and return a clean, structured error response that your clients can actually act on.

What Spring Throws — and Why

When Spring MVC binds a @RequestBody and runs the @Valid check, Bean Validation populates a BindingResult internally. If any constraint fails, Spring immediately raises MethodArgumentNotValidException — a subclass of BindException — before your controller method body even runs. The exception carries the full BindingResult, which is a container of FieldError and ObjectError objects.

FieldError vs ObjectError: A FieldError refers to a single property (e.g. email is blank). An ObjectError (also called a global error) refers to the whole object — typically produced by a class-level constraint. Both are accessible through BindingResult.

Accessing the BindingResult Directly

The simplest way to handle errors without a separate exception handler is to add a BindingResult parameter immediately after the @Valid parameter in your controller method signature. Spring then does not throw — it populates the result and lets your method decide what to do.

import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/users") public class UserController { @PostMapping public ResponseEntity<?> createUser( @Valid @RequestBody CreateUserRequest request, BindingResult bindingResult) { if (bindingResult.hasErrors()) { Map<String, String> errors = new HashMap<>(); for (FieldError error : bindingResult.getFieldErrors()) { errors.put(error.getField(), error.getDefaultMessage()); } return ResponseEntity.badRequest().body(errors); } // happy path return ResponseEntity.ok("User created"); } }

This approach works, but it has a significant drawback: you must duplicate the error-extraction logic in every controller method that receives a validated body. For anything beyond a trivial project, centralising that logic is far better.

Do not mix BindingResult with @ExceptionHandler for the same exception. If you declare a BindingResult parameter, Spring suppresses the exception. Your @ExceptionHandler(MethodArgumentNotValidException.class) will never fire for that endpoint. Pick one strategy per method.

Extracting Errors from MethodArgumentNotValidException

The centralised approach — covered in depth in lessons 6 and 7 — uses @ExceptionHandler. For now, understand the API you will use inside that handler. MethodArgumentNotValidException exposes the same BindingResult:

import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; // Inside an @ExceptionHandler method: public Map<String, String> extractFieldErrors(MethodArgumentNotValidException ex) { return ex.getBindingResult() .getFieldErrors() .stream() .collect(Collectors.toMap( FieldError::getField, FieldError::getDefaultMessage, (first, second) -> first + "; " + second // merge if same field has multiple errors )); }

The merge function in Collectors.toMap handles the case where a single field violates more than one constraint simultaneously — for example a password field that is both too short and lacks an uppercase letter.

Designing a Helpful Error Payload

A well-designed error response answers three questions for the API consumer: What went wrong? Which field caused it? What should I send instead? A flat Map<String, String> of field-to-message pairs is the minimum viable structure. A richer record adds an HTTP status, a timestamp, and a machine-readable error code:

public record ValidationErrorResponse( int status, String error, Map<String, String> fieldErrors, java.time.Instant timestamp ) { public static ValidationErrorResponse of(Map<String, String> fieldErrors) { return new ValidationErrorResponse( 400, "Validation Failed", fieldErrors, java.time.Instant.now() ); } }

A response from this record looks like:

{ "status": 400, "error": "Validation Failed", "fieldErrors": { "email": "must be a well-formed email address", "username": "size must be between 3 and 20" }, "timestamp": "2024-09-15T10:32:01.543Z" }
Use the constraint's own message as-is when possible. The default messages from Jakarta Validation (e.g. "must not be blank", "must be a well-formed email address") are already clear and localised if you configure a MessageSource. Override them only when the default is genuinely confusing in your domain — over-customising creates maintenance overhead.

Localising Error Messages

Bean Validation looks up constraint messages through a MessageSource. Spring Boot auto-configures one that reads from src/main/resources/ValidationMessages.properties (and locale-specific variants like ValidationMessages_ar.properties). Override any default message by adding its key:

# ValidationMessages.properties jakarta.validation.constraints.NotBlank.message=This field is required. jakarta.validation.constraints.Email.message=Please enter a valid email address. jakarta.validation.constraints.Size.message=Must be between {min} and {max} characters.

You can also write inline messages directly on the annotation, which take priority:

@NotBlank(message = "Username cannot be empty") @Size(min = 3, max = 20, message = "Username must be 3–20 characters") private String username;
Interpolation placeholders: Inside annotation message strings, {min}, {max}, and {value} are replaced with the constraint's attribute values at runtime. This avoids magic numbers scattered across error strings.

Handling Constraint Violations on Path Variables and Query Params

When you annotate method parameters directly — rather than a request body — Bean Validation throws ConstraintViolationException (not MethodArgumentNotValidException). You must handle both:

import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolation; // Inside an @ExceptionHandler method: public Map<String, String> extractViolations(ConstraintViolationException ex) { return ex.getConstraintViolations() .stream() .collect(Collectors.toMap( cv -> extractFieldName(cv.getPropertyPath().toString()), cv -> cv.getMessage(), (first, second) -> first + "; " + second )); } private String extractFieldName(String path) { // path looks like "methodName.parameterName" — take the last segment int dot = path.lastIndexOf('.'); return dot >= 0 ? path.substring(dot + 1) : path; }

This is also why your controller class must be annotated with @Validated (not just @Valid on the parameter) when validating path variables and request params — Spring needs the class-level annotation to apply the AOP proxy that intercepts method calls.

Returning the Right HTTP Status

Validation failures are the client's fault, so the correct status is always in the 4xx range:

  • 400 Bad Request — the request body or parameters violate constraints. This is the standard choice for validation errors.
  • 422 Unprocessable Entity — semantically well-formed but logically invalid (e.g. a start date after an end date). Some teams prefer 422 specifically for business-rule violations to distinguish them from format errors.
  • 404 Not Found — do not return this for a missing required field; reserve it for "the resource you asked about does not exist".
Be consistent across your entire API. Mixing 400 and 422 unpredictably forces clients to handle both for the same category of error. Pick one convention and document it. Most teams use 400 for all constraint failures and reserve 422 for domain-level checks.

Summary

When Bean Validation fails, Spring throws either MethodArgumentNotValidException (request body) or ConstraintViolationException (method parameters). Both carry a list of errors you can iterate and convert into field-to-message maps. Return a structured JSON payload with a 400 status, field names, and the constraint messages — either inline per method using BindingResult, or (better) centrally in an exception handler. The next lesson shows you how to write that handler with @ExceptionHandler.