DEV Community

Ankit Verma
Ankit Verma

Posted on

Bean Validation (@Valid) + binding errors

🧠 The big idea in one line

Bean Validation lets you declare the rules your incoming data must obey right on the data object, and Spring checks them for you at the edge of your app — so bad input is rejected before it ever reaches your logic.

  • Why it exists: every web app receives junk — empty names, negative ages, malformed emails. Checking all of that by hand, in every controller method, is repetitive and easy to get wrong.
  • The shift: instead of writing checks, you attach rules to the fields, and something else runs them.
  • When you meet it: the moment a request carries a body or form — a signup, an order, a search filter — and you want to trust the fields before you use them.

🩹 The problem: hand-written checks everywhere

Imagine a controller that creates a user. Without any help, you check each field yourself:

@PostMapping("/users")
public User create(@RequestBody UserRequest req) {
    if (req.getName() == null || req.getName().isBlank())
        throw new IllegalArgumentException("name required");
    if (req.getAge() < 18)
        throw new IllegalArgumentException("must be 18+");
    // ... and on, and on
    return service.save(req);
}
Enter fullscreen mode Exit fullscreen mode
  • The rules are buried in the method, mixed with real work.
  • The same checks get copy-pasted into every endpoint that touches a user.
  • The error you throw is a raw exception — no clean list of what was wrong.
  • Change a rule and you must hunt down every copy.

The rules really belong to the data, not to one method. That is the idea Bean Validation makes real.


🏷️ Step 1 — Rules become annotations (constraints)

A constraint is a single rule attached to a field, written as an annotation. "This must not be blank." "This must be at least 18." You put the rule on the field it governs:

public class UserRequest {

    @NotBlank
    private String name;

    @Min(18)
    private int age;

    @Email
    private String email;

    // getters / setters
}
Enter fullscreen mode Exit fullscreen mode
  • @NotBlank, @Min, @Email are constraint annotations — each names one rule.
  • The rules now live with the data, readable at a glance, defined once.
  • These annotations come from the Jakarta Bean Validation standard (the specification; Hibernate Validator is the usual implementation that actually enforces them). It is a Java standard, not a Spring invention — Spring just plugs into it.

A quick tour of the everyday constraints:

Constraint Passes when… Note
@NotNull value is not null says nothing about emptiness
@NotEmpty not null and length/size > 0 for String, Collection, Map, array
@NotBlank not null and has non-whitespace text String only
@Min / @Max number ≥ / ≤ a bound on numeric types
@Size(min, max) length/size in range String or collection
@Email looks like an email format check only
@Pattern(regexp) matches a regex your own format rule

⚠️ Easy to confuse: the three "not empty" checks are different.

  • @NotNull → only rejects null. An empty string passes.
  • @NotEmpty → rejects null and "", but " " (spaces) passes.
  • @NotBlank → rejects null, "", and " ". For user-typed strings, this is almost always the one you want.

⚙️ Step 2 — Who actually runs the rules?

Declaring a rule does nothing on its own — something has to read the annotations and check the object. That something is a validator: an object that takes your populated data object, runs every constraint on it, and reports back the failures.

  • Spring Boot, when the validation library is on the classpath, builds a validator and wires it in automatically — you don't create one by hand.
  • You get that library through the starter:
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
Enter fullscreen mode Exit fullscreen mode
  • Without this dependency the annotations are just silently ignored — a classic "why isn't my validation running?" trap.

So now we have rules on the object and a validator ready to run them. The last piece is telling Spring to actually run it on a request.


🎯 Step 3 — @Valid triggers the check at the boundary

Spring reads a JSON body or form into your object automatically — this mapping of request data onto object fields is called binding. You mark the bound parameter with @Valid to say: after binding, run the validator on it.

@PostMapping("/users")
public User create(@Valid @RequestBody UserRequest req) {
    // reached ONLY if every constraint passed
    return service.save(req);
}
Enter fullscreen mode Exit fullscreen mode
  • @RequestBody binds the JSON into req.
  • @Valid tells Spring to validate req right after binding, before your code runs.
  • If everything passes, the method body runs with data you can trust.
  • If anything fails, the method body never runs — Spring stops at the boundary.

The failures collected during binding and validation are called binding errors. The next question is: where do they go?


📥 Step 4 — Where the errors live, and the two paths

Every failure lands in an Errors object (its common subtype is BindingResult) — a container holding each thing that went wrong. What Spring does with it depends on whether you ask for that container as a parameter.

Path A — Spring throws. No BindingResult parameter:

@PostMapping("/users")
public User create(@Valid @RequestBody UserRequest req) { ... }
Enter fullscreen mode Exit fullscreen mode
  • Validation fails → Spring raises an exception and your method is skipped.
  • With no handler, the client gets an automatic 400 Bad Request.
  • This is the common, clean choice: let it throw, handle it in one place (see Step 6).

Path B — you inspect it yourself. Add a BindingResult parameter:

@PostMapping("/users")
public ResponseEntity<?> create(@Valid @RequestBody UserRequest req,
                                BindingResult result) {
    if (result.hasErrors()) {
        return ResponseEntity.badRequest().body(result.getAllErrors());
    }
    return ResponseEntity.ok(service.save(req));
}
Enter fullscreen mode Exit fullscreen mode
  • The BindingResult catches the errors instead of letting them throw.
  • Now the method does run, and you decide what to do with the failures. > ⚠️ The parameter order is a hard rule. The BindingResult must come immediately after the object it validates. Put anything between them and Spring goes back to Path A and throws — a subtle, much-hit bug.
JSON/form  ──►  bind to object  ──►  @Valid runs validator
                                         │
                       ┌─────────────────┴─────────────────┐
                       ▼                                     ▼
             BindingResult param?                    no such param
                       │                                     │
               inspect result yourself             Spring throws → 400
Enter fullscreen mode Exit fullscreen mode

🧩 Step 5 — The failure looks different for JSON vs forms

The exception Spring throws is not the same depending on how the data arrived. Both carry a BindingResult inside, but they have different types — which matters when you write a handler.

Input style Parameter Exception on failure
JSON body @Valid @RequestBody MethodArgumentNotValidException
Form / query params @Valid @ModelAttribute BindException
  • For a REST API sending JSON, you will almost always be handling MethodArgumentNotValidException.
  • Both expose the same getBindingResult(), so once you have the result the handling code looks the same.

📤 Step 6 — Turning binding errors into a clean response

You rarely want the raw exception page. You catch it in one place and shape a tidy reply. Spring gives a standard error body type, ProblemDetail (RFC 9457), so responses look consistent:

@RestControllerAdvice
class ValidationAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail handle(MethodArgumentNotValidException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(FieldError::getField,
                                      FieldError::getDefaultMessage)));
        return pd;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • getFieldErrors() gives one entry per field that failed.
  • Each FieldError knows the field name and a message (getDefaultMessage()).
  • The result is a 400 with a clean { field: message } map — the client learns exactly what to fix.
  • Wiring these handlers in one advice class is its own topic; here just note that a thrown validation error becomes a friendly response in a single spot.

You can control the message per constraint:

@NotBlank(message = "Name is required")
private String name;
Enter fullscreen mode Exit fullscreen mode
  • Field errors are tied to one field (name was blank).
  • Global errors (also called object errors) are about the whole object — e.g. "password and confirmation must match," a rule that spans two fields.

🪆 Step 7 — Nested objects and collections

Validation does not automatically dive into nested objects. You must mark the nested field with @Valid too, or its constraints are skipped.

public class OrderRequest {

    @NotNull
    private String product;

    @Valid                       // <-- without this, Address rules are ignored
    private Address address;

    @Valid                       // <-- validates every Item in the list
    private List<Item> items;
}
Enter fullscreen mode Exit fullscreen mode
  • @Valid on address tells the validator to descend into the Address object and run its constraints.
  • @Valid on a List validates each element.
  • Forget the inner @Valid and the nested rules quietly never run — another silent trap.

🔀 Step 8 — @Validated: groups and validating single params

@Valid is the plain standard annotation. Spring adds its own @Validated, which does two extra things.

1. Validation groups — apply different rules in different situations.

  • A field might be required on update but not on create. You tag constraints with a group (a marker interface) and activate the group you want.
public class UserRequest {
    @NotNull(groups = Update.class)   // required only when updating
    private Long id;

    @NotBlank(groups = {Create.class, Update.class})
    private String name;
}

// activate a group for this endpoint:
public User update(@Validated(Update.class) @RequestBody UserRequest req) { ... }
Enter fullscreen mode Exit fullscreen mode
  • @Valid cannot select a group; @Validated(Group.class) can.

2. Validating loose method parameters — not a whole object.

  • To validate a bare @RequestParam or @PathVariable, you put @Validated on the class, then constraints directly on the parameters:
@RestController
@Validated                              // <-- enables param-level checks
class SearchController {

    @GetMapping("/search")
    List<Hit> search(@RequestParam @Min(1) int page,
                     @RequestParam @Size(max = 50) String q) { ... }
}
Enter fullscreen mode Exit fullscreen mode
  • Here the failure is a third exception type: ConstraintViolationException (not the two from Step 5), because there is no object and no BindingResult — just individual parameters. > ⚠️ Easy to confuse: @Valid and @Validated are not the same annotation. > - @Valid → the Java standard annotation. Validates a whole object, cascades into nested @Valid fields. No groups. > - @Validated → Spring's annotation. Supports groups, and on a class enables validating single @RequestParam / @PathVariable values. > - Rule of thumb: use @Valid on the body object; use @Validated when you need groups or method-parameter checks.

🛠️ Step 9 — Writing your own constraint (briefly)

When no built-in rule fits, you can define one. A custom constraint is two pieces: an annotation and a validator class that holds the logic.

@Constraint(validatedBy = NotReservedValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NotReserved {
    String message() default "value is reserved";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class NotReservedValidator implements ConstraintValidator<NotReserved, String> {
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value == null || !value.equalsIgnoreCase("admin");
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The annotation points to its validator with validatedBy.
  • isValid returns true or false; the three members (message, groups, payload) are required boilerplate the spec expects.
  • Now @NotReserved behaves like any built-in constraint — reusable across your whole app.

⚠️ Step 10 — Traps the mechanism creates

  • Missing dependency, silent no-op. Without spring-boot-starter-validation on the classpath the annotations are simply ignored. Validation "works on my machine" but not after a slimmed-down build.
  • Misplaced result container. The BindingResult must sit immediately after the validated parameter, or Spring throws instead of handing it to you.
  • Forgotten nested cascade. Inner objects and list elements are only validated when their field carries @Valid.
  • Wrong emptiness check. A blank string sails past @NotNull. Pick the constraint (@NotNull, @NotEmpty, @NotBlank) that matches what "empty" means for that field.
  • Groups without the right annotation. Groups only fire under @Validated; using plain @Valid silently applies the default group and skips your group-specific rules.
  • Three exception types, one habit. JSON gives MethodArgumentNotValidException, forms give BindException, loose params give ConstraintViolationException. A handler written for one will not catch the others.

📊 Quick summary

Piece Role
Constraint (@NotBlank, @Min, …) one rule, declared on the field
Validator runs the rules on the object (auto-wired by Boot)
@Valid trigger validation after binding; cascades into nested @Valid
@Validated Spring's variant: groups + single-parameter validation
BindingResult / Errors holds the failures
MethodArgumentNotValidException thrown for a failed @RequestBody
BindException thrown for a failed form / @ModelAttribute
ConstraintViolationException thrown for failed loose @RequestParam / @PathVariable
@ExceptionHandlerProblemDetail turn the failure into a clean 400

🎯 Decision rule

  • Validating a request body or form object? → put @Valid on the parameter.
  • Want to inspect errors inline? → add a BindingResult right after it. Otherwise let it throw and handle centrally.
  • Need rules that differ by create/update, or to validate a bare param? → reach for @Validated (with a group, or on the class).
  • Validation not running at all? → check the spring-boot-starter-validation dependency first.
  • Nested object or list not being checked? → add @Valid on that field.

💡 Remember this

  • Rules live on the data as annotations; Spring runs them for you at the boundary. That is the whole point — no hand-written checks scattered through controllers.
  • The trigger, then the container. @Valid runs the check; the errors land in a BindingResult — ask for that parameter to inspect them, or let Spring throw a 400.
  • Two annotations, two jobs. Use @Valid for whole objects and @Validated for groups and single parameters — and the exception type depends on how the data arrived.
  • Silence usually means a missing piece: the starter dependency, a misplaced BindingResult, or a forgotten nested @Valid.

Top comments (0)