· Jose Antonio López  · 4 min read

How to Create Custom Validators with Hibernate and Spring Boot

Learn how to implement custom validations in Java with Hibernate Validator, Bean Validation, and Spring Boot.

Learn how to implement custom validations in Java with Hibernate Validator, Bean Validation, and Spring Boot.

Objective

Data validation is a critical part of any application. Although Java Bean Validation (JSR 303/JSR 380) provides useful annotations like @NotNull, @Size, or @Email, more specific business rules are often required.

This article describes how to create a custom validator to ensure that passwords meet specific security criteria (minimum length, uppercase letters, numbers, and special characters). To learn how to professionally handle exceptions thrown by these validators, it is recommended to consult the global exception handling guide.

Prerequisites

To follow this article, the following must be installed:

Tool/StepDownload Link
Java JDK 21+Download JDK
GradleGradle
Hibernate ValidatorHibernate Validator

Project Configuration (build.gradle)

Gradle is used for this article. Below are the most relevant lines to build a project with validation support and Hibernate Validator:

build.gradle
plugins {
    id 'org.springframework.boot' version '3.5.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.safeboxes'
version = '1.0.0'
sourceCompatibility = '21'

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // Third-party libraries
    implementation 'org.hibernate.validator:hibernate-validator:9.1.0.Final'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}

test {
    testLogging {
        events "PASSED", "SKIPPED", "FAILED"
    }
}

Implementation

Directory and File Structure

Following a clean project structure, validators and controllers are organized as follows:

src
├── main
│   └── java
│       └── com/safeboxes
│           ├── config
│           │   └── ApiConfig.java
│           ├── domain
│           │   ├── exceptions
│           │   │   ├── SafeBoxException.java
│           │   │   └── enums
│           │   │       └── SafeBoxErrorMessage.java
│           │   ├── services
│           │   │   └── SafeBoxService.java
│           │   └── validators
│           │       ├── SecurePassword.java
│           │       └── SecurePasswordValidator.java
│           ├── dtos
│           │   └── safes
│           │       └── CreateSafeBoxDTO.java
│           └── infrastructure
│               └── controllers
│                   └── SafeBoxController.java
└── test
    └── java
        └── com/safeboxes
            └── domain/validators
                └── SecurePasswordValidatorTest.java

1. Annotation Definition

First, the annotation interface is created. This defines where it can be applied and which class will handle the validation logic.

src/main/java/com/safeboxes/domain/validators/SecurePassword.java
package com.safeboxes.domain.validators;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {SecurePasswordValidator.class})
public @interface SecurePassword {

    String message() default "Invalid Password";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

2. Validator Implementation

The SecurePasswordValidator class implements ConstraintValidator, where the logic to verify if the password is secure resides.

src/main/java/com/safeboxes/domain/validators/SecurePasswordValidator.java
package com.safeboxes.domain.validators;

import com.safeboxes.domain.exceptions.SafeBoxException;
import com.safeboxes.domain.exceptions.enums.SafeBoxErrorMessage;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

public class SecurePasswordValidator implements ConstraintValidator<SecurePassword, String> {

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (!isSecurePassword(password)) {
            SafeBoxErrorMessage error = SafeBoxErrorMessage.INVALID_PASSWORD_FORMAT;
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(SafeBoxErrorMessage.getMessage(error));
            throw new SafeBoxException(error);
        }
        return true;
    }

    private boolean isSecurePassword(String password) {
        final int MINIMUM_PASSWORD_LENGTH = 12;
        if (password == null || password.length() < MINIMUM_PASSWORD_LENGTH) {
            return false;
        }
        
        final String UPPER_CASE_REG_EX = ".*[A-Z].*";
        final String LOWER_CASE_REG_EX = ".*[a-z].*";
        final String DIGIT_REG_EX = ".*\\d.*";
        final String SPECIAL_CHAR_REG_EX = ".*[! @#$%^&*()].*";

        return Pattern.matches(UPPER_CASE_REG_EX, password) &&
                Pattern.matches(LOWER_CASE_REG_EX, password) &&
                Pattern.matches(DIGIT_REG_EX, password) &&
                Pattern.matches(SPECIAL_CHAR_REG_EX, password);
    }
}

3. Using the Validator in a DTO

The new @SecurePassword annotation is applied in a DTO (Data Transfer Object) using a Java record.

src/main/java/com/safeboxes/dtos/safes/CreateSafeBoxDTO.java
package com.safeboxes.dtos.safes;

import com.safeboxes.domain.validators.SecurePassword;
import jakarta.validation.constraints.NotNull;

public record CreateSafeBoxDTO(
    @NotNull String name,
    @NotNull @SecurePassword String password
)  {
}

4. Using in the Controller

To execute the validation when receiving a request, the @Valid annotation is used in the Spring Boot controller. Lombok with @RequiredArgsConstructor is employed to simplify dependency injection.

src/main/java/com/safeboxes/infrastructure/controllers/SafeBoxController.java
package com.safeboxes.infrastructure.controllers;

import com.safeboxes.config.ApiConfig;
import com.safeboxes.domain.services.SafeBoxService;
import com.safeboxes.dtos.safes.CreateSafeBoxDTO;
import com.safeboxes.dtos.safes.SafeBoxDTO;
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 lombok.RequiredArgsConstructor;

import jakarta.validation.Valid;

@RestController 
@RequestMapping(value = ApiConfig.BASE_PATH + "/safeboxes")
@RequiredArgsConstructor
public class SafeBoxController {
    private final SafeBoxService safeBoxService;

    @PostMapping
    public SafeBoxDTO createSafeBox(@Valid @RequestBody CreateSafeBoxDTO request) {
        return safeBoxService.createSafeBox(request);
    }
}

Unit Tests

It is essential to test that the validator works correctly for all cases:

src/test/java/com/safeboxes/domain/validators/SecurePasswordValidatorTest.java
package com.safeboxes.domain.validators;

import com.safeboxes.domain.exceptions.SafeBoxException;
import com.safeboxes.domain.exceptions.enums.SafeBoxErrorMessage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import jakarta.validation.ConstraintValidatorContext;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith(MockitoExtension.class)
class SecurePasswordValidatorTest {

    @InjectMocks
    private SecurePasswordValidator passwordValidator;

    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private ConstraintValidatorContext context;

    @ParameterizedTest 
    @ValueSource(strings = {
            "short",              // Too short
            "123456789012",      // No letters
            "abcdefghijkl",      // No upper case
            "ABCDEFGHIJKL",      // No lower case
            "Abcdefghijkl",      // No digits
            "Abcdefghijk1",      // No special char
            "Abcdefghijk!",      // No digit
            "             ",      // Spaces
    })
    @NullSource 
    @DisplayName("Invalid passwords should throw SafeBoxException")
    void invalid_passwords_should_throw_exception(String password) {
        SafeBoxException exception = assertThrows(SafeBoxException.class, () -> {
            passwordValidator.isValid(password, context);
        });
        assertEquals(SafeBoxErrorMessage.INVALID_PASSWORD_FORMAT, exception.getErrorMessage());
    }

    @ParameterizedTest 
    @ValueSource(strings = {
            "ValidPass123!",
            "Secure#2026Password",
            "A1b2C3d4E5f!",
            "PasswordWithSpace 1!"
    })
    @DisplayName("Valid passwords should return true")
    void valid_passwords_should_return_true(String password) {
        assertTrue(passwordValidator.isValid(password, context));
    }

    @Test
    @DisplayName("should return true for a secure password")
    void secure_password_ok() {
        final String testPassword = "ValidPassword123!";
        assertTrue(passwordValidator.isValid(testPassword, context));
    }
}

References

  • Hibernate
  • Spring Boot
Share: