Skip to content
CampusEduX

Validation and Errors · Lesson 44 of 95

Bean Validation

Bean Validation in Spring Boot explained: use @Valid, @NotBlank, @Size and @Min on request records and return clear 400 errors in a clinic booking example.

9 min read

A hospital reception desk has a form. The clerk will not accept it if the name is blank, the phone number has six digits, or the visit date is yesterday. She checks each field before the form goes into the file. Your API needs the same clerk. Bean Validation is that clerk for Spring Boot: you write the rules once, as annotations, and Spring checks every incoming request.

Let's see what Bean Validation is, which annotations you will use every week, and how to build an appointment API that refuses bad data with clear messages.

What is Bean Validation?

The rules live in the jakarta.validation.constraints package. You put them on the fields of a request class or record. Then you add @Valid in front of the controller parameter, and Spring Boot runs the check before your method body starts.

In Spring Boot 4 the starter is named spring-boot-starter-validation. It is not included in the web starter, so you add it yourself.

Why is it used?

Never trust data that arrives from outside. Users make typing mistakes, and some people send bad data on purpose. If you skip validation, you meet these problems:

  • Bad data in the database. A blank name or an age of minus five gets saved and breaks reports later.
  • Crashes. A missing value becomes a NullPointerException deep inside your code.
  • Security holes. Unchecked input is the start of many attacks.
  • Repeated `if` blocks. Without annotations, every method begins with ten lines of checks.

With Bean Validation, the rules sit next to the fields they protect. Anyone reading the class sees the rules at once, and every endpoint that uses the class gets the same checks.

How it works

Here is the path of a booking request.

text
Client sends JSON | v Jackson builds the record | v @Valid triggers the validator | checks every annotation v Any rule broken? | | yes no | | v v 400 Bad Controller method Request runs normally

Jackson first turns the JSON body into a Java object. Because the parameter has @Valid, Spring hands that object to the validator. If any rule fails, Spring throws an exception named in the next paragraph, and the controller method never runs. The reply is a 400 status. If all rules pass, your method runs as usual.

The exception has a long name, so here it is on its own line:

java
MethodArgumentNotValidException

Real-Life Example

At a railway ticket counter, the clerk looks at your form. Name present? Age between 1 and 120? Journey date not in the past? If a box is wrong, she hands the form back with a red circle on it. She does not wait for the train to leave before she finds the mistake. Bean Validation does that check at the counter, before your business code starts work.

Code Example

CityCare Clinic lets patients book appointments. The request must have a name, a valid Indian mobile number, an email, a sensible age and a future visit date. We need the web starter and the validation starter.

text
citycare-clinic/ ├─ pom.xml └─ src/main/java/ com/citycare/clinic/ ├─ ClinicApplication.java ├─ AppointmentRequest.java └─ AppointmentController.java

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.citycare</groupId> <artifactId>clinic</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: ClinicApplication.java in package com.citycare.clinic

java
package com.citycare.clinic; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ClinicApplication { public static void main(String[] args) { SpringApplication.run(ClinicApplication.class, args); } }

File: AppointmentRequest.java in package com.citycare.clinic

java
package com.citycare.clinic; import java.time.LocalDate; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Future; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record AppointmentRequest( @NotBlank(message = "Patient name is required") @Size(min = 2, max = 40, message = "Name must be 2 to 40 characters") String patientName, @Pattern(regexp = "[6-9][0-9]{9}", message = "Phone must be a 10 digit Indian mobile number") String phone, @Email(message = "Email is not valid") String email, @Min(value = 1, message = "Age must be at least 1") @Max(value = 120, message = "Age must be at most 120") int age, @Future(message = "Visit date must be in the future") LocalDate visitDate) { }

File: AppointmentController.java in package com.citycare.clinic

java
package com.citycare.clinic; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.context.MessageSourceResolvable; 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.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.method.annotation.HandlerMethodValidationException; @RestController public class AppointmentController { @PostMapping("/appointments") @ResponseStatus(HttpStatus.CREATED) public String book(@Valid @RequestBody AppointmentRequest request) { return "Booked " + request.patientName() + " for " + request.visitDate(); } @GetMapping("/appointments/slots") public String slots(@RequestParam @Min(1) @Max(10) int count) { return count + " slots shown"; } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> onInvalidBody(MethodArgumentNotValidException ex) { Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); return errors; } @ExceptionHandler(HandlerMethodValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public List<String> onInvalidParam(HandlerMethodValidationException ex) { return ex.getAllErrors().stream().map(MessageSourceResolvable::getDefaultMessage).toList(); } }

First send a good request, then a bad one:

bash
mvn spring-boot:run curl -X POST localhost:8080/appointments \ -H "Content-Type: application/json" \ -d '{"patientName":"Asha Rao","phone":"9876543210","email":"asha@example.com","age":34,"visitDate":"2030-01-15"}'

Output:

text
Booked Asha Rao for 2030-01-15

The reply status is 201. Now send a request where every field is wrong:

bash
curl -X POST localhost:8080/appointments \ -H "Content-Type: application/json" \ -d '{"patientName":" ","phone":"12345","email":"not-an-email","age":0,"visitDate":"2020-01-01"}'

Output:

json
{ "age": "Age must be at least 1", "phone": "Phone must be a 10 digit Indian mobile number", "patientName": "Name must be 2 to 40 characters", "email": "Email is not valid", "visitDate": "Visit date must be in the future" }

The reply has status 400 and is spaced out here. The name broke two rules, @NotBlank and @Size, but this small handler keeps one message per field, so you see one. Field order in the reply may differ on your machine.

Code Explained

  • AppointmentRequest is a record. The annotations sit on its components, and each one carries a message that the user will read.
  • @NotBlank fails for null, empty and spaces-only text. @Size limits the length. @Pattern matches a regular expression. @Email checks the email shape.
  • @Min and @Max limit numbers, and @Future needs a date after today.
  • @Valid @RequestBody in the controller is what switches the check on. Without @Valid the annotations do nothing.
  • @RequestParam @Min(1) @Max(10) int count puts rules on a single query value. In Spring 7 this works without any class-level annotation.
  • The two @ExceptionHandler methods turn the failures into readable JSON. Without them, the client gets only a bare 400, as you will see below. The next topics cover better ways to do this for the whole app.

Calling slots?count=50 returns:

json
["must be less than or equal to 10"]

What Happens Without a Handler?

Spring Boot's default error reply for a failed validation is short. It has the status, but it does not say which field was wrong:

json
{ "timestamp": "2026-09-26T21:31:36.652Z", "status": 400, "error": "Bad Request", "path": "/appointments" }

So your handler, or a global one, decides how helpful the reply is.

Common Constraint Annotations

AnnotationChecksWorks on
@NotNullValue is not nullAny object
@NotBlankNot null, and has a non-space characterText
@NotEmptyNot null and not emptyText, lists, maps
@Size(min, max)Length or item countText, lists
@Min and @MaxNumber rangeNumbers
@PositiveGreater than zeroNumbers
@EmailLooks like an emailText
@PatternMatches a regexText
@Past and @FutureDate is before or after nowDates

Most constraints treat null as valid. Only @NotNull, @NotBlank and @NotEmpty reject it, so add one of them for required fields.

Common Mistakes

  • Forgetting the validation starter. With only the web starter, the validation library is not on the classpath, so the annotations do not even compile.
  • Forgetting `@Valid`. The annotations on your class are silent until the parameter has @Valid.
  • Using `@NotNull` on text. A string of spaces passes @NotNull. Use @NotBlank for text.
  • Nested objects not checked. To validate a list of items inside a request, mark the list items or the field with @Valid too, as the practice section shows.
  • Trusting the frontend. A React form may validate, but any client can skip it. Always validate on the server.

Interview Questions

What is the difference between @NotNull, @NotEmpty and @NotBlank?

Ans:@NotNull rejects null only. @NotEmpty also rejects an empty text or collection. @NotBlank is for text and also rejects text made only of spaces.

What does @Valid do?

Ans:It tells Spring to run the validator on that object, and on nested objects that are also marked, before the method body runs.

Which exception is thrown when a request body fails validation?

Ans:It is the argument-not-valid exception. It holds a BindingResult with every broken rule.

Where does the validation engine come from?

Ans:Bean Validation is a specification. Hibernate Validator is the implementation that comes with the validation starter.

Key Points to Remember

  • Put rules on fields with annotations from jakarta.validation.constraints.
  • Add the validation starter, and add @Valid on the parameter to turn the checks on.
  • A failed body check throws the argument-not-valid exception, and the reply is 400.
  • Use @NotBlank for required text and @Positive or @Min for numbers.
  • Give each rule a clear message.
  • Validate on the server even if the frontend validates too.

Frequently Asked Questions

Why is my Bean Validation not working in Spring Boot?

The usual causes are a missing validation starter or a missing @Valid on the controller parameter. Check both first.

Can I validate query parameters and path variables?

Yes. Put the constraint next to the parameter, for example @PathVariable @Positive long id. A broken rule gives a 400 reply.

How do I show all errors and not only the first?

Read every field error from the BindingResult in a handler, as we did with a map of field names to messages.

Do I write the error messages in the annotation?

You can, with the message attribute. Larger apps keep them in a message file so they can be translated.

Practice Problems

Try each problem on your own first. Both use the web starter and the validation starter.

Easy: CinePoint Seat Booking

CinePoint accepts POST /bookings with movie and seats. The movie must not be blank, and seats must be from 1 to 6. On a broken rule, reply 400 with the first message as plain text. Otherwise reply 201 with <n> seats booked for <movie>.

Show answer
The rules live on the record. The BindingResult after @Valid @RequestBody keeps control in the method, so we build the 400 reply by hand.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.cinepoint</groupId> <artifactId>seats</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: SeatsApplication.java in package com.cinepoint.seats

java
package com.cinepoint.seats; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SeatsApplication { public static void main(String[] args) { SpringApplication.run(SeatsApplication.class, args); } }

File: BookingRequest.java in package com.cinepoint.seats

java
package com.cinepoint.seats; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; public record BookingRequest( @NotBlank(message = "Movie name is required") String movie, @Min(value = 1, message = "Book at least 1 seat") @Max(value = 6, message = "You can book at most 6 seats") int seats) { }

File: BookingController.java in package com.cinepoint.seats

java
package com.cinepoint.seats; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class BookingController { @PostMapping("/bookings") public ResponseEntity<String> book(@Valid @RequestBody BookingRequest request, BindingResult result) { if (result.hasErrors()) { String message = result.getFieldErrors().get(0).getDefaultMessage(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); } return ResponseEntity.status(HttpStatus.CREATED) .body(request.seats() + " seats booked for " + request.movie()); } }

Sending nine seats:

bash
curl -X POST localhost:8080/bookings \ -H "Content-Type: application/json" \ -d '{"movie":"Monsoon Express","seats":9}'

prints the message, with status 400:

bash
You can book at most 6 seats [400]

Medium: SweetOven Cake Order with Nested Lines

A bakery order has a customer name and a list of lines, and each line has a cake name and a quantity. Rules: the customer is required, the list has at least one line, every cake name is required and every quantity is positive. A bad request must return 400 with a map from field path, such as lines[0].quantity, to message. Also reject an order id that is zero or negative in GET /orders/{id}.

Show answer
Nested checks only run when the nested type is marked with @Valid. The field path in each error tells the client exactly which line is wrong.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.sweetoven</groupId> <artifactId>orders</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: OrdersApplication.java in package com.sweetoven.orders

java
package com.sweetoven.orders; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class OrdersApplication { public static void main(String[] args) { SpringApplication.run(OrdersApplication.class, args); } }

File: CakeOrder.java in package com.sweetoven.orders

java
package com.sweetoven.orders; import java.util.List; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; public record CakeOrder( @NotBlank(message = "Customer name is required") String customer, @NotEmpty(message = "Add at least one cake") List<@Valid Line> lines) { public record Line( @NotBlank(message = "Cake name is required") String cake, @Positive(message = "Quantity must be positive") int quantity) { } }

File: OrderController.java in package com.sweetoven.orders

java
package com.sweetoven.orders; import java.util.Map; import java.util.TreeMap; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController public class OrderController { @PostMapping("/orders") @ResponseStatus(HttpStatus.CREATED) public String place(@Valid @RequestBody CakeOrder order) { return "Order for " + order.customer() + " has " + order.lines().size() + " line(s)"; } @GetMapping("/orders/{id}") public String find(@PathVariable @Positive long id) { return "Order " + id; } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> onInvalid(MethodArgumentNotValidException ex) { Map<String, String> errors = new TreeMap<>(); ex.getBindingResult().getFieldErrors() .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); return errors; } }

Posting an order with a blank customer, a zero quantity and a blank cake name returns:

json
{ "customer": "Customer name is required", "lines[0].quantity": "Quantity must be positive", "lines[1].cake": "Cake name is required" }

The JSON is spaced out here. A request to /orders/-5 returns status 400.