03 - Validación e invariancias

Una petición puede rechazarse por dos motivos distintos: incumple el formato del contrato o propone un estado inválido para el negocio. Son preguntas diferentes, que se responden en lugares diferentes y con mecanismos diferentes. Mezclarlas produce los errores clásicos: reglas de negocio escritas en el controller que otra vía de entrada omite, o datos inválidos que llegan hasta el dominio y se convierten en errores del servidor.

Pregunta Dónde se responde Mecanismo Respuesta típica
¿La petición cumple el formato del contrato? La capa de presentación (el DTO de entrada) Jakarta Validation 400 indicando campo y motivo
¿El estado propuesto es válido para el negocio? El dominio (modelos) Invariantes expresadas en código Excepción de dominio, mapeada al error del contrato

El mismo dato puede aparecer en las dos preguntas. Que el IBAN tenga veintidós dígitos y empiece por ES puede ser una regla de formato del contrato y, además, la invariante del tipo de dominio que lo representa. Puede duplicarse, y los apartados siguientes explican por qué no es una redundancia.

Jakarta Validation declara la validación como anotaciones sobre los campos del DTO. El DTO describe el contrato; las anotaciones describen qué solicitudes el contrato admite.

public record CreateAccountRequest(
        @NotBlank String name,
        @NotBlank @Email String email,
        @Size(max = 240) String description,
        @Positive @Digits(integer = 8, fraction = 2) BigDecimal balance) {}

Anotación Comprueba
@NotNull, @NotBlank, @NotEmpty Presencia: valor, cadena con contenido, colección no vacía
@Size Longitud de cadena o tamaño de colección
@Min, @Max, @Positive, @NegativeOrZero Rango numérico
@Email, @Pattern Formato
@Digits, @DecimalMin Precisión y escala numéricas
@Past, @Future Comparación con el momento actual

Spring valida el DTO de entrada cuando el parámetro del controller se anota con @Valid:

@Tag(name = "Cuentas")
public interface AccountHttpApi {
    @PostMapping("/accounts")
    ResponseEntity<AccountResponse> create(@Valid @RequestBody CreateAccountRequest request);
}

El DTO de un nivel puede contener otros DTO; cada uno se valida en cascada:

public record CreateOrderRequest(
        @NotBlank String customerId,
        @NotEmpty @Valid List<ItemRequest> items) {
    public record ItemRequest(
            @NotBlank String productId,
            @Positive int quantity) {}
}

Sin @Valid el DTO no se valida aunque lleve anotaciones: es una decisión explícita, operación por operación.

Cuando el formato propio de la aplicación no encaja con ninguna anotación estándar, se declara una constraint propia que delega en una clase validadora:

@Constraint(validatedBy = IbanFormatValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ValidIban {
    String message() default "El formato del IBAN no es válido";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class IbanFormatValidator implements ConstraintValidator<ValidIban, String> {

    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // la obligatoriedad corresponde a @NotBlank
        }
        return value.matches("^ES\\d{22}$");
    }
}

La validadora se separa de la anotación y se puede comprobar directamente (ver §5); la anotación solo declara mensaje y dónde aplica.

Si la validación falla, Spring lanza la excepción antes de que el controller ejecute su cuerpo. Se unifica con un manejador central que devuelve un documento ProblemDetail (RFC 9457):

@RestControllerAdvice
class GlobalValidationHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail onInvalidRequest(MethodArgumentNotValidException exception) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Solicitud inválida");
        problem.setProperty("errors", exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList());
        return problem;
    }
}

Todas las operaciones comparten el mismo formato de error de validación; solo cambian los campos que fallan. Además, el rechazo ocurre antes de tocar el dominio ni la base de datos.

springdoc-openapi traduce las anotaciones de DTO a restricciones del esquema OpenAPI: @NotBlank marca el campo requerido, @Size la longitud máxima, @Min el mínimo. Swagger UI muestra qué admite el contrato sin leer el código.

Las constraints estándar se reflejan solas en el esquema. Para las personalizadas, documentar su restricción en la operación o en el esquema (por ejemplo @Schema) — si quien consume la API no puede ver lo que la validación exige, el contrato miente en ese punto.

En el otro lado del par está el dominio. Un value object es un tipo pequeño, inmutable y comparado por valor, que representa un concepto de negocio y protege sus invariantes en su propia construcción: si los datos no son válidos, el objeto no existe.

Es la idea de DDD (*Domain-Driven Design*): el tipo que representa un concepto no deja llegar nunca a un estado inválido ni se deja confundir con otros tipos de negocio. Sustituye la *primitive obsession*: en lugar de adivinar si cada String es un IBAN, un teléfono o un código de banco, cada concepto tiene su tipo.

public record Iban(String value) {

    private static final Pattern FORMAT = Pattern.compile("^ES\\d{22}$");

    public Iban { // constructor compacto: valida antes de asignar
        if (value == null || !FORMAT.matcher(value).matches()) {
            throw new InvalidIbanException();
        }
    }

    public String country() {
        return value.substring(0, 2);
    }
}

El modelo declara sus relaciones con tipos de dominio (Iban iban donde antes estaba String iban) y ninguna otra pieza puede construir un Iban con contenido incorrecto: el cuerpo del constructor compacto se ejecuta en toda vía de creación — deserialización, mapeador, caso de uso, otro módulo.

Enfoque Cuándo se aplica Qué garantiza Coste para el dominio
Anotaciones de Jakarta Validation Solo donde un framework valida (una petición HTTP) Nada: un new Iban(“…”) directo no anota nada y salta la validación Dependencia de jakarta.validation dentro del dominio, prohibida en la verificación de módulos (tema 04)
Invariante en el value object Toda construcción del objeto, desde cualquier punto del programa El tipo no existe en estado inválido Ninguna: es código y tipos del propio dominio

La invariante vive en el tipo, donde el objeto se construye; las anotaciones viven donde el framework decide aplicarlas.

La pureza del dominio no es estética: el mismo objeto puede construirse desde un controller, un caso de uso, un mapeador o el propio módulo. Solo el código de invariante protege en todas las vías; las anotaciones quedan reservadas a los DTO de las fronteras, que sí dependen de jakarta.validation.

La misma regla puede aparecer en el DTO y en el value object, porque respaldan decisiones distintas:

Quién regula Qué previene Error que produce
El DTO con anotaciones Que una solicitud inválida llegue a tocar el sistema; rechazo temprano y claro 400 con campo y mensaje
El value object Que el estado inválido exista en el sistema por cualquier vía de entrada Excepción de dominio, traducida por el manejador al error del contrato (por ejemplo 409 o 422)
El DTO rechaza pronto y con un error claro las solicitudes que no cumplen el contrato; el value object garantiza la invariante aunque el dato llegue por una vía que no pasa por la frontera. Ninguno cubre por sí solo todas las vías de entrada; por eso se complementan.

Gestionar el campo válido para el contrato pero inválido para el negocio (por ejemplo, saldo inicial de 0.02 bien formado pero no admitido por las reglas): el caso de uso invoca al modelo, la excepción de negocio salta y el manejador de la API la traduce al error documentado en el contrato.

  • Value objects: pruebas unitarias sin framework ni mocks; casos frontera incluidos valores límite. Son las piezas más baratas de probar; si son record, cada invariante es directo.
  • Constraints personalizadas: probar la clase validadora directamente con valores que cumplen y no cumplen.
  • DTO de entrada: pruebas de integración HTTP que verifican el rechazo, no la lógica del negocio; no repetir los casos cubiertos por el value object.
  • Handler de errores: su formato se prueba una vez (el manejador compartido), no en cada operación.

class IbanTest {

    @ParameterizedTest
    @ValueSource(strings = {"", "ES12", "GB29 NWBK 6016 1331 9268 19", "es9121000418450200051332"})
    void rejectsInvalidValue(String value) {
        assertThrows(InvalidIbanException.class, () -> new Iban(value));
    }

    @Test
    void acceptsValidValueAndExposesCountry() {
        assertEquals("ES", new Iban("ES9121000418450200051332").country());
    }
}

@WebMvcTest(CreateAccountController.class)
class CreateAccountValidationTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean CreateAccount useCase;

    @Test
    void rejectsInvalidEmailAsBadRequest() throws Exception {
        mockMvc.perform(post("/accounts")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"Ana\",\"email\":\"no-email\"}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0]", containsString("email")));
    }
}

La validación declarada con anotaciones la aplica el framework; probar Spring no es una responsabilidad de estos tests. Lo que se prueba aquí es que cada ruta errónea del contrato produce el rechazo correcto. Lo que no se prueba: repetir el catálogo completo de combinaciones absurdas de campos, ni negocio que ya está bien cubierto por las pruebas del value object.
  • clase/daw/dws/1eval/validacion.txt
  • Última modificación: 2026/10/21 10:22
  • por cesguiro