1. Límite entre validación de entrada y de negocio: el validador no es un detective
Cuando un proyecto crece, surge un deseo muy humano: «Vamos a prohibirlo todo con anotaciones, para que los controladores estén limpios y el servicio nunca vea solicitudes incorrectas». Suena bonito, como «vivamos en armonía y sin errores». En la práctica, esto a menudo lleva a validadores que intentan leer el estado actual del sistema, y de repente la capa de validación se convierte en una pequeña… capa de servicio, pero peor.
Imaginemos que estamos escribiendo la API de Task Tracker. Ya tenemos reglas de formato: longitud de title, tamaño de tags, unicidad de etiquetas, orden de dueAfter/dueBefore. Todo esto se puede verificar con solo mirar la solicitud. Pero en cuanto quieras comprobar algo como «una tarea no se puede modificar si está ARCHIVED», te topas con un hecho simple: la solicitud no contiene el estado actual de la tarea (y menos mal, porque es un estado gestionado por el servidor).
Es decir, tenemos dos clases de reglas:
Podemos verificar la entrada sin conocer el mundo exterior — eso es validación de entrada.
Y podemos verificar si se puede realizar la operación teniendo en cuenta el estado actual del recurso y las reglas del dominio — eso es validación de negocio.
Si se mezclan estas clases, se obtiene un validador que accede al repositorio. Comienza a vivir su propia vida, crea dependencias extrañas y, a veces, también comete errores «al estilo Spring»: dependencias cíclicas, consultas inesperadas al almacenamiento y divertidos N+1… incluso sin base de datos.
2. Dos pruebas para separar la validación de entrada y de negocio
La forma más fiable de no confundirse no es intentar aprenderse las definiciones de memoria, sino hacerse dos preguntas muy sencillas. Me gustan este tipo de reglas porque se pueden aplicar incluso cuando el cerebro está cansado, pero el deadline no se cansa (nunca se cansa).
La primera pregunta es: «¿Puedo tomar una decisión mirando solo esta solicitud?» Si es así, es candidata a validación de entrada. Si necesitas leer la tarea del repositorio, mirar su estado actual, comprobar la existencia del recurso, tener en cuenta el historial de transiciones — eso ya no es «calidad de la solicitud», es «permisibilidad de la operación».
La segunda pregunta: «Si mañana llega la misma solicitud, pero el estado del sistema ha cambiado, ¿cambiaría el resultado de la verificación?» Si el resultado depende del estado del sistema, entonces casi siempre es validación de negocio. Por ejemplo, «el título es demasiado corto» mañana seguirá siendo «demasiado corto». En cambio, «una tarea archivada no se puede modificar» depende de si está archivada ahora o no.
Para afianzarlo, es útil tener una pequeña tabla. No es una «lista de reglas para memorizar», sino más bien un mini-mapa del terreno:
| Criterio | Validación de entrada | Validación de negocio |
|---|---|---|
| ¿En qué se basa la decisión? | Solo en el payload de entrada (path/query/body) | Datos actuales del sistema (recurso, estado, reglas de dominio) |
| ¿Dónde vive en el proyecto? | DTO + Bean Validation (@Valid, restricciones) | Capa de servicio / dominio (normalmente if, métodos de dominio, excepciones de dominio) |
| Significado HTTP típico | «La solicitud en sí misma es incorrecta» → tiende a 400 Bad Request | «La solicitud es correcta, pero la operación entra en conflicto con las reglas/estado» → tiende a 409 Conflict |
| ¿Se pueden llamar repositorios/servicios? | No (como disciplina básica) | Sí, normalmente es imprescindible |
| Ejemplos típicos | title vacío, etiquetas duplicadas, dueAfter > dueBefore | «No se puede modificar una tarea ARCHIVED», «No se puede pasar de TODO a DONE» |
Nótese que la tabla dice «tiende al estado», no «ya lo devolvemos todo». Hoy nos importa más colocar correctamente la regla que formatear bonito la respuesta (eso es otra disciplina).
3. Validación de entrada en la API de Task Tracker
La validación de entrada es el «control de acceso en la entrada» de tu API. No en el sentido de «somos malos», sino en el sentido de «protegemos al sistema de la basura». Aquí se verifican la forma, los rangos, las restricciones básicas y la coherencia del propio mensaje de entrada. Lo mejor: estas comprobaciones suelen ser deterministas y no requieren ir al almacenamiento.
Empecemos por lo simple y familiar: restricciones en los campos.
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
// Obligatorio + límites de longitud: es una verificación pura de la "forma" de la solicitud
@NotBlank
@Size(min = 3, max = 120)
String title,
// Opcional, pero limitamos el tamaño máximo
@Size(max = 2000)
String description,
// También "forma": límite de longitud, sin acceso al sistema
@Size(max = 80)
String assigneeName,
// Unicidad dentro del payload (por ejemplo, "bug" y "Bug") — es validación de entrada
@UniqueTagsIgnoreCase
@Size(max = 10)
List<@NotBlank @Size(max = 30) String> tags
) {}
Aquí todo trata sobre «la propia solicitud»: longitudes, obligatoriedad, formato de la colección. Ninguno de estos puntos requiere saber qué tareas existen ya, qué estado tiene una tarea concreta, o qué ocurre en el repositorio. Si el cliente envía title = "", es una solicitud incorrecta en cualquier clima, en cualquier huso horario, e incluso si el servidor está hoy de buen humor.
Ahora un ejemplo un poco más complicado: una regla entre campos. Ayer ya vimos que un «rango de fechas» no se puede expresar con una sola anotación en un único campo.
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.validation.ValidTaskDueDateRange;
import java.time.LocalDate;
// Verificación de un par de campos a la vez: una regla así no se puede expresar con un solo campo
@ValidTaskDueDateRange
public record TaskSearchCriteria(
LocalDate dueAfter,
LocalDate dueBefore
) {}
Y el validador (breve, en esencia):
package com.example.tasktracker.domain.validation;
import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class ValidTaskDueDateRangeValidator implements ConstraintValidator<ValidTaskDueDateRange, TaskSearchCriteria> {
@Override
public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
// Si el criterio no se ha definido por completo — no "rompemos" la solicitud: es un caso de búsqueda válido sin rango
if (value == null || value.dueAfter() == null || value.dueBefore() == null) {
return true;
}
// Regla principal: el inicio del rango no debe ser posterior al final
return !value.dueAfter().isAfter(value.dueBefore());
}
}
Esto también es validación de entrada. ¿Por qué? Porque la regla se calcula completamente a partir de la entrada. No necesitamos «recuperar la tarea» — verificamos que el criterio de búsqueda en sí mismo sea lógico.
Ahora, sobre 400 Bad Request. En el marco del modelo semántico de HTTP, 400 es lo que mejor describe la situación «su solicitud no se puede procesar porque es incorrecta». Como servidor puedes ser muy diligente, pero un title vacío no lo «adivinas» — no eres telépata.
Para sentir la diferencia, mira dos ejemplos de entrada. Ambos son «malos», pero de distinta manera.
Ejemplo de «forma incorrecta» (candidato a 400): etiquetas duplicadas dentro de una misma solicitud.
{
"title": "Fix prod",
"tags": ["bug", "Bug"]
}
No estamos obligados a consultar el repositorio: ya dentro de un único payload se ve que esto contradice las reglas.
4. Validación de negocio y semántica de 409
La validación de negocio comienza donde termina la «verificación de la forma» y comienza la «verificación de la viabilidad de la operación». La solicitud puede estar perfectamente formada: tipos correctos, cadenas correctas, fechas ideales… pero la operación puede estar prohibida por las reglas del dominio. Y esto no es un capricho del servidor, sino parte del contrato.
Idea clave: la validación de negocio casi siempre requiere el estado actual del recurso. Por lo tanto, casi siempre vive en el servicio (o cerca de él, en la lógica de dominio), porque es el servicio quien obtiene el recurso y gestiona la operación.
El ejemplo más clásico de nuestro proyecto: no se puede modificar una tarea archivada. Para comprobarlo, necesitamos obtener la tarea y ver su estado.
Tipo mínimo de excepción de dominio (sin «HTTP bonito» por ahora, solo el significado):
package com.example.tasktracker.domain.exception;
// Excepción universal "regla de dominio violada"
public class BusinessRuleViolationException extends RuntimeException {
public BusinessRuleViolationException(String message) {
// El mensaje aquí trata sobre el significado de la regla, no sobre el formato del JSON de entrada
super(message);
}
}
Y ahora la regla, ya en el lado del servicio:
package com.example.tasktracker.domain.service;
import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
public final class TaskRules {
private TaskRules() {}
public static void assertEditable(Task task) {
// Esto es una comprobación de negocio: necesita la tarea actual y su estado (estado gestionado por el servidor)
if (task.status() == TaskStatus.ARCHIVED) {
throw new BusinessRuleViolationException("No se puede modificar una tarea archivada");
}
}
}
¿Por qué esto no es validación de entrada? Porque la solicitud de modificar una tarea puede no contener status en absoluto. Y es correcto: el estado es parte del estado actual de la tarea, no de la forma de la solicitud.
Segundo ejemplo: transición de estados. El nuevo estado puede ser un valor enum válido (DONE, ARCHIVED, etc.), es decir, desde el punto de vista de la validación de entrada está bien: el cliente ha enviado un valor existente. Pero la operación «establecer el estado ARCHIVED» puede estar prohibida si la tarea no está actualmente en DONE.
Helper breve para las transiciones:
package com.example.tasktracker.domain.model;
public final class TaskStatusTransitions {
private TaskStatusTransitions() {}
public static boolean canMove(TaskStatus from, TaskStatus to) {
// Fijamos explícitamente las transiciones permitidas — esto es lógica de dominio, no "validación de forma"
return switch (from) {
case TODO -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.BLOCKED;
case IN_PROGRESS -> to == TaskStatus.BLOCKED || to == TaskStatus.DONE;
case BLOCKED -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.DONE;
case DONE -> to == TaskStatus.ARCHIVED;
case ARCHIVED -> false;
};
}
}
Y la comprobación de negocio en el servicio podría tener este aspecto (sin detalles del repositorio, solo el significado):
import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatusTransitions;
public void assertStatusTransition(Task task, TaskStatus newStatus) {
// La solicitud puede ser "válida" según el enum, pero la operación puede estar prohibida por el estado de la tarea
if (!TaskStatusTransitions.canMove(task.status(), newStatus)) {
throw new BusinessRuleViolationException("Transición de estado no válida");
}
}
Esto es un clásico 409 Conflict en cuanto a significado: la solicitud es estructuralmente normal, pero entra en conflicto con el estado actual del recurso y las reglas de transición.
Si quieres recordarlo con una frase: la validación de entrada responde a «¿se puede entender la solicitud?», la validación de negocio responde a «¿se puede realizar la operación?».
5. Pipeline: DTO verificado — servicio decide
Para que esto no quede en teoría, es útil ver la ruta general de una solicitud en la aplicación. Spring MVC y Bean Validation ya nos proporcionan el «primer cordón» de comprobaciones, pero es el servicio quien debe ser el lugar donde se toman las decisiones de dominio. El controlador en esta historia es un mensajero: recibe el paquete, verifica el embalaje y lo pasa adelante, pero no decide el destino de la mercancía.
Diagrama de flujo (simplificado, sin detalles internos de Spring):
flowchart TD
A[HTTP Request] --> B[Binding: path/query/body -> Java types]
B --> C[Input validation: Bean Validation on DTO]
C -->|OK| D[Controller]
D --> E[Service]
E --> F[Business validation using current state]
F -->|OK| G[Apply operation, save to repo]
C -->|Failed| H[Bad request semantics: 400]
F -->|Violation| I[Conflict semantics: 409]
Ahora un fragmento del controlador. Nótese que aquí no hace falta «magia»: basta con @Valid, y la validación de entrada se ejecutará antes de entrar al servicio.
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public void create(
// @Valid activa la validación de entrada según las anotaciones del DTO antes de llamar al servicio
@Valid @RequestBody TaskCreateRequest request
) {
// El controlador no decide las reglas de dominio — pasa la solicitud "empaquetada" hacia adelante
taskService.create(request);
}
}
Sí, aquí el servicio acepta el DTO de solicitud; en un proyecto real a menudo mapearás el DTO a un modelo/comando interno mediante un mapper (para eso tenemos incluso el paquete api.mapper). Pero para entender el límite de la validación, esto no es crítico: lo importante es el principio «DTO verificado — luego decide el servicio».
Y aquí un ejemplo de cómo se ve en el servicio la secuencia «primero obtén el estado, luego decide si se puede»:
package com.example.tasktracker.domain.service;
import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.repository.TaskRepository;
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Task getTaskOrThrow(String taskId) {
// Esto no es validación de entrada: vamos al almacenamiento y obtenemos el estado actual del sistema
return taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
}
Y el uso de la regla de negocio:
import com.example.tasktracker.domain.model.Task;
public void updateTask(String taskId) {
// 1) Primero obtenemos el recurso (estado actual)
Task task = getTaskOrThrow(taskId);
// 2) Luego verificamos las restricciones de dominio para la operación
TaskRules.assertEditable(task);
// 3) Después se puede actualizar y guardar
}
Parece «demasiado simple», pero ahí está su fuerza: la validación de negocio no tiene que estar implementada como una anotación para ser útil. Debe estar en el lugar correcto, junto a la operación.
6. Zonas grises: recurso, unicidad, tiempo
En los proyectos reales hay zonas donde es fácil empezar a discutir (o hacerlo mal en silencio). Y, sinceramente, a veces discutir es útil, porque significa que has empezado a pensar en la API como un contrato, no como un conjunto de métodos. Analicemos varias zonas grises para que tengas un punto de apoyo, no una «opinión de chat».
Primer caso: existencia del recurso. Por ejemplo, POST /api/v1/tasks/{taskId}/comments: la solicitud de comentario puede ser perfecta en cuanto a la forma, pero la tarea puede no existir. Esto definitivamente no es validación de entrada, porque no se puede determinar a partir del payload. Es una comprobación del estado del sistema. Semánticamente, suele estar más cerca de 404 Not Found que de 409, porque no hay un «conflicto con el estado» — el recurso simplemente no existe. Pero lo importante es que esta comprobación se realiza en el servicio, no en ConstraintValidator.
Segundo caso: unicidad en el sistema. Por ejemplo, «el título de la tarea debe ser único entre todas las tareas» (en nuestras especificaciones no aparece, pero como ejemplo). Esto es casi siempre validación de negocio, porque necesitas comprobar «externamente» el conjunto actual de datos. La validación de unicidad dentro de una misma lista de etiquetas es validación de entrada. La unicidad en todo el almacenamiento es validación de negocio.
Aquí a menudo se comete un antipatrón: escribir una restricción personalizada @UniqueTaskTitle que accede al repositorio. Técnicamente, Spring puede permitirlo (el validador puede ser un bean y obtener la dependencia), pero conceptualmente estás convirtiendo la capa de validación en una capa de negocio. Esto hace que el procesamiento sea más complejo, complica las dependencias y a menudo provoca efectos inesperados.
Cómo se ve un ejemplo «tentador pero peligroso»:
import com.example.tasktracker.domain.repository.TaskRepository;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class ExistingTaskIdValidator implements ConstraintValidator<ExistingTaskId, String> {
private final TaskRepository taskRepository;
public ExistingTaskIdValidator(TaskRepository taskRepository) {
// El validador depende de repente del almacenamiento/repositorio — eso es "mezclar capas"
this.taskRepository = taskRepository;
}
@Override
public boolean isValid(String taskId, ConstraintValidatorContext context) {
// Aquí la validación se convierte en una consulta al sistema: el resultado depende de los datos en BD/almacenamiento
return taskId != null && taskRepository.existsById(taskId);
}
}
¿Por qué es malo como camino básico? Porque la «validez» de la solicitud se vuelve de repente dependiente del almacenamiento. Y la validación puede ejecutarse con frecuencia, en diferentes niveles, antes de que siquiera hayas decidido qué operación estás haciendo. Como resultado, puedes obtener consultas adicionales al almacenamiento y escenarios extraños donde «la validación pasó, pero luego igual no funcionó».
Tercer caso: reglas «dependientes del tiempo», por ejemplo, «dueDate no puede estar en el pasado con respecto al día de hoy». Formalmente, no lees el estado del sistema, pero usas la «hora actual», por lo que el resultado de la comprobación puede variar. La mayoría de las veces sigue siendo validación de entrada (porque no requiere un recurso), pero aquí es importante hacer la comprobación con cuidado: usar Clock, pensar en la zona horaria y recordar que «ayer/hoy» es algo resbaladizo. Si la regla es realmente de dominio («no se puede establecer dueDate anterior a createdAt de la tarea»), entonces ya depende del estado de una tarea concreta y pasa a ser validación de negocio.
7. Errores típicos en el límite
Error nº 1: intentar prohibir «con anotaciones» lo que no está en la solicitud.
La trampa más común es querer escribir @NotArchivedTask en el DTO de actualización. Pero el DTO no sabe si la tarea actual está archivada, porque ese estado se almacena en el servidor. Al final, el validador o bien empezará a acceder al repositorio (y mezclarás las capas), o será inútil y siempre «válido».
Error nº 2: convertir ConstraintValidator en un mini-servicio.
En cuanto el validador obtiene un repositorio, logging, configuraciones y empieza a «verificarlo todo», pierdes el modelo simple: «DTO → anotación → comprobación pura». La validación se convierte no en un filtro de entrada predecible, sino en una operación de negocio disfrazada de anotación. Mantenerlo es difícil, y depurarlo es aún más divertido (especialmente cuando la diversión es en producción).
Error nº 3: etiquetar las restricciones de negocio como 400 Bad Request.
Si la solicitud es correcta, pero la operación está prohibida por las reglas del dominio, 400 convierte la situación en «el cliente envió basura». Esto rompe la lógica del cliente: la interfaz de usuario suele reaccionar de forma diferente a «corrige los campos del formulario» y a «operación prohibida porque el recurso está en ese estado». Semánticamente, estos casos tienden a 409.
Error nº 4: duplicar la misma regla sin motivo tanto en el DTO como en el servicio.
A veces surge la tentación de «por si acaso» verificar las etiquetas tanto en @UniqueTagsIgnoreCase como de nuevo en el servicio. Al final tienes dos fuentes de verdad: pueden divergir (por ejemplo, una hace trim de espacios y la otra no). Es mejor tener un único «lugar principal» para la regla y entender a qué tipo pertenece.
Error nº 5: confundir «validación» con «normalización de datos».
La validación responde a «sí/no». La normalización responde a «cómo llevar a la forma canónica». A veces el validador realmente necesita hacer una normalización ligera (por ejemplo, trim() para comparar etiquetas), pero si dentro del validador empiezas a cambiar el significado de los datos, complicas el contrato. La normalización suele ser más apropiada en el mapper/servicio (donde gestionas explícitamente la transformación), no como un efecto secundario oculto de la comprobación.
GO TO FULL VERSION