Bean Validation in Spring Boot: @Valid and Custom Validators
Introduction
This tutorial shows how to validate the input of a Spring Boot REST API with Bean Validation, so that invalid data is rejected with a clear400 Bad Request before it reaches your business logic. We will start with what Bean Validation is and how to add it. We will then put constraints such as @NotBlank, @Size, @Email and @Positive on a request record, switch validation on with @Valid, and return a readable list of field errors. Next we will validate path variables and query parameters directly, using Spring's built-in method validation. We will then write a custom constraint that checks an ISBN's check digit, and lastly look at error messages, validation groups and validating objects in your own code.(This tutorial uses the request mapping annotations from Spring MVC Request MappingClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies. Validation of configuration properties, with the same annotations, is covered in @ConfigurationPropertiesClick to read tutorial: Type-Safe Configuration with @ConfigurationProperties.)
Bean Validation in Spring Boot
Bean Validation (Jakarta Validation) is the Java standard for declaring rules on data with annotations -@NotNull, @Size(max = 100), @Email - and checking them with a validator. Spring Boot adds it with one starter, which brings the API and Hibernate Validator, the reference implementation -
pom.xml - the validation starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
@RequestBody marked with @Valid, and any controller method parameter that carries a constraint annotation. The most useful built-in constraints are -
@NotNull,@NotEmpty(not null and not empty),@NotBlank(a string with at least one non-space character).@Size(min, max)for strings and collections;@Min,@Max,@Positive,@PositiveOrZero,@DecimalMin,@Digitsfor numbers.@Email,@Pattern(regexp = ...)for formats;@Past,@Futureand theirOrPresentvariants for dates.
The example API
The application below accepts new books and lists books with a page size. The request recordNewBook declares its rules, including a custom @Isbn constraint explained later, and a small exception handler in the controller turns validation failures into a sorted map of field errors -
Isbn.java - a custom constraint annotation
//Isbn.java
package com.javabrahman.rest.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Constraint(validatedBy = IsbnValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Isbn {
String message() default "must be a valid 13-digit ISBN";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
IsbnValidator.java - checks the ISBN-13 check digit
//IsbnValidator.java
package com.javabrahman.rest.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class IsbnValidator implements ConstraintValidator<Isbn, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // null is left to @NotNull / @NotBlank
}
String digits = value.replace("-", "");
if (!digits.matches("\\d{13}")) {
return false;
}
int sum = 0;
for (int i = 0; i < 12; i++) {
sum += (digits.charAt(i) - '0') * (i % 2 == 0 ? 1 : 3); // weights 1,3,1,3,...
}
int check = (10 - sum % 10) % 10;
return check == digits.charAt(12) - '0';
}
}
ValidationApp.java - the request record, the controller and the error handler
//ValidationApp.java
package com.javabrahman.rest.validation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
record NewBook(
@NotBlank @Size(max = 100) String title,
@NotBlank String author,
@NotNull @Isbn String isbn,
@NotNull @Min(1450) @Max(2100) Integer year,
@NotNull @Positive BigDecimal price,
@Email String publisherEmail,
@NotEmpty List<@NotBlank String> tags) {
}
@RestController
@RequestMapping("/api/books")
class BookController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
String create(@Valid @RequestBody NewBook book) {
return "created: " + book.title() + " (" + book.isbn() + ")";
}
@GetMapping
String list(@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) @Isbn String isbn) {
return "page size " + size + (isbn == null ? "" : ", isbn " + isbn);
}
// A failed @Valid @RequestBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
Map<String, String> invalidBody(MethodArgumentNotValidException e) {
Map<String, String> errors = new TreeMap<>(); // sorted by field name
e.getBindingResult().getFieldErrors().forEach(error -> errors.merge(error.getField(), error.getDefaultMessage(), (a, b) -> a + "; " + b));
return errors;
}
// Failed constraints on other method parameters (@RequestParam, @PathVariable)
@ExceptionHandler(HandlerMethodValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
Map<String, String> invalidParameters(HandlerMethodValidationException e) {
Map<String, String> errors = new TreeMap<>();
e.getParameterValidationResults().forEach(result -> errors.put(result.getMethodParameter().getParameterName(),
result.getResolvableErrors().getFirst().getDefaultMessage()));
return errors;
}
}
@SpringBootApplication
public class ValidationApp {
public static void main(String[] args) {
SpringApplication.run(ValidationApp.class, args);
}
}
Validating a request body with @Valid
A valid book is accepted. Then the same request is sent with many mistakes at once - a blank title, a missing author, a wrong ISBN check digit, a negative price, a malformed email and a blank tag - OUTPUT of a valid and an invalid book> curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"978-0-13-468599-1","year":2018,"price":45.99,"publisherEmail":"orders@example.com","tags":["java","best practices"]}'
HTTP/1.1 201
Content-Length: 43
Content-Type: text/plain; charset=UTF-8
created: Effective Java (978-0-13-468599-1)
> curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":" ","isbn":"978-0-13-468599-2","year":2018,"price":-5,"publisherEmail":"orders-at-example.com","tags":["java"," "]}'
HTTP/1.1 400
Content-Length: 215
Content-Type: application/json
{"author":"must not be blank","isbn":"must be a valid 13-digit ISBN","price":"must be greater than 0","publisherEmail":"must be a well-formed email address","tags[1]":"must not be blank","title":"must not be blank"}
Explanation of the output
@Validon the@RequestBodymade Spring validate the record after converting the JSON. The valid book reached the method and was created.- For the invalid book, the method was never called. Spring collected every violation, not just the first, and threw
MethodArgumentNotValidException, which our handler turned into a400response listing each field with its message. - The ISBN differs from the valid one only in its last digit, so it was rejected by the check-digit calculation of the custom constraint.
List<@NotBlank String>validates each element: the blank second tag was reported astags[1].- Without our handler, Spring Boot would still answer
400, but with its default error body and no field details. Moving this handler into a@RestControllerAdviceapplies it to every controller, and the standardProblemDetailformat gives a consistent error body for all errors - both covered in REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail.
Validating path variables and query parameters
Constraints can also go straight on@RequestParam and @PathVariable parameters, as on the size and isbn parameters of list(). Since Spring Framework 6.1, Spring MVC applies them by itself - built-in method validation - with no @Validated annotation on the controller, and reports failures as HandlerMethodValidationException -
OUTPUT of validated query parameters> curl -i http://localhost:8080/api/books
HTTP/1.1 200
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
page size 20
> curl -i http://localhost:8080/api/books?size=50&isbn=9781617293566
HTTP/1.1 200
Content-Length: 32
Content-Type: text/plain; charset=UTF-8
page size 50, isbn 9781617293566
> curl -i http://localhost:8080/api/books?size=500&isbn=12345
HTTP/1.1 400
Content-Length: 83
Content-Type: application/json
{"isbn":"must be a valid 13-digit ISBN","size":"must be less than or equal to 100"}
@Max(100), and the second handler reported both broken parameters by name. (Older code puts @Validated on the controller class for this, which switches on a proxy-based validation that throws ConstraintViolationException instead; with built-in method validation it is no longer needed.)
Writing a custom constraint
Built-in constraints cannot check that an ISBN's last digit is correct, so the example defines its own@Isbn constraint, in two parts -
- The annotation,
Isbn: meta-annotated with@Constraint(validatedBy = IsbnValidator.class), with the three attributes every constraint must have -message,groupsandpayload. Its@TargetincludesRECORD_COMPONENTandPARAMETER, so it works on the record and on method parameters alike. - The validator,
IsbnValidator: implementsConstraintValidator<Isbn, String>with one method,isValid(). It strips hyphens, requires 13 digits, and computes the ISBN-13 check digit - the sum of the first 12 digits weighted 1, 3, 1, 3 and so on, subtracted from the next multiple of 10. - The validator returns
truefornull, leaving "is it required?" to@NotNull. That is the convention for all constraints, and it makes@Isbnusable on optional fields, such as theisbnquery parameter. - Validators are created by Spring, so a custom validator can have dependencies injected through its constructor - for example a repository, to check that a value is unique.
Messages, groups and validating in your own code
- Messages: each constraint has a default message, which
message = "..."replaces. Messages can contain the constraint's attributes, as in@Size(max = 100, message = "must be at most {max} characters"), and can come from aValidationMessages.propertiesfile - with translations such asValidationMessages_de.properties- by using a key:message = "{book.title.size}". - Cross-field rules, such as "the end date must be after the start date", need a constraint on the class or record itself, written the same way as
@IsbnwithElementType.TYPEas its target. - Groups apply different rules in different situations - for example
idmust be null when creating but present when updating. Assign constraints to marker interfaces withgroups = OnCreate.classand select them with@Validated(OnCreate.class)instead of@Valid. - Validating in your own code: inject
jakarta.validation.Validatorand callvalidator.validate(object), which returns the set of violations - useful for data that does not come through a controller, such as a file import or a message from a queue. - Validate at the boundary: validating request data in the controller keeps the rest of the application free to assume its input is valid. Business rules that need the database or other services - "this ISBN is not already in the catalogue" - belong in the service layer.
Summary
In this tutorial we first saw what Bean Validation is, added it withspring-boot-starter-validation, and listed the main built-in constraints. We then put constraints on a request record, validated it with @Valid, and returned every field error in a sorted 400 response, including an element of a list. Next we validated query parameters directly with Spring's built-in method validation and HandlerMethodValidationException. We then wrote a custom @Isbn constraint with a validator that checks the check digit, and lastly covered messages, cross-field rules, groups and validating in your own code. The next tutorial moves error handling out of the controller and into one place: @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail.Spring REST API Tutorials
ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces
ResponseEntity and HTTP Status Codes in Spring BootClick to read tutorial: ResponseEntity and HTTP Status Codes in Spring Boot REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail Building a REST API with Spring Boot and @RestControllerClick to read tutorial: Building a REST API with Spring Boot and @RestController Spring MVC Request Mapping: Paths, Params and BodiesClick to read tutorial: Spring MVC Request Mapping: Paths, Params and Bodies API Versioning in Spring Framework 7 and Spring Boot 4Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4 Pagination, Sorting and Filtering in Spring Boot REST APIsClick to read tutorial: Pagination, Sorting and Filtering in Spring Boot REST APIs Calling REST Services with RestClient and HTTP InterfacesClick to read tutorial: Calling REST Services with RestClient and HTTP Interfaces