· Jose Antonio López  · 4 min lectura

Crear validadores personalizados con Hibernate y Spring Boot

Aprende a implementar validaciones personalizadas en Java con Hibernate Validator, Bean Validation y Spring Boot.

Aprende a implementar validaciones personalizadas en Java con Hibernate Validator, Bean Validation y Spring Boot.

Objetivo

La validación de datos es una parte crítica de cualquier aplicación. Aunque Java Bean Validation (JSR 303/JSR 380) proporciona anotaciones útiles como @NotNull, @Size o @Email, a menudo se necesitan reglas de negocio más específicas.

En este artículo se describe cómo crear un validador personalizado para asegurar que las contraseñas cumplan con criterios de seguridad específicos (longitud mínima, mayúsculas, números y caracteres especiales). Para aprender a gestionar de forma profesional las excepciones lanzadas por estos validadores, se recomienda consultar la guía de manejo global de excepciones.

Prerrequisitos

Para seguir este artículo se requiere tener instalado:

Herramienta/PasoEnlace de descarga
Java JDK 21+Descargar JDK
GradleGradle
Hibernate ValidatorHibernate Validator

Configuración del Proyecto (build.gradle)

Para este artículo se utiliza Gradle. A continuación se muestran las líneas más relevantes para construir un proyecto con soporte de validación y 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"
    }
}

Implementación

Directorio y estructura de archivos

Siguiendo una estructura de proyecto limpia, se organizan los validadores y controladores de la siguiente manera:

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. Definición de la Anotación

Primero se crea la interfaz de la anotación. Esta define dónde se puede aplicar y qué clase se encargará de la lógica de validación.

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. Implementación del Validador

La clase SecurePasswordValidator implementa ConstraintValidator, donde reside la lógica para verificar si la contraseña es segura.

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. Uso del Validador en un DTO

Se aplica la nueva anotación @SecurePassword en un DTO (Data Transfer Object) utilizando un record de Java.

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. Uso en el Controlador

Para que la validación se ejecute al recibir una petición, se utiliza la anotación @Valid en el controlador de Spring Boot. Se emplea Lombok con @RequiredArgsConstructor para simplificar la inyección de dependencias.

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);
    }
}

Tests Unitarios

Es fundamental probar que el validador funcione correctamente para todos los casos:

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));
    }
}

Referencias

  • Hibernate
  • Spring Boot
Compartir: