Bean Validation and Consistent Error Responses

Bean Validation lets a Spring Boot API reject malformed input before the controller performs business work. Consistent error responses make that rejection predictable for clients: the same shape, the same field naming rules, and useful messages regardless of which endpoint failed.

In this Web APIs section, validation belongs at the HTTP boundary. It does not replace domain invariants inside services or database constraints, but it does stop obvious request-shape problems close to where JSON is decoded. The outcome is a controller method that receives a trustworthy command object, plus an error format that frontend code, API consumers, and tests can depend on.

How Spring Boot Runs Bean Validation

Bean Validation is the specification behind annotations such as @NotBlank, @Size, @Email, and @Min. Modern Spring Boot applications usually use the Jakarta namespace, so annotations live under jakarta.validation. The implementation commonly present on the classpath is Hibernate Validator, but application code should normally depend on the Bean Validation API rather than implementation-specific types.

The key runtime object is a Validator. Spring Boot auto-configures one when validation support is on the classpath. When Spring MVC sees @Valid or @Validated on a controller argument, it asks that validator to inspect the object after request-body binding has created it. Binding converts JSON into a Java object. Validation then walks the object graph, reads constraint annotations, evaluates each constraint validator, and collects violations.

For @RequestBody arguments, failed validation normally raises MethodArgumentNotValidException. For invalid simple parameters such as @RequestParam @Min(1) int page, Spring may raise a different validation exception. A consistent API handles these related failures in one @RestControllerAdvice and translates them into a stable response body.

Constraint annotations have common attributes. message controls the violation text, groups lets you enable constraints selectively, and payload is metadata for tooling. Nested objects require @Valid on the nested property so the validator descends into that object. Collections also need @Valid on the element position when each item should be validated.

API Anatomy

The usual shape is a request DTO, a controller method, and an exception handler. Keep validation annotations on transport DTOs when the rule describes the HTTP request, such as required JSON fields, string length, or accepted range. Keep business rules in the service or domain model when they require database lookup, ownership checks, state transitions, or cross-aggregate decisions.

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

record SignupRequest(
    @Email @NotBlank String email,
    @NotBlank @Size(min = 8, max = 72) String password
) {}

@RestController
@RequestMapping("/api/signups")
class SignupController {
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    SignupRequest create(@Valid @RequestBody SignupRequest request) {
        return request;
    }
}

In this first example, Spring deserializes the JSON body into SignupRequest. If email is blank or not shaped like an email address, validation fails before create returns. A valid request such as {"email":"a@example.com","password":"correct horse"} receives status 201 with the echoed DTO in this teaching example. A blank email receives a validation error instead of entering application logic.

Consistent Error Shape

Spring Framework includes ProblemDetail, a Java representation of the RFC 7807 problem-details format. It gives every error a status, title, detail, and optional extension properties. For validation, a useful extension property is errors: an array containing field names, rejected values when safe, and messages.

import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

record FieldErrorResponse(String field, String message) {}

@RestControllerAdvice
class ApiValidationErrors {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail handleInvalidBody(MethodArgumentNotValidException ex) {
        List<FieldErrorResponse> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new FieldErrorResponse(error.getField(), error.getDefaultMessage()))
            .toList();

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setType(URI.create("https://example.com/problems/validation-error"));
        problem.setTitle("Request validation failed");
        problem.setDetail("One or more request fields are invalid.");
        problem.setProperty("errors", errors);
        return problem;
    }
}

With that handler, the invalid signup request {"email":"","password":"short"} deterministically produces status 400. The JSON contains title equal to Request validation failed and an errors array with entries for email and password. Exact default messages can vary by validator and message configuration, so tests should assert the fields and your configured messages when you own the message text.

Nested Objects and Collections

Validation becomes more interesting when a request contains nested records. The outer DTO must mark the nested value with @Valid; otherwise only the outer property constraints run. For collections, validate both the collection itself and its elements when both matter.

import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import java.util.List;

record LineItemRequest(
    @NotBlank String sku,
    @Min(1) int quantity
) {}

record OrderRequest(
    @NotBlank String customerId,
    @NotEmpty @Size(max = 20) List<@Valid LineItemRequest> items
) {}

class OrderValidationExample {
    static int totalQuantity(OrderRequest request) {
        return request.items().stream().mapToInt(LineItemRequest::quantity).sum();
    }
}

This second worked example separates two rules. @NotEmpty says the order must contain at least one line item. @Size(max = 20) prevents very large request bodies from becoming normal work. List<@Valid LineItemRequest> says each element must be inspected, so an item with an empty SKU or zero quantity becomes a field error such as items[0].sku or items[0].quantity. Without the element-level @Valid, an order could pass even though an item is unusable.

Parameter Validation

Request-body validation covers JSON DTOs. Query parameters and path variables use method validation. Add @Validated to the controller class and place constraints on parameters. This is useful for pagination, filters, and IDs that must be positive.

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
class CourseSearchController {
    @GetMapping("/api/courses")
    String search(
        @RequestParam(defaultValue = "1") @Min(1) int page,
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
    ) {
        return "page=" + page + ", size=" + size;
    }
}

This third example has deterministic success output: GET /api/courses?page=2&size=50 returns page=2, size=50. size=500 fails validation because the maximum is 100. Many teams normalize parameter-validation failures into the same problem-details shape as body-validation failures, even though the exception type may differ, so clients do not need endpoint-specific error parsing.

Design Choices and Trade-offs

DTO validation is fast to understand and easy to test, but it should not become a dumping ground for every rule. Use annotations for local, deterministic checks: blankness, length, numeric ranges, formats, and nested structure. Use services for rules that need repositories, current user identity, time windows, uniqueness, or state transitions. Bean Validation has custom validators, but injecting repositories into validators can make validation order, transaction boundaries, and test setup harder to reason about.

Message design is another trade-off. Default messages are convenient but inconsistent across languages and implementations. Explicit messages give client teams a stable contract, but they can leak policy details if they expose too much. For public APIs, prefer stable machine-readable codes in addition to human-readable messages when clients must branch on the failure.

Returning rejected values is useful during debugging, but can expose passwords, tokens, personal data, or payload fragments. A conservative error response names the field and explains the violation without echoing sensitive input. If you include rejected values, redact by field name and cap string length.

Failure Modes and Troubleshooting

Symptom: invalid request bodies reach the controller. Cause: the parameter is missing @Valid, validation support is absent from the classpath, or constraints were placed on a type that is not the bound request object. Diagnosis: send a clearly invalid body and set a breakpoint in the controller; if it is hit, MVC did not run body validation. Correction: add validation support, annotate the @RequestBody parameter with @Valid, and keep constraints on the DTO being bound.

Symptom: nested fields are not reported. Cause: the outer DTO lacks @Valid on the nested property or collection element. Diagnosis: test with an invalid nested item and inspect the returned field list. Correction: add @Valid to nested object properties and to collection elements that should be traversed.

Symptom: clients receive HTML error pages or different JSON shapes for similar validation failures. Cause: exceptions are falling through to default error handling or separate handlers return incompatible bodies. Diagnosis: compare responses for invalid body, invalid query parameter, malformed JSON, and missing required parameter. Correction: centralize API exception translation in @RestControllerAdvice and cover each exception family with tests.

Symptom: a field appears as items[0].sku in one endpoint and lineItems[0].productCode in another for the same client concept. Cause: DTO names and API names drifted. Diagnosis: inspect serialized request names and validation field paths together. Correction: align DTO property names with the public JSON contract or map internal names to external names in the error translator.

Security, Performance, and Reliability

Validation is part of defensive input handling, but it is not authorization. A request can be perfectly valid and still forbidden for the current user. Run authorization in the appropriate security layer or service after syntactic request validation. Avoid logging full invalid bodies; validation failures often contain exactly the data a client was not supposed to send.

Performance costs usually come from very large payloads, deep object graphs, expensive custom validators, or overly broad cascaded validation. Put request-size limits at the web server or framework layer, cap collection sizes in DTOs, and keep custom validators deterministic and local when possible. For reliability, make validation errors stable enough that clients can fix requests without guessing, and add contract tests so future refactors do not silently change the error format.

Hands-on Lab

Prerequisites: a small Spring Boot Web project with validation support, Java available locally, and a way to send HTTP requests such as curl, HTTPie, or an IDE client.

  1. Create a SignupRequest DTO with @Email, @NotBlank, and @Size constraints.
  2. Add a POST /api/signups endpoint whose @RequestBody parameter is annotated with @Valid.
  3. Add a @RestControllerAdvice that catches MethodArgumentNotValidException and returns a ProblemDetail with an errors property.
  4. Send a valid request and verify that the endpoint returns 201.
  5. Send {"email":"","password":"short"} and verify that the endpoint returns 400, title is Request validation failed, and the field list contains email and password.
  6. Add a nested request object, intentionally omit @Valid, and observe that nested invalid values pass. Then add @Valid and verify that nested field errors appear.

Cleanup: remove any temporary echo behavior from controllers, keep the exception handler if it matches the API standard, and delete test data created during manual requests. If the handler changed an existing API contract, roll it back or version the response before exposing it to existing clients.

Assessment Exercises

  1. A request has @NotEmpty List<LineItem> items, but invalid line item fields are not rejected. What annotation is missing, and where should it be placed?
  2. Design a validation response for a public API that supports both browser clients and mobile clients. Which fields are stable for machines, and which are only display text?
  3. Given a rule that a username must be unique, decide whether it belongs in Bean Validation, a service, or the database. Explain the concurrency risk in your choice.
  4. Write a test plan that proves invalid JSON, invalid field values, and invalid query parameters all return the same problem-details envelope.
  5. Identify one validation message that could leak sensitive policy or user data, then rewrite it to be useful without exposing that information.

Summary

Bean Validation gives Spring Boot controllers a declarative way to reject bad request bodies and parameters. The mechanism depends on binding, @Valid or @Validated, constraint traversal, and exception translation. A good API pairs those mechanics with a deliberate error shape, clear field paths, careful redaction, focused tests, and a boundary between syntactic request checks and deeper business rules.