====== 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.
===== 1. Dos preguntas, dos fronteras =====
^ 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.
===== 2. Validación de entrada con Jakarta Validation =====
**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.
==== 2.1 Constraints estándar ====
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 |
==== 2.2 Validación automática y en cascada ====
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 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 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.
==== 2.3 Constraints personalizadas ====
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 {
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.
==== 2.4 El resultado: 400 coherente con la API ====
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.
==== 2.5 Del código al contrato: OpenAPI ====
**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.
===== 3. Invariancias con value objects =====
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.
==== 3.1 Por qué no Jakarta Validation en el dominio ====
^ 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''.
===== 4. ¿Duplicar la regla en dos sitios? =====
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.
===== 5. Pruebas =====
* **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.