Creating Custom Validation Rules in Java
Course: Advanced Java Training
Topic: Creating Custom Validation Rules
Course Link: Advanced Java Training Course
Demo Link: Register for a Free Course Demo
Official Website: JustAcademy
1. Introduction to Custom Validation Rules
Custom validation rules in Java allow developers to define their own validation logic when standard validation annotations such as @NotNull, @NotBlank, @Size, and @Email are not sufficient. They help enforce application-specific business rules and ensure that incoming data meets the required conditions before it is processed or stored.
In Spring Boot applications, custom validation is commonly implemented using Jakarta Bean Validation, custom constraint annotations, and validator classes. These mechanisms integrate with DTOs, REST controllers, and service layers to provide reusable and maintainable validation logic.
Example Scenario
Suppose an employee registration application requires employee codes to follow the format EMP-1234. Java does not provide a standard annotation specifically for this business rule. A custom validation annotation can be created to validate the format automatically.
2. Learning Objectives
- Understand the purpose of custom validation in Java.
- Learn the basics of Jakarta Bean Validation.
- Create custom constraint annotations.
- Implement validation logic using
ConstraintValidator.
- Apply custom validation to DTOs and request objects.
- Integrate validation with Spring Boot REST controllers.
- Handle validation errors and return meaningful API responses.
- Follow best practices for reusable validation components.
3. What Is Bean Validation?
Bean Validation is a Java specification that defines a standard approach for validating object properties, method parameters, and return values. Modern Java applications commonly use Jakarta Bean Validation, implemented by providers such as Hibernate Validator.
Spring Boot applications can use Bean Validation to check incoming request data before executing business logic.
Common Built-in Validation Annotations
| Annotation |
Purpose |
Example |
@NotNull |
Prevents a value from being null. |
@NotNull private Long id; |
@NotBlank |
Requires a non-null string containing at least one non-whitespace character. |
@NotBlank private String name; |
@NotEmpty |
Requires a non-null string, collection, or supported container to contain an element. |
@NotEmpty private List<String> skills; |
@Size |
Checks the size or length of supported values. |
@Size(min=3, max=20) |
@Email |
Checks whether a string has a valid email-like format. |
@Email private String email; |
@Pattern |
Checks a string against a regular expression. |
@Pattern(regexp="^[A-Z]+$") |
@Min |
Defines a minimum numeric value. |
@Min(18) private int age; |
@Max |
Defines a maximum numeric value. |
@Max(65) private int age; |
Although these annotations cover many common cases, complex business rules may require custom validation.
4. When Should You Create Custom Validation Rules?
Custom validation is useful when validation depends on a specific business requirement that cannot be expressed clearly using built-in annotations alone.
- Employee code: Must follow a format such as
EMP-1234.
- Product code: Must begin with a specific prefix.
- Username: Must follow company-specific naming rules.
- Booking date: Must not be in the past.
- Age restriction: A user must meet a minimum age for a particular service.
- Business identifier: Must satisfy a domain-specific format or checksum.
- Cross-field validation: An end date must be later than a start date.
Choose the appropriate validation approach based on the requirement. Simple format rules may use @Pattern, while reusable business-specific rules may benefit from custom annotations and validator classes.
5. Understanding the Custom Validation Architecture
A typical custom Bean Validation constraint contains three important parts:
- Custom annotation: Defines the validation rule and its configuration.
- Validator class: Implements the logic that checks whether a value is valid.
- Target model: Applies the annotation to a DTO, entity, or other supported element.
When validation is triggered, the Bean Validation provider identifies the annotation and invokes its corresponding validator. If the value violates the rule, a constraint violation is produced.
6. Project Setup for Spring Boot
For a Spring Boot application, include the validation starter in your Maven project. Spring Boot's validation starter normally brings in a compatible Jakarta Bean Validation provider.
Maven Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Use a Spring Boot version compatible with Jakarta Validation. In modern Spring Boot applications, imports typically use the jakarta.validation package rather than the older javax.validation package.
7. Step 1: Create a Custom Validation Annotation
In this example, an employee code must follow the format EMP-1234. We will create an annotation named @ValidEmployeeCode.
ValidEmployeeCode.java
package com.example.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Documented
@Constraint(validatedBy = EmployeeCodeValidator.class)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
public @interface ValidEmployeeCode {
String message() default
"Employee code must follow the format EMP-1234";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Explanation of the Annotation
| Element |
Explanation |
@Constraint |
Connects the annotation to its validation implementation. |
validatedBy |
Specifies the validator class that performs the check. |
@Target |
Specifies where the annotation can be applied, such as fields and method parameters. |
@Retention(RUNTIME) |
Makes the annotation available at runtime for validation. |
message() |
Defines the default message when validation fails. |
groups() |
Supports grouping constraints for different validation scenarios. |
payload() |
Allows metadata to be associated with a constraint. |
The message(), groups(), and payload() methods are standard elements expected in a custom Bean Validation constraint annotation.
8. Step 2: Implement the ConstraintValidator Interface
The validator class contains the actual business rule. It implements ConstraintValidator with the custom annotation type and the type of value being validated.
EmployeeCodeValidator.java
package com.example.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class EmployeeCodeValidator
implements ConstraintValidator<ValidEmployeeCode, String> {
private static final String EMPLOYEE_CODE_PATTERN =
"^EMP-[0-9]{4}$";
@Override
public boolean isValid(
String value,
ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return value.matches(EMPLOYEE_CODE_PATTERN);
}
}
Understanding the Code
ConstraintValidator<ValidEmployeeCode, String> connects the annotation to a validator for string values.
isValid() is called by the validation provider to evaluate the supplied value.
- The regular expression
^EMP-[0-9]{4}$ requires the prefix EMP- followed by exactly four digits.
- Returning
true means the value satisfies this particular constraint.
- Returning
false means the value violates the constraint and normally produces a validation error.
Important: The validator returns true for null because this example checks only the format. If the field is mandatory, add @NotBlank separately. Keeping required-value checks separate makes validation rules more reusable.
9. Step 3: Apply the Custom Annotation to a DTO
A Data Transfer Object (DTO) defines the structure of the data accepted by an API. Applying validation annotations to a DTO helps reject invalid input before it reaches the main business logic.
EmployeeRequest.java
package com.example.dto;
import com.example.validation.ValidEmployeeCode;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class EmployeeRequest {
@NotBlank(message = "Employee name is required")
private String name;
@NotBlank(message = "Employee code is required")
@ValidEmployeeCode
private String employeeCode;
@NotBlank(message = "Email is required")
@Email(message = "Please provide a valid email address")
private String email;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmployeeCode() {
return employeeCode;
}
public void setEmployeeCode(String employeeCode) {
this.employeeCode = employeeCode;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
The employee code is subject to two independent constraints: @NotBlank checks that the value is present, and @ValidEmployeeCode checks its format.
10. Step 4: Trigger Validation in a REST Controller
In Spring Boot, use @Valid on the request body to trigger validation of the DTO before the controller method processes the request.
EmployeeController.java
package com.example.controller;
import com.example.dto.EmployeeRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/employees")
public class EmployeeController {
@PostMapping
public ResponseEntity<String> createEmployee(
@Valid @RequestBody EmployeeRequest request) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body("Employee details are valid");
}
}
When a request is received, Spring converts the JSON body into an EmployeeRequest object and runs its validation constraints. If validation succeeds, the method executes. If validation fails, Spring typically raises a request validation exception, which can be handled centrally.
11. Testing the Custom Validation Rule
Send a POST request to /api/employees with the following JSON.
Valid Request
{
"name": "Amit Sharma",
"employeeCode": "EMP-1234",
"email": "[email protected]"
}
Expected result: HTTP 201 Created, with the response message Employee details are valid.
Invalid Request
{
"name": "Amit Sharma",
"employeeCode": "EMP-12AB",
"email": "[email protected]"
}
Expected result: The request fails validation because EMP-12AB does not match the required format. Spring normally responds with HTTP 400 Bad Request when request-body validation fails and no custom exception handler changes that behavior.
Additional Test Cases
| Input |
Expected Result |
Reason |
EMP-1234 |
Valid |
Correct prefix and four digits. |
EMP-0001 |
Valid |
Four digits are allowed, including leading zeros. |
EMP-123 |
Invalid |
Only three digits are provided. |
EMP-12345 |
Invalid |
Five digits are provided. |
emp-1234 |
Invalid |
The prefix must be uppercase. |
1234 |
Invalid |
The required prefix is missing. |
null |
Valid for this rule alone |
Presence is handled by a separate constraint. |
12. Handling Validation Errors Globally
Centralized exception handling allows an API to return consistent error messages instead of exposing framework-generated error details directly.
GlobalExceptionHandler.java
package com.example.exception;
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.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException exception) {
Map<String, String> errors = new LinkedHashMap<>();
exception.getBindingResult()
.getFieldErrors()
.forEach(error ->
errors.putIfAbsent(
error.getField(),
error.getDefaultMessage()
)
);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errors);
}
}
Example Error Response
{
"employeeCode": "Employee code must follow the format EMP-1234"
}
The exact response depends on the request and the configured exception handling. The example uses a simple map of field names to messages. For larger applications, consider a standardized error structure containing a timestamp, status code, general message, and field-level errors.
13. Creating a Custom Validation Rule with Configurable Parameters
Custom annotations can also accept parameters. For example, you may want to validate product codes against a configurable prefix instead of hardcoding one prefix in the validator.
ValidProductCode.java
package com.example.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Documented
@Constraint(validatedBy = ProductCodeValidator.class)
@Target(FIELD)
@Retention(RUNTIME)
public @interface ValidProductCode {
String message() default "Invalid product code";
String prefix() default "PROD-";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
ProductCodeValidator.java
package com.example.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class ProductCodeValidator
implements ConstraintValidator<ValidProductCode, String> {
private String prefix;
@Override
public void initialize(ValidProductCode annotation) {
this.prefix = annotation.prefix();
}
@Override
public boolean isValid(
String value,
ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return value.matches(
"^" + java.util.regex.Pattern.quote(prefix)
+ "[0-9]{5}$"
);
}
}
Usage Example
@ValidProductCode(prefix = "ITEM-")
private String productCode;
This configuration accepts values such as ITEM-12345. The Pattern.quote() method treats the supplied prefix as a literal string in the regular expression, avoiding unintended regex interpretation of special characters.
14. Cross-Field Custom Validation
Some business rules involve comparing multiple fields rather than validating one value independently. For example, a booking's end date must be later than its start date.
For this type of rule, define the custom constraint with ElementType.TYPE so that it can be applied to the entire DTO or class. Its validator should inspect both properties.
BookingRequest.java
package com.example.dto;
import java.time.LocalDate;
import jakarta.validation.constraints.NotNull;
@ValidDateRange
public class BookingRequest {
@NotNull
private LocalDate startDate;
@NotNull
private LocalDate endDate;
public LocalDate getStartDate() {
return startDate;
}
public void setStartDate(LocalDate startDate) {
this.startDate = startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public void setEndDate(LocalDate endDate) {
this.endDate = endDate;
}
}
DateRangeValidator.java
package com.example.validation;
import com.example.dto.BookingRequest;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, BookingRequest> {
@Override
public boolean isValid(
BookingRequest request,
ConstraintValidatorContext context) {
if (request == null
|| request.getStartDate() == null
|| request.getEndDate() == null) {
return true;
}
return request.getEndDate()
.isAfter(request.getStartDate());
}
}
The @ValidDateRange annotation must be defined similarly to the earlier custom annotation, but its target should include ElementType.TYPE and its validator should reference DateRangeValidator.class. Its default message could be End date must be after start date.
This example treats missing dates as valid for the date-range constraint because their presence is checked independently by @NotNull. It avoids duplicating the responsibility of required-field validation.
15. Custom Validation vs. Service-Level Validation
| Aspect |
Custom Bean Validation |
Service-Level Validation |
| Main purpose |
Validate input structure and reusable constraints. |
Validate business operations and application state. |
| Typical example |
Employee code format. |
Checking whether an employee code is already registered. |
| Reuse |
Can be applied to many DTOs or fields. |
Usually tied to a business workflow or service. |
| External dependencies |
Ideally simple and deterministic. |
May involve repositories, databases, or external services. |
| Execution |
Triggered by Bean Validation. |
Called as part of application business logic. |
A format validator should not be used as a substitute for database uniqueness checks, authorization, or other checks that depend on changing application state. Those rules normally belong in the service layer and, where appropriate, in database constraints.
16. Validation Groups
Validation groups allow an application to apply different constraints in different scenarios. For example, creating an employee may require a new employee code, while updating an employee may allow some fields to be omitted.
Define a Group
package com.example.validation;
public interface OnCreate {
}
Apply the Group to a Field
@NotBlank(
message = "Employee code is required",
groups = OnCreate.class
)
@ValidEmployeeCode(groups = OnCreate.class)
private String employeeCode;
To activate the group explicitly, use Spring's @Validated(OnCreate.class) at the relevant controller boundary or another supported validation entry point. Group behavior should be designed carefully because default-group constraints and group sequences have specific rules.
17. Unit Testing a Custom Validator
Unit tests help ensure that a custom rule correctly accepts valid input and rejects invalid input. You can test the validator directly without starting the entire Spring Boot application.
EmployeeCodeValidatorTest.java
package com.example.validation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class EmployeeCodeValidatorTest {
private final EmployeeCodeValidator validator =
new EmployeeCodeValidator();
@Test
void acceptsValidEmployeeCode() {
assertTrue(validator.isValid("EMP-1234", null));
}
@Test
void rejectsIncorrectEmployeeCode() {
assertFalse(validator.isValid("EMP-12AB", null));
}
@Test
void rejectsCodeWithWrongLength() {
assertFalse(validator.isValid("EMP-123", null));
}
@Test
void acceptsNullForFormatConstraint() {
assertTrue(validator.isValid(null, null));
}
}
The direct unit tests above verify the validator's core logic. For a full integration test, also test the annotation through a Bean Validation provider or send HTTP requests to the Spring Boot endpoint to verify error handling and response status codes.
18. Best Practices for Custom Validation
- Keep validators focused: Each validator should enforce a clearly defined rule.
- Reuse built-in annotations: Avoid creating custom validators for rules already covered by standard annotations.
- Separate presence and format: Use
@NotBlank or @NotNull for required values and custom constraints for additional rules.
- Provide useful messages: Error messages should explain what needs to be corrected without revealing sensitive details.
- Keep validation deterministic: Prefer predictable checks that do not rely on external services.
- Use the right target: Use field-level annotations for individual properties and type-level annotations for cross-field rules.
- Test edge cases: Test null values, empty strings, whitespace, invalid formats, and boundary values.
- Do not rely only on frontend validation: Validate untrusted data on the server.
- Protect sensitive data: Avoid logging passwords, access tokens, or other confidential input when reporting validation failures.
- Use DTOs for API input: Avoid binding untrusted request fields directly to sensitive persistence entities.
- Handle concurrency correctly: Use database constraints where necessary for uniqueness and integrity rules that can change between validation and saving.
19. Common Errors and Troubleshooting
| Problem |
Possible Cause |
Solution |
| Custom annotation is ignored. |
Validation was not triggered or the annotation is configured incorrectly. |
Check @Constraint, the validator class, and @Valid or another validation entry point. |
| Validation dependency errors. |
Validation starter or provider is missing. |
Add the compatible Spring Boot validation dependency. |
| Import errors for validation classes. |
Old and new validation package names are mixed. |
Use the package namespace appropriate for the application's framework version. |
| Null values pass unexpectedly. |
The validator deliberately treats null as valid. |
Add @NotNull or @NotBlank when the value is required. |
| Error messages are not returned as expected. |
Exception handling is missing or configured differently. |
Inspect the validation exception and configure centralized error handling. |
| Invalid cross-field values pass. |
The annotation is applied to the wrong target or its validator is incomplete. |
Use a type-level constraint and test the combined field conditions. |
| Duplicate codes are accepted. |
A format validator is being used for a uniqueness requirement. |
Check uniqueness in the service layer and enforce it with a suitable database constraint. |
20. Practical Exercise
Task: Create a custom validation annotation named @ValidPhoneNumber for a customer registration application.
Requirements
- The phone number must contain exactly 10 digits.
- Alphabetic characters and special characters must not be accepted.
- Use a separate required-field constraint if the phone number is mandatory.
- Apply the annotation to a customer request DTO.
- Trigger validation in a Spring Boot REST controller.
- Return a meaningful error message for invalid values.
- Write unit tests for valid, invalid, short, long, and null values.
Suggested Regular Expression
^[0-9]{10}$
Expected learning outcome: You should be able to define a custom annotation, implement its validator, integrate it into a REST API, and verify the behavior using tests.
21. Interview Questions and Answers
Q1. What is custom validation in Java?
Answer: Custom validation is the process of defining application-specific validation rules when built-in constraints do not adequately express a requirement.
Q2. What is Jakarta Bean Validation?
Answer: It is a Java specification for defining and evaluating constraints on objects, properties, method parameters, and return values.
Q3. Which interface is used to implement a custom validator?
Answer: ConstraintValidator<A, T>, where A is the constraint annotation and T is the validated value type.
Q4. What is the purpose of the isValid() method?
Answer: It evaluates a value against a validation rule and returns true when the constraint is satisfied or false when it is violated.
Q5. Why does a custom annotation need a message() method?
Answer: It provides the default violation message that can be returned when the constraint fails.
Q6. What is the purpose of @Constraint?
Answer: It connects a custom constraint annotation to one or more validator implementations.
Q7. What is the difference between @Valid and @Validated?
Answer: @Valid triggers standard cascading validation. Spring's @Validated also supports validation groups and is commonly used for group-specific validation.
Q8. Why might a custom validator return true for null?
Answer: A validator may handle only format or business-specific checks, leaving required-value validation to @NotNull or @NotBlank.
Q9. How do you validate two fields together?
Answer: Create a type-level constraint whose validator examines the relevant fields and determines whether their combined values satisfy the business rule.
Q10. How are validation errors handled in Spring Boot?
Answer: Request validation errors can be handled with centralized exception handling, commonly using @RestControllerAdvice and an exception handler for the relevant validation exception.
Q11. Should a custom validator check database uniqueness?
Answer: Database-dependent business checks are generally better handled in the service layer, with a database constraint where necessary to prevent race conditions.
Q12. How can custom validators be tested?
Answer: Test the validator's isValid() behavior directly, and add integration tests to verify that annotations are triggered correctly through the validation provider and API.
22. Quick Revision Checklist
- Understand built-in and custom Bean Validation constraints.
- Define the annotation with
@Constraint, @Target, and @Retention.
- Implement
ConstraintValidator and its isValid() method.
- Apply the custom annotation to DTO properties or classes.
- Trigger request validation with
@Valid or an appropriate validation mechanism.
- Handle validation exceptions consistently.
- Test normal values, edge cases, null values, and invalid input.
- Keep structural validation separate from database-dependent business rules.
23. Conclusion
Creating custom validation rules is an important skill in Advanced Java and Spring Boot development. Custom constraint annotations and ConstraintValidator implementations allow developers to express reusable business-specific rules in a structured way. When combined with DTOs, REST controller validation, centralized exception handling, and automated tests, custom validation improves data quality, API consistency, and maintainability.
For further learning, explore the Advanced Java Training Course at JustAcademy and register for a course demo.