Request validation
Bean Validation on request bodies, groups, custom constraints, and validating path and query parameters — with 400 versus 422 decided once.
Every value that reaches your service came from outside it, which means it can be anything. Validation is where you decide what "anything" is allowed to be — and the decision worth making early is where that happens, because a codebase that validates in four layers validates in none of them consistently.
The answer is the edge. Validate the request the moment it becomes an object, and let everything behind that boundary assume it is valid.
Bean Validation, and what Spring does with it
Constraints go on the request DTO — the one from the previous lesson, not the entity:
public class CreateCustomer {
@NotBlank(message = "name is required")
public String name;
@NotBlank @Email(message = "must be a well-formed email address")
public String email;
@Min(value = 18, message = "must be 18 or older")
public int age;
}@Valid on the parameter is what makes Spring run them:
@PostMapping("/customers")
public ResponseEntity<CustomerView> create(@Valid @RequestBody CreateCustomer body) { ... }Send something wrong and the constraint failures never reach your method body. Spring throws MethodArgumentNotValidException before the first line runs. Which is exactly right — and then it produces this:
$ curl -i -X POST /customers -d '{"name":"","email":"not-an-email","age":12}'
HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-09-12T10:29:11.016+00:00","status":400,"error":"Bad Request","path":"/customers"}Three fields were rejected and the client is told none of them. Not which field, not why. A form cannot highlight anything; a support ticket says "it says bad request".
The constraints did their job. The default error rendering did not, and fixing that is the next lesson's subject — it is one @RestControllerAdvice, and the same request then returns every field and message.
The constraints worth knowing
| Constraint | Applies to | Note |
|---|---|---|
@NotNull | anything | present, may be empty |
@NotEmpty | string, collection, map | present and not empty |
@NotBlank | string | present, and not only whitespace |
@Size(min, max) | string, collection | length or count |
@Min, @Max, @Positive | numbers | |
@Email, @Pattern | string | |
@Past, @Future | dates | |
@Valid on a field | a nested object | required, or nesting is not checked |
The first three are a real trap for strings. @NotNull accepts "". @NotEmpty accepts " ". For a name, an address line, a description — anything a human types — @NotBlank is almost always what you meant.
And the last row is the one people discover in production: constraints on a nested object are not applied unless the field holding it carries @Valid.
public class CreateOrder {
@NotNull @Valid // without @Valid, Address's own constraints never run
public Address shippingAddress;
}Validating what is not a body
@Valid covers request bodies. Path variables and query parameters need @Validated on the class, which switches on a different mechanism:
@RestController
@Validated // on the class, not the method
public class Orders {
@GetMapping("/orders")
public List<OrderView> list(@RequestParam @Min(1) @Max(100) int size) { ... }
}Without it those annotations are decoration. With it, a violation throws ConstraintViolationException — a different exception from the body case, which your error handler has to know about. Two paths, two exception types, one response shape: that is the work the next lesson does.
Custom constraints, for rules the annotations cannot say
When a rule is specific to your domain, write it once rather than repeating an if in four controllers:
@Target({ FIELD }) @Retention(RUNTIME)
@Constraint(validatedBy = SupportedCurrencyValidator.class)
public @interface SupportedCurrency {
String message() default "currency is not supported";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class SupportedCurrencyValidator implements ConstraintValidator<SupportedCurrency, String> {
private static final Set<String> OK = new HashSet<>(asList("INR", "USD", "EUR"));
@Override public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value == null || OK.contains(value); // null is @NotNull's job, not ours
}
}Two details in that validator are conventions worth keeping. null returns true — combining a null check into every constraint makes @NotNull meaningless and error messages confusing. And a validator does not reach into a database; a constraint that needs a query belongs in the service, where it can be transactional and where "does this email already exist" can be answered without a race.
Groups, when the same object has two rule sets
The same DTO is sometimes valid under different rules — a draft may have no address, a submitted order must:
public interface OnSubmit {}
public class OrderForm {
@NotBlank(groups = OnSubmit.class)
public String shippingAddress;
}
public void submit(@Validated(OnSubmit.class) @RequestBody OrderForm form) { ... }Useful, and easy to overuse. Two groups is a rule set; five is a sign the object is really two objects with different shapes, and separate DTOs will read better.
400 or 422 — decide once
Both are defensible and the argument is not worth having twice in one codebase:
400 Bad Request— I could not read what you sent. Malformed JSON, a string where a number belongs, a missing required field the parser needed. The client is broken.422 Unprocessable Content— I read it perfectly; the values are not acceptable. An email that is not an email, an age of 12. The user needs to fix something.
Spring's default for a failed @Valid is 400. The argument for 422 is that it separates a client bug from a user mistake, which lets a front end react differently — show a field error, or report a defect. Whichever you pick, write it in the API documentation and make the error handler enforce it everywhere, because a client that sees both for the same class of problem will handle neither well.