Java · lesson 53 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Validation in Spring Boot
Validate Spring Boot 3 requests with Bean Validation: @Valid bodies, error JSON from @RestControllerAdvice, path params, nested objects, custom rules.
By SkillByExample · Updated
Bean Validation (Jakarta Validation, implemented by Hibernate Validator) lets you declare rules on
request types with annotations and have Spring check them before your controller method runs. Add
spring-boot-starter-validation next to spring-boot-starter-web. Every example is part of one
Spring Boot 3.5 project, started with java -jar target/val-0.0.1-SNAPSHOT.jar, and every response
is what curl received. Code blocks start at the type declaration; the imports are
jakarta.validation.*, jakarta.validation.constraints.* and Spring’s
org.springframework.web.bind.annotation.*.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Constraints and @Valid
public record CreateUserRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotNull @Min(18) @Max(120) Integer age,
@Size(min = 8, max = 64, message = "must be 8-64 characters") String password) {}
@RestController
@RequestMapping("/api/users")
public class UserController {
record UserCreated(String name, String email) {}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserCreated create(@Valid @RequestBody CreateUserRequest request) { // @Valid triggers the checks
return new UserCreated(request.name(), request.email());
}
}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse"}'
{"name":"Ada","email":"ada@example.com"}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":" ","email":"not-an-email","age":12,"password":"short"}'
{"timestamp":"2026-10-07T22:08:35.216+00:00","status":400,"error":"Bad Request","path":"/api/users"}
The server log for the second request:
2026-10-07T22:08:35.207Z WARN 4902 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.val.UserController$UserCreated com.example.val.UserController.create(com.example.val.CreateUserRequest) with 4 errors: [Field error in object 'createUserRequest' on field ...
@Valid made Spring run all four checks before create was called; all four failed, so the method
never ran and the request got a 400. The default body, though, says nothing about which fields were
wrong — the details only reached the server log. age is an Integer, not int, so that a missing
value is null and @NotNull can report it; an int would silently default to 0.
Turning violations into a useful 400
@RestControllerAdvice // applies to every controller
public class ValidationErrors {
record ErrorBody(int status, Map<String, String> errors) {}
@ExceptionHandler(MethodArgumentNotValidException.class) // @Valid @RequestBody failed
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorBody body(MethodArgumentNotValidException ex) {
Map<String, String> errors = new TreeMap<>(); // sorted by field name
ex.getBindingResult().getFieldErrors().forEach(e ->
errors.merge(e.getField(), e.getDefaultMessage(), (a, b) -> a + "; " + b));
ex.getBindingResult().getGlobalErrors().forEach(e -> // class-level constraints
errors.put(e.getObjectName(), e.getDefaultMessage()));
return new ErrorBody(400, errors);
}
@ExceptionHandler(HandlerMethodValidationException.class) // @PathVariable / @RequestParam failed
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorBody params(HandlerMethodValidationException ex) {
Map<String, String> errors = new TreeMap<>();
ex.getParameterValidationResults().forEach(r -> r.getResolvableErrors().forEach(e ->
errors.put(r.getMethodParameter().getParameterName(), e.getDefaultMessage())));
return new ErrorBody(400, errors);
}
}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":" ","email":"not-an-email","age":12,"password":"short"}'
{"status":400,"errors":{"age":"must be greater than or equal to 18","email":"must be a well-formed email address","name":"must not be blank","password":"must be 8-64 characters"}}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"email":""}'
{"status":400,"errors":{"age":"must not be null","email":"must not be blank","name":"must not be blank"}}
A @RestControllerAdvice handles exceptions thrown from any controller. Every violation is reported
at once, so a client can fix a whole form in one round trip; the TreeMap keeps fields in a stable
order. Look at the second response: "" failed @NotBlank but not @Email, and the missing
password produced no error at all. Every constraint except the null checks treats null (and
@Email treats empty) as valid — @Size alone does not make a field required. The second handler is
for the next section.
Path variables and query parameters
@RestController
@RequestMapping("/api/orders")
public class LookupController {
record OrderPage(long customerId, int page, String status) {}
@GetMapping("/{customerId}")
public OrderPage orders(@PathVariable @Min(1) long customerId,
@RequestParam(defaultValue = "0") @Min(0) @Max(50) int page,
@RequestParam(defaultValue = "OPEN") @Pattern(regexp = "OPEN|SHIPPED") String status) {
return new OrderPage(customerId, page, status);
}
}
$ curl -s -w '\n' 'localhost:8080/api/orders/7?page=2&status=SHIPPED'
{"customerId":7,"page":2,"status":"SHIPPED"}
$ curl -s -w '\n' 'localhost:8080/api/orders/0?page=99&status=LOST'
{"status":400,"errors":{"customerId":"must be greater than or equal to 1","page":"must be less than or equal to 50","status":"must match \"OPEN|SHIPPED\""}}
Constraints directly on method parameters are checked by Spring MVC itself (since Spring Framework
6.1, Boot 3.2) — no @Validated on the class is needed. A failure throws a different exception,
HandlerMethodValidationException, which is why the advice has a second handler; without it these
requests would get the generic 400 body again.
Nested objects and lists
public record OrderRequest(
@NotNull @Valid Address shipTo, // @Valid: check inside it too
@NotEmpty List<@Valid Item> items) {
public record Address(@NotBlank String street, @Pattern(regexp = "\\d{5}") String zip) {}
public record Item(@NotBlank String sku, @Positive int quantity) {}
}
@RestController
@RequestMapping("/api/orders") // POST here; LookupController keeps GET /{customerId}
public class OrderController {
record OrderAccepted(String zip, int lines) {}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderAccepted place(@Valid @RequestBody OrderRequest order) {
return new OrderAccepted(order.shipTo().zip(), order.items().size());
}
}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"shipTo":{"street":"1 Main St","zip":"12345"},"items":[{"sku":"KB-1","quantity":2}]}'
{"zip":"12345","lines":1}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"shipTo":{"street":"","zip":"ABC"},"items":[{"sku":"KB-1","quantity":2},{"sku":"","quantity":0}]}'
{"status":400,"errors":{"items[1].quantity":"must be greater than 0","items[1].sku":"must not be blank","shipTo.street":"must not be blank","shipTo.zip":"must match \"\\d{5}\""}}
$ curl -s -w '\n' localhost:8080/api/orders -H 'Content-Type: application/json' -d '{"items":[]}'
{"status":400,"errors":{"items":"must not be empty","shipTo":"must not be null"}}
Only the valid order reached place, which echoed its zip and line count; the other two never ran
it. Validation does not descend into a nested object unless the field is marked @Valid — without
it, shipTo would only be checked for null. List<@Valid Item> validates every element, and the error
paths say exactly where: items[1].sku is the second item. The last request shows the outer
constraints still apply when there is nothing to descend into.
A custom constraint
When no built-in annotation fits, write one: an annotation marked @Constraint, plus a validator.
The next section’s SignupRequest uses it, and the output for both follows there.
@Documented
@Constraint(validatedBy = UsernameValidator.class) // the class that does the check
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface Username {
String message() default "must be 3-16 lowercase letters or digits and not reserved";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class UsernameValidator implements ConstraintValidator<Username, String> {
private static final Set<String> RESERVED = Set.of("admin", "root", "support");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // leave null to @NotNull
return value.matches("[a-z0-9]{3,16}") && !RESERVED.contains(value);
}
}
A cross-field constraint
A rule involving two fields goes on the class, so its validator receives the whole object.
@Constraint(validatedBy = PasswordsMatchValidator.class)
@Target(ElementType.TYPE) // on the class, so it sees every field
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordsMatch {
String message() default "passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, SignupRequest> {
@Override
public boolean isValid(SignupRequest r, ConstraintValidatorContext context) {
return Objects.equals(r.password(), r.confirmPassword());
}
}
@PasswordsMatch
public record SignupRequest(
@NotNull @Username String username,
@NotBlank String password,
@NotBlank String confirmPassword) {}
@RestController
@RequestMapping("/api/signup")
public class SignupController {
record Welcome(String username) {}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Welcome signup(@Valid @RequestBody SignupRequest request) {
return new Welcome(request.username());
}
}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"ada99","password":"s3cret","confirmPassword":"s3cret"}'
{"username":"ada99"}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"admin","password":"s3cret","confirmPassword":"s3cret"}'
{"status":400,"errors":{"username":"must be 3-16 lowercase letters or digits and not reserved"}}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"Ada!","password":"s3cret","confirmPassword":"secret"}'
{"status":400,"errors":{"signupRequest":"passwords do not match","username":"must be 3-16 lowercase letters or digits and not reserved"}}
@Username is now as reusable as @Email. Its validator returns true for null so it composes with
@NotNull instead of duplicating it. @PasswordsMatch failed as a global error on the object, not
on a field — the advice’s getGlobalErrors() loop reported it under the object name
signupRequest. Field and class constraints are all evaluated, so both problems arrived together.
Practice
- Add an optional
phonetoCreateUserRequestthat, when present, must be 7-15 digits with an optional leading+. Show that omitting it is still accepted.
Solution
public record CreateUserRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotNull @Min(18) @Max(120) Integer age,
@Size(min = 8, max = 64, message = "must be 8-64 characters") String password,
@Pattern(regexp = "\\+?[0-9]{7,15}", message = "must be 7-15 digits, optional leading +") String phone) {}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse","phone":"12-34"}'
{"status":400,"errors":{"phone":"must be 7-15 digits, optional leading +"}}
$ curl -s -w '\n' localhost:8080/api/users -H 'Content-Type: application/json' -d '{"name":"Ada","email":"ada@example.com","age":36,"password":"correct-horse"}'
{"name":"Ada","email":"ada@example.com"}
@Pattern treats null as valid, which is exactly “optional, but well-formed if given”.
- Tighten
@Usernameso a username must start with a letter (9livesis rejected), and update its message.
Solution
// UsernameValidator.isValid
return value.matches("[a-z][a-z0-9]{2,15}") && !RESERVED.contains(value);
// Username
String message() default "must be 3-16 lowercase letters or digits, start with a letter, and not be reserved";
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"9lives","password":"s3cret","confirmPassword":"s3cret"}'
{"status":400,"errors":{"username":"must be 3-16 lowercase letters or digits, start with a letter, and not be reserved"}}
$ curl -s -w '\n' localhost:8080/api/signup -H 'Content-Type: application/json' -d '{"username":"ada99","password":"s3cret","confirmPassword":"s3cret"}'
{"username":"ada99"}
- Validation is not tied to HTTP. Write a plain JUnit test that validates
new SignupRequest("9lives", "pw", "different")with ajakarta.validation.Validatorand prints each violation (using the stricter rule from task 2).
Solution
package com.example.val;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
class SignupRequestTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void reportsEveryBrokenRule() {
var request = new SignupRequest("9lives", "pw", "different");
List<String> problems = validator.validate(request).stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.toList();
problems.forEach(System.out::println);
assertEquals(2, problems.size());
}
}
$ mvn -B test 2>&1 | grep -E '^[a-z]*: |Tests run:|BUILD'
: passwords do not match
username: must be 3-16 lowercase letters or digits, start with a letter, and not be reserved
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.309 s -- in com.example.val.SignupRequestTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
The class-level violation has an empty property path — it belongs to the object, not a field. In a
Spring bean you would inject the Validator instead of building one.