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.
@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.
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.
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) |
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.
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")));
}
}