Tabla de Contenidos

06_3 - Validación

Uno de los aspectos más importantes es la validación de datos. Tenemos que tener claro qué validamos o dónde validamos los datos y reglas de negocio.

Lo primero, ¿dónde validamos? Podríamos pensar que cuánto antes mejor, y, en parte, es correcto ¿Significa eso que tenemos que validar los datos en la capa de presentación? ¿Qué pasa si cambiamos los controladores o si tenemos varios controladores que reciben los mismos datos? Eso nos obligaría a repetir el código de validación en varios controladores, con lo que no parece la mejor opción.

La mayoría de validaciones deberían estar en nuestra capa de dominio, de forma que si cambiamos presentación o persistencia sigan siendo válidas.

En general, podemos distinguir dos tipos de validaciones: validaciones de datos de entrada y de lógica de negocio. La diferencia fundamental es que, mientras en las primeras no necesitamos acceder al estado del objeto para hacer las validaciones, en las segundas sí.

Por ejemplo, que un descuento no pueda ser negativo se puede validar accediendo al valor del campo únicamente, mientras que comprobar que un autor no esté repetido al añadirlo a un libro necesita acceder a los autores asociados a ese libro.

Validaciones de datos de entrada

Como hemos dicho, estas validaciones comprueban que los datos de entrada sean correctos, independientemente del estado del objeto.

Antes de ver dónde validamos, vamos a crearnos una excepción para lanzar en caso que nuestras validaciones fallen:

public class ValidationException extends RuntimeException {
    public ValidationException(String message) {
        super(message);
    }
}

Veamos ahora dónde validar nuestros datos. Por ejemplo, queremos que el ISBN de los libros no sea nulo. Tenemos, básicamente, dos opciones: validarlo en el modelo de negocio (Book) o en el DTO de entrada (BookDto).

En nuestro caso, validaremos los datos en los DTOs, que son la frontera de entrada al dominio. Así obligamos a que cualquier capa externa (presentación, persistencia, API, etc.) entregue datos consistentes. De esta forma, cuando se construya un Book, podremos asumir que los datos básicos ya son válidos.

Si validáramos solo en el modelo (Book), podrían colarse datos inválidos desde fuera hasta que se cree la entidad. En cambio, al validar en el BookDto, nos aseguramos de que cualquier capa externa entregue datos consistentes desde el primer momento.

Por ejemplo, podríamos definir varias validaciones a la hora de crear el DTO:

public record BookDto(
        Long id,
        String isbn,
        String titleEs,
        String titleEn,
        String synopsisEs,
        String synopsisEn,
        BigDecimal basePrice,
        double discountPercentage,
        BigDecimal price,
        String cover,
        LocalDate publicationDate,
        PublisherDto publisher,
        List<AuthorDto> authors
) {

    public BookDto(
            Long id,
            String isbn,
            String titleEs,
            String titleEn,
            String synopsisEs,
            String synopsisEn,
            BigDecimal basePrice,
            double discountPercentage,
            BigDecimal price,
            String cover,
            LocalDate publicationDate,
            PublisherDto publisher,
            List<AuthorDto> authors
    ) {
        this.id = id;

        // === ISBN ===
        if (isbn == null || isbn.isBlank()) {
            throw new ValidationException("ISBN es obligatorio");
        }
        this.isbn = isbn;

        // === Títulos ===
        if ((titleEs == null || titleEs.isBlank()) &&
            (titleEn == null || titleEn.isBlank())) {
            throw new ValidationException("Debe existir al menos un título (ES o EN)");
        }
        this.titleEs = titleEs;
        this.titleEn = titleEn;

        this.synopsisEs = synopsisEs;
        this.synopsisEn = synopsisEn;

        // === Precio base ===
        if (basePrice == null || basePrice.compareTo(BigDecimal.ZERO) < 0) {
            throw new ValidationException("El precio base no puede ser nulo ni negativo");
        }
        this.basePrice = basePrice;

        // === Descuento ===
        if (discountPercentage < 0 || discountPercentage > 100) {
            throw new ValidationException("El descuento debe estar entre 0 y 100");
        }
        this.discountPercentage = discountPercentage;

        this.price = price;

        this.cover = cover;

        // === Fecha publicación ===
        if (publicationDate != null && publicationDate.isAfter(LocalDate.now())) {
            throw new ValidationException("La fecha de publicación no puede ser futura");
        }
        this.publicationDate = publicationDate;

        this.publisher = publisher;

        this.authors = List.copyOf(authors); 
    }
}

Usamos List.copyOf(authors) para evitar aliasing, es decir, que alguien modifique desde fuera la lista original y cambie el estado interno del DTO sin pasar por las validaciones.

En este punto tenemos DTOs con validaciones manuales que garantizan datos consistentes al entrar en el dominio.

Sin embargo, este enfoque presenta dos problemas: mucho código repetitivo y validaciones dispersas dentro de cada DTO. La solución pasa por centralizar la lógica de validación y hacerla más declarativa.

Antes de recurrir a un framework externo como Jakarta Bean Validation, vamos a implementar nuestro propio mini–sistema de validación. La idea es disponer de una clase base Validator<T> que nos proporcione utilidades genéricas (como notNull, notEmpty, pattern, etc.), y a partir de ella crear validadores concretos para cada DTO, como BookValidator.

De esta forma entendemos la mecánica que hay detrás: recorrer los campos, comprobar reglas y acumular errores. Una vez tengamos claro este patrón, daremos el salto a Jakarta Bean Validation, que no es más que una implementación estándar y mucho más completa de la misma idea.

Validaciones con un mini–framework propio

Hemos visto que validar directamente dentro de cada DTO funciona, pero también implica repetir mucho código y mezclar responsabilidades. Una alternativa es separar la lógica de validación en clases dedicadas. Esto es, de hecho, lo que hacen frameworks como Jakarta Bean Validation: ofrecen una forma estándar y declarativa de definir validaciones.

Antes de introducir Jakarta, vamos a construir nuestro propio mini–framework de validación. La idea es tener una clase abstracta Validator<T> que proporcione métodos genéricos para comprobar reglas (notNull, notEmpty, pattern, etc.) y acumular errores. Luego, cada DTO tendrá su propio validador específico, por ejemplo, BookValidator.

Veamos un ejemplo inicial con la validación `notNull` aplicada al campo ISBN de nuestro BookDto:

public abstract class Validator<T> {

    private List<String> errors = new ArrayList<>();

    public abstract List<String> validate(T t);

    protected void addError(String error) {
        errors.add(error);
    }

    public boolean isValid() {
        return errors.isEmpty();
    }

    public List<String> getErrors() {
        return errors;
    }

    protected void notNull(T instance, Field field) throws IllegalAccessException {
        field.setAccessible(true);
        Object value = field.get(instance);
        if (value == null) {
            addError(field.getName() + " must not be null");
        }
    }

    // Aquí podríamos añadir más validaciones: notEmpty, maxLength, pattern, etc.
}

public class BookValidator extends Validator<BookDto> {

    @Override
    public List<String> validate(BookDto bookDto) {
        try {
            Field isbnField = BookDto.class.getDeclaredField("isbn");
            this.notNull(bookDto, isbnField);
        } catch (Exception e) {
            throw new ValidationException("ISBN is required");
        }
        return getErrors();
    }
}

Con esto tenemos una primera versión muy básica de un sistema de validaciones centralizado. Lo importante aquí no es la potencia, sino entender el patrón: tener validadores genéricos que recorren los objetos y aplican reglas, acumulando errores.

Una vez definido nuestro validador, podemos crear un `BookDto` y comprobar si es válido:

BookDto bookDto = new BookDto(
        1L,
        null, // ISBN nulo para probar la validación
        "Fundación",
        "Foundation",
        "Sinopsis en español",
        "Synopsis in English",
        BigDecimal.valueOf(10.0),
        10,
        null,
        "cover.jpg",
        LocalDate.of(1951, 6, 1),
        new PublisherDto(1L, "Gnome Press", "gnome"),
        List.of(new AuthorDto(1L, "Isaac Asimov", "USA", null, null, 1920, 1992, "isaac-asimov"))
);

// Creamos el validador
BookValidator validator = new BookValidator();
List<String> errors = validator.validate(bookDto);

if (!errors.isEmpty()) {
    System.out.println("Errores de validación:");
    errors.forEach(System.out::println);
} else {
    System.out.println("El BookDto es válido");
}

Aunque este enfoque funciona, sigue siendo manual y limitado:

Para resolver estas limitaciones, existen frameworks como Jakarta Bean Validation (JBV).

JBV nos permite declarar las reglas directamente sobre los campos mediante anotaciones (`@NotNull`, `@Size`, `@PastOrPresent`, etc.) y delegar en un motor de validación que recorre automáticamente los objetos y reporta los errores.

De esta forma:

  1. Reducimos el código repetitivo.
  1. Obtenemos validaciones consistentes y reutilizables.
  1. Nos acercamos a la forma en que funcionan frameworks maduros como Hibernate Validator, que implementa JBV.

En la siguiente sección veremos cómo transformar nuestros DTOs y reglas manuales en validaciones declarativas con JBV.

Jakarta Validation

Jakarta Bean Validation (JBV) es una especificación de Java que define un conjunto de APIs y anotaciones para la validación de objetos en aplicaciones Java. Proporciona un marco estándar para la validación de datos, permitiendo a los desarrolladores especificar reglas de validación directamente en las clases de dominio de sus aplicaciones.

La especificación Jakarta Bean Validation se centra en la validación de datos de manera declarativa mediante el uso de anotaciones en los campos de las clases. Algunas de las anotaciones comunes incluyen @NotNull para garantizar que un valor no sea nulo, @Size para especificar restricciones sobre la longitud de una cadena, @Min y @Max para establecer límites numéricos, entre otras.

Diferentes implementaciones, como Hibernate Validator, Apache BVal, y Apache Geronimo Validator, proporcionan el soporte concreto para la especificación Jakarta Bean Validation, permitiendo a los desarrolladores elegir la implementación que mejor se adapte a sus necesidades.

Para usar JVB, primero debemos añadir la dependencia:

        <dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
            <version>${jakarta.validation.version}</version>
        </dependency>

Después, deberíamos implementar las diferentes anotaciones que queramos usar (podemos crear anotaciones nuevas). Por ejemplo, vamos a implementar la anotación @NotNull:

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

    String message() default "must not be null";
}

La anotación @Constraint(validatedBy = NotNullValidator.class) indica cuál es la clase encargada de validar esa anotación. En nuestro caso, podemos crear algo similar a:

public class NotNullValidator implements ConstraintValidator<NotNull, Object> {

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        return value != null;
    }
}

De esta forma, ya podemos usar esas anotaciones en nuestros DTOs:

public record BookDto(
        Long id,
        @NotNull
        String isbn,
        String titleEs,
        String titleEn,
        String synopsisEs,
        String synopsisEn,
        BigDecimal basePrice,
        double discountPercentage,
        BigDecimal price,
        String cover,
        LocalDate publicationDate,
        PublisherDto publisher,
        List<AuthorDto> authors
) {
}

Siempre que queramos validar, deberemos usar un Validator de JBV:

BookDto bookDto = new BookDto(null, "Fundación", "Foundation",...);

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
jakarta.validation.Validator validator = factory.getValidator();

Set<ConstraintViolation<BookDto>> violations = validator.validate(bookDto);

if (!violations.isEmpty()) {
    System.out.println("Errores de validación:");
    violations.forEach(v -> System.out.println(v.getPropertyPath() + ": " + v.getMessage()));
} else {
    System.out.println("El BookDto es válido");
}

Cuando usamos JBV, primero creamos la fábrica de validadores mediante Validation.buildDefaultValidatorFactory(). Esta fábrica (ValidatorFactory) es la encargada de generar instancias de Validator, que son los objetos que realmente realizan la validación de los DTOs. Con el Validator, podemos llamar a métodos como validate(object) para validar todo el objeto, o validateProperty(object, propertyName) para validar únicamente un campo concreto.

Cada vez que una regla de validación falla, JBV genera un objeto ConstraintViolation que contiene información detallada: el nombre del campo que no cumplió la regla (getPropertyPath()), el mensaje de error (getMessage()) y el valor que provocó la violación (getInvalidValue()). Así, al llamar a validator.validate(bookDto), obtenemos un conjunto de todas las violaciones detectadas.

En resumen, el flujo consiste en crear el DTO a validar, obtener la fábrica y el validador, ejecutar la validación y procesar las violaciones. Todo esto se hace de manera automática, sin necesidad de recorrer los campos manualmente ni implementar múltiples comprobaciones, lo que es la principal ventaja de JBV frente a las validaciones manuales o nuestros mini–frameworks propios.

Al usar Jakarta Bean Validation directamente en nuestros DTOs de dominio estamos introduciendo una dependencia externa en la capa de dominio, lo que rompe la pureza de la arquitectura limpia.

Esto puede provocar acoplamientos indeseados, dificultar el testing unitario puro del dominio y complicar cambios de framework en el futuro. Sin embargo, en la práctica es una concesión común y aceptada debido a la productividad y consistencia que ofrece.

Hibernate Validator

Hibernate Validator es la implementación de referencia de Jakarta Bean Validation (JBV). Nos permite usar todas las anotaciones de JBV de manera completa y además proporciona validadores avanzados y reglas adicionales.

Hibernate Validator hace que nuestras validaciones sean automáticas y consistentes, integrándose fácilmente con frameworks de persistencia, APIs REST o formularios, y eliminando la necesidad de escribir lógica de validación manual o mini–frameworks caseros.

La ventaja de Hibernate Validator es que además de respetar todas las anotaciones estándar de JBV, permite crear validadores personalizados complejos y reutilizables, y soporta mensajes internacionales y interpolación avanzada.

Como siempre, primero debemos añadir la dependencia:

        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>${org.hibernate.validator}</version>
        </dependency>

Ahora ya podemos usar sus anotaciones en nuestros DTOs:

public record PublisherDto(
        Long id,
        @NotNull(message = "Nombre no puede ser nulo")
        String name,
        @NotNull(message = "Slug no puede ser nulo")
        @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$", message = "Slug inválido, debe ser URL-friendly (minúsculas, números y guiones)")
        String slug
) {
}

Aquí usamos las anotaciones estándar de Hibernate Validator/JBV:

Para centralizar la validación de cualquier DTO, creamos la clase DtoValidator. Esta clase inicializa un Validator de Hibernate Validator de manera estática, usando ValidatorFactory. Luego, mediante el método validate(T dto), podemos pasar cualquier DTO y automáticamente se verifican todas las anotaciones de validación. Si se detectan violaciones, se lanza una ValidationException que ahora acepta el conjunto de ConstraintViolation, permitiendo acceder a todos los errores de manera estructurada.

public class DtoValidator {

    private static final Validator validator;

    static {
        ValidatorFactory factory = Validation.byDefaultProvider()
                .configure()
                .messageInterpolator(new org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator())
                .buildValidatorFactory();
        validator = factory.getValidator();
    }

    public static <T> void validate(T dto) {
        Set<ConstraintViolation<T>> violations = validator.validate(dto);
        if (!violations.isEmpty()) {
            throw new ValidationException(violations);
        }
    }
}

Para que esto funcione, modificamos nuestra ValidationException para aceptar directamente el conjunto de ConstraintViolation. Esto permite no solo almacenar los errores, sino también construir un mensaje completo concatenando cada violación y, al mismo tiempo, mantener acceso al detalle de cada una mediante el método getViolations().

public class ValidationException extends RuntimeException {

    private final Set<ConstraintViolation<?>> violations;
    
    public ValidationException(String message) {
        super(message);
        this.violations = Set.of();
    }

    public ValidationException(Set<? extends ConstraintViolation<?>> violations) {
        super("Errores de validación detectados: " + violations.size());
        this.violations = Set.copyOf(violations);
    }

    public Set<ConstraintViolation<?>> getViolations() {
        return violations;
    }

    @Override
    public String getMessage() {
        return violations.stream()
                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                .collect(Collectors.joining(", "));
    }
}

Con esta estructura, validar un PublisherDto es tan sencillo como llamar a DtoValidator.validate(publisherDto). Si existen errores, se lanza la excepción con todos los detalles de las violaciones, y de esta forma mantenemos centralizada la lógica de validación, aprovechando las ventajas de Hibernate Validator sin repetir código en cada DTO.

class PublisherDtoTest {



    @ParameterizedTest
    @DisplayName("Create publisherDto with invalid data should throw ValidationException")
    @CsvSource({
            "1L, '', 'valid-slug'",
            "1L, 'Valid Name', ''",
            "1L, '', ''",
            "1L, Valid Name, invalid slug",
            "1L, Valid Name, 'invalid_slug!'"
    })
    void createPublisherDto_WithNullName_ShouldThrowException() {
        PublisherDto publisherDto = new PublisherDto(1L, null, "slug");
        assertThrows(ValidationException.class, () -> DtoValidator.validate(publisherDto));

    }

}

En muchas aplicaciones necesitamos validar que ciertos campos cumplan un formato específico. Por ejemplo, en nuestro caso, un slug debe ser URL-friendly, es decir, contener solo minúsculas, números y guiones. Para no repetir la lógica en varios DTOs, podemos crear nuestra propia anotación de validación, aprovechando la infraestructura de Jakarta Bean Validation / Hibernate Validator.

Primero, definimos la anotación @Slug. Esta anotación permite especificar un mensaje de error personalizado y es reconocida por JBV mediante la propiedad @Constraint, que indica la clase que implementará la validación:

 
@Target({ ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SlugValidator.class)
public @interface Slug {
    String message() default "Valor inválido, debe ser URL-friendly (minúsculas, números y guiones)";

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

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

Cuando definimos nuestra anotación @Slug aparecen dos elementos: Class<?>[] groups() y Class<? extends Payload>[] payload().

En resumen, ambos son requeridos por la especificación de Jakarta Bean Validation, incluso si no los usamos de manera activa. Nos permiten mantener la anotación compatible con la infraestructura de validación y con futuras extensiones, asegurando que nuestro @Slug pueda integrarse correctamente con cualquier Validator de JBV o Hibernate Validator.

A continuación, implementamos la lógica de validación en SlugValidator. Este validador comprueba que el valor no sea nulo ni vacío y que cumpla la expresión regular correspondiente al formato URL-friendly:

public class SlugValidator implements ConstraintValidator<Slug, String> {

    private static final String SLUG_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";

    @Override
    public boolean isValid(String value, jakarta.validation.ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return false;
        }
        return value.matches(SLUG_PATTERN);
    }
}

Una vez creada la anotación y el validador, podemos usar @Slug en cualquier DTO que necesite un campo URL-friendly. Por ejemplo, tanto PublisherDto como AuthorDto pueden usarla en sus campos slug:

public record PublisherDto(
        Long id,
        @NotNull(message = "Nombre no puede ser nulo")
        String name,
        @Slug
        String slug
) {
}

De esta manera, centralizamos la lógica de validación de slugs y evitamos duplicación de código. Cuando llamamos a DtoValidator.validate(dto), el validador comprobará automáticamente la anotación @Slug, junto con otras anotaciones estándar como @NotNull o @Pattern. Esto permite mantener consistencia en toda la aplicación y simplifica la gestión de errores de validación.

Igual que con JBV, usar Hibernate Validator introduce una dependencia externa en la capa de dominio. Esto rompe la pureza de la arquitectura limpia, creando acoplamientos que podrían dificultar el testing unitario puro del dominio o cambios futuros de framework.

En la práctica, sin embargo, esta es una concesión común, porque Hibernate Validator aporta productividad, consistencia y compatibilidad con ecosistemas de Java ampliamente usados.

Validaciones de lógica de negocio

Mientras que las validaciones de entrada se ocupan de que los datos sean sintácticamente correctos (ej. ISBN de 13 dígitos, precio no negativo, fecha no futura), las reglas de negocio dependen del estado del dominio o de relaciones entre entidades. Estas validaciones suelen ir en la entidad o en servicios de dominio, no en los DTOs.

Las diferencias claves con respecto a las validaciones de datos de entrada son:

Por ejemplo, en la entidad Book, podríamos tener una validación que asegura que un autor no se agregue dos veces a un libro:

    public void addAuthor(Author author) {
        if (this.authors.contains(author)) {
            throw  new BusinessException("Author already exists");
        }
        this.authors.add(author);
    }

Este tipo de validación es interna a la entidad y depende del estado actual del libro (this.authors). Se asegura de que la regla de negocio —un libro no puede tener autores duplicados— se cumpla en cualquier momento que se manipulen los autores del libro.

En cambio, en el servicio BookServiceImpl, podríamos tener una validación al crear un libro:

    @Override
    public BookDto create(BookDto bookDto) {
        Optional<BookDto> existingBookDto = findByIsbn(bookDto.isbn());

        if (existingBookDto.isPresent()) {
            throw new BusinessException("Book with isbn " + bookDto.isbn() + " already exists");
        }

        BookEntity newBookEntity = BookMapper.getInstance().fromBookToBookEntity(
                BookMapper.getInstance().fromBookDtoToBook(bookDto)
        );

        if(bookDto.publisher() != null  &&
                publisherRepository.findById(bookDto.publisher().id()).isEmpty()) {
            throw new ResourceNotFoundException("Publisher with id " + bookDto.publisher().id() + " does not exist");
        }
        return BookMapper.getInstance().fromBookToBookDto(
                BookMapper.getInstance().fromBookEntityToBook(
                        bookRepository.save(newBookEntity)
                )
        );
    }

Aquí, la validación ocurre en un servicio de dominio y no solo depende del estado de un objeto, sino también de información externa, como:

Si alguna de estas condiciones no se cumple, se lanzan excepciones de negocio (BusinessException o ResourceNotFoundException), que reflejan reglas del sistema más amplias que solo afectan a la entidad Book.

Las diferencias claves entre ambas validaciones son: