Custom validation lets you write your own rule when the standard annotations (@NotBlank, @Min, @Email) are not enough.It is used for application-specific rules, such as “a student is eligible only if the age is 17 to 25 and the marks reach the course cutoff”.You create an annotation, link it to a validator class that holds the logic, and put the annotation on your model.Spring runs it with @Valid like any standard rule, and a failure produces the same 400 response with a field-level message.
Features
- Your own annotation (
@AdmissionEligible) that works exactly like the built-in ones. - Rules can use several fields together (age, marks and course), which standard annotations can’t do.
- Messages are attached to a specific field, so the client sees
"marks": "...". - Reusable: the annotation can go on any class.
- Runs after the standard rules, and skips fields they already reported as missing.
- No extra classes: the validator is nested inside the annotation file.
Custom Validation Types
| Type | How it works | Used for |
|---|---|---|
| Field-level custom annotation | The annotation sits on one field, and the validator checks that single value. | A single-value rule, such as a mobile number or an ID format |
| Class-level custom annotation | The annotation sits on the class, and the validator sees the whole object. | A rule across several fields, such as age + marks + course |
| Programmatic validation in the service | You write an if check and throw an exception yourself. |
A rule that needs the database, such as “email already exists” |
Hands-on Experiment: Creating a spring boot application with Custom Validation
The following steps are involved in developing this hands-on experiment.
Step 1: Generate the Project
Step 2: Project Structure
Step 3: pom.xml
Step 4: application.properties
Step 5: Create Entity Class
Step 6: Create Repository Interface
Step 7: Create Service Class
Step 8: Create Controller Class
Step 9: Main Application Class
Step 10: Run the Main Application Class
Step 1: Configure the Project on Spring Initializr
Go to start.spring.io and set:
- Project: Maven
- Language: Java
- Spring Boot version: 3.2.x
- Group: com.example
- Artifact: customvalidation
- Name: customvalidation
- Package name: com.example.customvalidation
- Packaging: Jar
- Java: 17
- Dependencies: Spring Web, Spring Data JPA, MySQL Driver, Validation I/O.
Click Generate to download the ZIP, then extract and import it into your IDE as a Maven project.
Step 2: Generated Project Structure

Step 3: Explore pom.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>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>student-custom-validation-demo</artifactId>
<version>1.0.0</version>
<name>student-custom-validation-demo</name>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Step 4: application.properties
spring.application.name=student-custom-validation-demo
server.port=8082
spring.datasource.url=jdbc:mysql://localhost:3306/student_admission_db?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialectCode language: JavaScript (javascript)
Step 5: Create Entity Class
package com.example.customvalidation.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@Entity
@Table(name = "students")
@AdmissionEligible // our custom rule, checked after the standard ones
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Name is required")
private String name;
@NotNull(message = "Age is required")
private Integer age;
@NotNull(message = "Marks are required")
@Min(value = 0, message = "Marks must be between 0 and 100")
@Max(value = 100, message = "Marks must be between 0 and 100")
private Integer marks;
@NotBlank(message = "Course is required")
private String course;
public Student() {
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public Integer getMarks() { return marks; }
public void setMarks(Integer marks) { this.marks = marks; }
public String getCourse() { return course; }
public void setCourse(String course) { this.course = course; }
}
Create below Annotation
package com.example.customvalidation.entity;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
// Custom validation annotation. Put it on the Student class to check admission eligibility.
// The validator logic is nested inside, so the whole custom rule lives in this one file.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AdmissionEligible.EligibilityValidator.class)
public @interface AdmissionEligible {
// These three members are required by Bean Validation for every custom annotation
String message() default "Student is not eligible for admission";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// The actual rule
class EligibilityValidator implements ConstraintValidator<AdmissionEligible, Student> {
@Override
public boolean isValid(Student student, ConstraintValidatorContext context) {
// Missing values are reported by @NotNull / @NotBlank, so skip them here
if (student.getAge() == null || student.getMarks() == null || student.getCourse() == null) {
return true;
}
context.disableDefaultConstraintViolation();
boolean valid = true;
// Rule 1: age must be 17 to 25
if (student.getAge() < 17 || student.getAge() > 25) {
addError(context, "age", "Age must be between 17 and 25 for admission");
valid = false;
}
// Rule 2: marks must reach the cutoff of the chosen course
Integer cutoff = switch (student.getCourse()) {
case "Engineering" -> 70;
case "Science" -> 65;
case "Commerce" -> 55;
case "Arts" -> 50;
default -> null;
};
if (cutoff == null) {
addError(context, "course", "Course must be one of: Engineering, Science, Commerce, Arts");
valid = false;
} else if (student.getMarks() < cutoff) {
addError(context, "marks", "Marks " + student.getMarks()
+ " are below the cutoff of " + cutoff + " for " + student.getCourse());
valid = false;
}
return valid;
}
// Attaches the message to a specific field, so the client sees field-level errors
private void addError(ConstraintValidatorContext context, String field, String message) {
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(field)
.addConstraintViolation();
}
}
}
Step 6: Create below Repository Interface
package com.example.customvalidation.repository;
import com.example.customvalidation.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
}
Step 7: Create below Service class
package com.example.customvalidation.service;
import com.example.customvalidation.entity.Student;
import com.example.customvalidation.repository.StudentRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class StudentService {
private final StudentRepository studentRepository;
public StudentService(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
public Student admit(Student student) {
student.setId(null); // a client must never choose the database id
return studentRepository.save(student);
}
public List<Student> getAll() {
return studentRepository.findAll();
}
}
Step 8: Create Controller class
package com.example.customvalidation.controller;
import com.example.customvalidation.entity.Student;
import com.example.customvalidation.service.StudentService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@RestController
@RequestMapping("/students")
public class StudentController {
private final StudentService studentService;
public StudentController(StudentService studentService) {
this.studentService = studentService;
}
// POST /students (@Valid runs the standard rules and our @AdmissionEligible rule)
@PostMapping
public ResponseEntity<Student> admit(@Valid @RequestBody Student student) {
return ResponseEntity.status(HttpStatus.CREATED).body(studentService.admit(student));
}
// GET /students
@GetMapping
public List<Student> getAll() {
return studentService.getAll();
}
// Validation failed -> 400 with { field: message }
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new TreeMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.merge(error.getField(), error.getDefaultMessage(),
(first, second) -> first + "; " + second));
return ResponseEntity.badRequest().body(errors);
}
}
Step 9: Main Application
package com.example.customvalidation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class StudentCustomValidationApplication {
public static void main(String[] args) {
SpringApplication.run(StudentCustomValidationApplication.class, args);
}
}
Step 10: Run the StudentCustomValidationApplication


