π§ 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);
}
- 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
}
-
@NotBlank,@Min,@Emailare 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 rejectsnull. An empty string passes.@NotEmptyβ rejectsnulland"", but" "(spaces) passes.@NotBlankβ rejectsnull,"", 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'
- 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);
}
-
@RequestBodybinds the JSON intoreq. -
@Validtells Spring to validatereqright 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) { ... }
- 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));
}
- The
BindingResultcatches 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
BindingResultmust 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
π§© 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;
}
}
-
getFieldErrors()gives one entry per field that failed. - Each
FieldErrorknows the field name and a message (getDefaultMessage()). - The result is a
400with 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;
-
Field errors are tied to one field (
namewas 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;
}
-
@Validonaddresstells the validator to descend into theAddressobject and run its constraints. -
@Validon aListvalidates each element. - Forget the inner
@Validand 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) { ... }
-
@Validcannot select a group;@Validated(Group.class)can.
2. Validating loose method parameters β not a whole object.
- To validate a bare
@RequestParamor@PathVariable, you put@Validatedon 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) { ... }
}
- Here the failure is a third exception type:
ConstraintViolationException(not the two from Step 5), because there is no object and noBindingResultβ just individual parameters. > β οΈ Easy to confuse:@Validand@Validatedare not the same annotation. > -@Validβ the Java standard annotation. Validates a whole object, cascades into nested@Validfields. No groups. > -@Validatedβ Spring's annotation. Supports groups, and on a class enables validating single@RequestParam/@PathVariablevalues. > - Rule of thumb: use@Validon the body object; use@Validatedwhen 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");
}
}
- The annotation points to its validator with
validatedBy. -
isValidreturnstrueorfalse; the three members (message,groups,payload) are required boilerplate the spec expects. - Now
@NotReservedbehaves 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-validationon the classpath the annotations are simply ignored. Validation "works on my machine" but not after a slimmed-down build. -
Misplaced result container. The
BindingResultmust 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@Validsilently applies the default group and skips your group-specific rules. -
Three exception types, one habit. JSON gives
MethodArgumentNotValidException, forms giveBindException, loose params giveConstraintViolationException. 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
|
@ExceptionHandler β’ ProblemDetail
|
turn the failure into a clean 400 |
π― Decision rule
-
Validating a request body or form object? β put
@Validon the parameter. -
Want to inspect errors inline? β add a
BindingResultright 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-validationdependency first. -
Nested object or list not being checked? β add
@Validon 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.
@Validruns the check; the errors land in aBindingResultβ ask for that parameter to inspect them, or let Spring throw a400. -
Two annotations, two jobs. Use
@Validfor whole objects and@Validatedfor 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.