Validation and Errors · Lesson 45 of 95
Custom Validation Annotation
Custom validation annotation in Spring Boot: write your own @Constraint and ConstraintValidator, check pincodes and date ranges, and inject Spring beans.
Every country has its own rules for data. An Indian pincode has six digits and never starts with zero. A vehicle plate looks like MH12AB1234. A hotel stay cannot end before it begins. The built-in annotations such as @NotBlank and @Min know nothing about these business rules. So what do you do? You write your own annotation, and use it as easily as the built-in ones.
In this guide you will build a custom validation annotation for a hotel booking API. You will make a field rule for pincodes and a class rule that compares two dates.
What is a Custom Validation Annotation?
It always has two pieces:
- The annotation. It carries the default error message and a link to the validator. It has no logic.
- The validator. A class that implements
ConstraintValidatorand contains theisValidmethod, where the real check lives.
If you have already used @Valid with the built-in annotations, this is the natural next step. The only new thing is that you supply the rule.
Why is it used?
Built-in annotations cover general checks. Real apps have their own rules:
- Business formats. Pincodes, GST numbers, vehicle plates, roll numbers.
- Rules across fields. Check-out after check-in, or the password field equal to its confirmation.
- Rules that need data. An email that is not registered yet, or a coupon that has not expired.
- Reuse. Write the rule once, and use it on ten request classes.
Without a custom annotation, you copy the same if statements into every controller. With it, one class holds the rule, and every place that uses the annotation stays in sync.
How it works
Here is what happens when a request with a custom annotation arrives.
textJSON body arrives | v @Valid starts validation | v Validator engine reads @IndianPincode on the field | v Finds PincodeValidator | isValid(value, context) v true = pass, false = violation | v Violations become a 400 reply
The engine sees your annotation, follows the @Constraint(validatedBy = ...) link, and creates the validator class. It then calls isValid with the value. If the method returns false, the engine records a violation with your message. All violations together become the MethodArgumentNotValid failure, and the client gets a 400.
Real-Life Example
Think of a bank's account opening desk. The general form checks are the same everywhere: name filled, age a number. But this bank also has its own rule: an account holder's PAN must match a special pattern, and the father's name must differ from the branch manager's. The bank prints a new stamp for this rule and gives it to every clerk. Your custom annotation is that stamp. Any clerk, on any form, can use it, and it always checks the same way.
Code Example
StayWell is a small hotel chain. A booking has a guest name, a pincode and two dates. We write the field annotation @IndianPincode and a class annotation @ValidStay. We need the web starter and the validation starter.
textstaywell-booking/ ├─ pom.xml └─ src/main/java/ com/staywell/booking/ ├─ BookingApplication.java ├─ IndianPincode.java ├─ PincodeValidator.java ├─ ValidStay.java ├─ StayValidator.java ├─ BookingRequest.java └─ BookingController.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.staywell</groupId> <artifactId>booking</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: BookingApplication.java in package com.staywell.booking
javapackage com.staywell.booking; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class BookingApplication { public static void main(String[] args) { SpringApplication.run(BookingApplication.class, args); } }
File: IndianPincode.java in package com.staywell.booking
javapackage com.staywell.booking; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import jakarta.validation.Constraint; import jakarta.validation.Payload; @Documented @Constraint(validatedBy = PincodeValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface IndianPincode { String message() default "Pincode must be 6 digits and cannot start with 0"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
File: PincodeValidator.java in package com.staywell.booking
javapackage com.staywell.booking; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class PincodeValidator implements ConstraintValidator<IndianPincode, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) { return true; } return value.matches("[1-9][0-9]{5}"); } }
File: ValidStay.java in package com.staywell.booking
javapackage com.staywell.booking; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import jakarta.validation.Constraint; import jakarta.validation.Payload; @Documented @Constraint(validatedBy = StayValidator.class) @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ValidStay { String message() default "Check-out must be after check-in"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
File: StayValidator.java in package com.staywell.booking
javapackage com.staywell.booking; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class StayValidator implements ConstraintValidator<ValidStay, BookingRequest> { @Override public boolean isValid(BookingRequest request, ConstraintValidatorContext context) { if (request.checkIn() == null || request.checkOut() == null) { return true; } if (request.checkOut().isAfter(request.checkIn())) { return true; } context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()) .addPropertyNode("checkOut") .addConstraintViolation(); return false; } }
File: BookingRequest.java in package com.staywell.booking
javapackage com.staywell.booking; import java.time.LocalDate; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @ValidStay public record BookingRequest( @NotBlank(message = "Guest name is required") String guest, @IndianPincode String pincode, @NotNull(message = "Check-in date is required") LocalDate checkIn, @NotNull(message = "Check-out date is required") LocalDate checkOut) { }
File: BookingController.java in package com.staywell.booking
javapackage com.staywell.booking; import java.util.Map; import java.util.TreeMap; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; 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 BookingController { @PostMapping("/bookings") @ResponseStatus(HttpStatus.CREATED) public String book(@Valid @RequestBody BookingRequest request) { return "Room booked for " + request.guest(); } @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())); ex.getBindingResult().getGlobalErrors() .forEach(e -> errors.put(e.getObjectName(), e.getDefaultMessage())); return errors; } }
Send a good booking first:
bashmvn spring-boot:run curl -X POST localhost:8080/bookings \ -H "Content-Type: application/json" \ -d '{"guest":"Neel Kumar","pincode":"411001","checkIn":"2030-03-01","checkOut":"2030-03-04"}'
Output:
textRoom booked for Neel Kumar
The status is 201. Now a booking with a pincode that starts with zero and dates in the wrong order:
bashcurl -X POST localhost:8080/bookings \ -H "Content-Type: application/json" \ -d '{"guest":"Neel Kumar","pincode":"012345","checkIn":"2030-03-04","checkOut":"2030-03-01"}'
Output:
json{ "checkOut": "Check-out must be after check-in", "pincode": "Pincode must be 6 digits and cannot start with 0" }
The reply has status 400 and is spaced out here so it is easy to read.
Code Explained
@Constraint(validatedBy = PincodeValidator.class)links the annotation to its checking class.@Targetsays where the annotation may sit.IndianPincodegoes on fields and parameters.ValidStaygoes on a type, which makes it a class-level rule.@Retention(RUNTIME)keeps the annotation alive when the app runs. Without it, the validator engine cannot see it.- The three members
message,groupsandpayloadare required by the Bean Validation rules, so every constraint annotation declares them. - The validator implements
ConstraintValidatorwith two type arguments: the annotation, and the type it checks. Ours means "I checkIndianPincodeonStringvalues". PincodeValidatorreturns true for null. That is the usual habit: a null field is the job of@NotNullor@NotBlank, and one annotation should check one thing.StayValidatorreceives the wholeBookingRequest, so it can compare two fields. When the dates are wrong, it builds a violation and attaches it to thecheckOutproperty, so the client sees the message next to the right field.- The context call that disables the default violation removes the general class-level message, so only our field-level message appears.
Field Rules and Class Rules
| Kind | Placed on | Validates | Good for |
|---|---|---|---|
| Field rule | A field or parameter | One value | Formats such as pincode or plate |
| Class rule | The type | The whole object | Two dates, password and confirmation |
Common Mistakes
- Missing `@Retention(RUNTIME)`. The annotation exists in the source but the engine cannot see it, and nothing is validated.
- Missing `message`, `groups` or `payload`. The engine refuses to load the constraint.
- Returning false for null. Then an optional field can never be empty. Return true for null and add
@NotNullwhere the field is required. - Forgetting `@Valid`. As with built-in rules, custom ones run only when the controller parameter has
@Valid. - One giant annotation. A rule that checks five different things gives confusing messages. Write small annotations and combine them.
Interview Questions
What are the steps to create a custom validation annotation?
Ans:Create an annotation marked with @Constraint, giving message, groups and payload. Create a class that implements ConstraintValidator with an isValid method. Then put the annotation on a field or class and use @Valid.
How do you validate two fields together?
Ans:Make a class-level annotation. Its validator receives the whole object and can compare the fields.
Can a validator use Spring beans?
Ans:Yes. Spring creates validator classes through its own factory, so you can inject a bean through the constructor. The practice section checks an email against a registry bean.
Why should a validator return true for null?
Ans:It keeps each annotation focused. Whether a value is required is decided by @NotNull, and the custom annotation only checks the format.
Key Points to Remember
- A custom validation annotation is an annotation plus a validator class.
- The annotation needs
@Constraint,@Target,@Retention(RUNTIME),message,groupsandpayload. - The validator implements
ConstraintValidatorand puts the logic inisValid. - Class-level annotations can compare several fields.
- Return true for null and leave required-checks to
@NotNull. - Validators can receive Spring beans through the constructor.
Frequently Asked Questions
How do I create a custom validation annotation in Spring Boot?
Define the annotation with @Constraint(validatedBy = ...), write a validator class with isValid, and place the annotation on a field. Add @Valid on the controller parameter.
Can I show a different message for each use?
Yes. The message you give when using the annotation replaces the default one, for example @IndianPincode(message = "Bad pincode").
How do I compare two fields, such as password and confirm password?
Use a class-level annotation like @ValidStay, and inside the validator compare both values.
Why is my custom annotation ignored?
Check three things: @Retention(RUNTIME) is present, the parameter has @Valid, and the validation starter is in your pom.xml.
Related Topics
- Bean Validation: learn the built-in annotations first.
- @ControllerAdvice and @ExceptionHandler: return validation errors in one shared place.
- Standard API Error Response: shape the JSON that carries your messages.
- DTO Pattern: keep rules on request classes.
Practice Problems
Try each problem on your own first. Both use the web starter and the validation starter.
Easy: Wheelo Vehicle Number
Wheelo rents bikes. Create an annotation @VehicleNumber that accepts numbers like MH12AB1234: two capital letters, two digits, one to three capital letters, then four digits. Use it in POST /rentals. A wrong number returns 400 with the message Use a number like MH12AB1234.
Show answerHide answer
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.wheelo</groupId> <artifactId>rentals</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: RentalsApplication.java in package com.wheelo.rentals
javapackage com.wheelo.rentals; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class RentalsApplication { public static void main(String[] args) { SpringApplication.run(RentalsApplication.class, args); } }
File: VehicleNumber.java in package com.wheelo.rentals
javapackage com.wheelo.rentals; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import jakarta.validation.Constraint; import jakarta.validation.Payload; @Constraint(validatedBy = VehicleNumberValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface VehicleNumber { String message() default "Use a number like MH12AB1234"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
File: VehicleNumberValidator.java in package com.wheelo.rentals
javapackage com.wheelo.rentals; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class VehicleNumberValidator implements ConstraintValidator<VehicleNumber, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { return value == null || value.matches("[A-Z]{2}[0-9]{2}[A-Z]{1,3}[0-9]{4}"); } }
File: RentalRequest.java in package com.wheelo.rentals
javapackage com.wheelo.rentals; import jakarta.validation.constraints.NotBlank; public record RentalRequest( @NotBlank(message = "Rider name is required") String rider, @VehicleNumber String vehicleNumber) { }
File: RentalController.java in package com.wheelo.rentals
javapackage com.wheelo.rentals; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; 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 RentalController { @PostMapping("/rentals") @ResponseStatus(HttpStatus.CREATED) public String rent(@Valid @RequestBody RentalRequest request) { return request.vehicleNumber() + " rented to " + request.rider(); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public String onInvalid(MethodArgumentNotValidException ex) { return ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); } }
Posting MH12AB1234 gives status 201 and MH12AB1234 rented to Vikram. Posting mh12-1234 gives:
bashUse a number like MH12AB1234 [400]
Medium: PagePoint New Member Email
A library app registers members with POST /members. The email must be valid and must not already be in the member list. Write @NewEmail, whose validator asks a Spring bean, MemberRegistry, whether the email exists. The check should ignore letter case. Show errors as a map from field name to message.
Show answerHide answer
asha@example.com, and saves each new email after a successful join.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.pagepoint</groupId> <artifactId>members</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: MembersApplication.java in package com.pagepoint.members
javapackage com.pagepoint.members; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class MembersApplication { public static void main(String[] args) { SpringApplication.run(MembersApplication.class, args); } }
File: MemberRegistry.java in package com.pagepoint.members
javapackage com.pagepoint.members; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Component; @Component public class MemberRegistry { private final Set<String> emails = ConcurrentHashMap.newKeySet(); public MemberRegistry() { emails.add("asha@example.com"); } public boolean exists(String email) { return emails.contains(email.toLowerCase()); } public void add(String email) { emails.add(email.toLowerCase()); } }
File: NewEmail.java in package com.pagepoint.members
javapackage com.pagepoint.members; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import jakarta.validation.Constraint; import jakarta.validation.Payload; @Constraint(validatedBy = NewEmailValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface NewEmail { String message() default "This email is already registered"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
File: NewEmailValidator.java in package com.pagepoint.members
javapackage com.pagepoint.members; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; public class NewEmailValidator implements ConstraintValidator<NewEmail, String> { private final MemberRegistry registry; public NewEmailValidator(MemberRegistry registry) { this.registry = registry; } @Override public boolean isValid(String email, ConstraintValidatorContext context) { return email == null || !registry.exists(email); } }
File: MemberRequest.java in package com.pagepoint.members
javapackage com.pagepoint.members; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record MemberRequest( @NotBlank(message = "Name is required") String name, @Email(message = "Email is not valid") @NewEmail String email) { }
File: MemberController.java in package com.pagepoint.members
javapackage com.pagepoint.members; import java.util.Map; import java.util.TreeMap; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; 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 MemberController { private final MemberRegistry registry; public MemberController(MemberRegistry registry) { this.registry = registry; } @PostMapping("/members") @ResponseStatus(HttpStatus.CREATED) public String join(@Valid @RequestBody MemberRequest request) { registry.add(request.email()); return "Welcome, " + request.name(); } @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; } }
Registering meera@example.com works the first time. Registering ASHA@example.com fails, and so does a second meera@example.com, each with status 400:
bash{"email":"This email is already registered"} [400]