Popular Searches
Popular Course Categories
Popular Courses

Creating Custom Validation Rules

Creating Custom Validation Rules

Spring AOP, Validation, and Exception Management

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:

  1. Custom annotation: Defines the validation rule and its configuration.
  2. Validator class: Implements the logic that checks whether a value is valid.
  3. 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.

Previous Next
whatsapp