Diseñar el contrato de una API con DTOs de entrada y salida separados de las entidades.
Devolver errores en formato estándar ProblemDetail desde un único punto.
Validar peticiones de forma declarativa y responder con los errores por campo.
Elegir el método y el código de estado HTTP correctos para cada operación.
1
DTOs Inmutables con Records: Jamás Exponer Entidades en la API
Las peticiones y respuestas REST deben ser record dedicados. Exponer entidades de BD provoca fugas de seguridad, ciclos de serialización JSON y acoplamiento severo.
En una líneapublic record CrearTareaRequest(@NotBlank String titulo) {}
Un DTO (Data Transfer Object) define la forma exacta de los datos que entran y salen por la API. Separarlo de la entidad JPA evita cuatro problemas: exponer campos internos (contraseñas, flags de auditoría), permitir que el cliente modifique campos que no debería (over-posting), acoplar el contrato público al esquema de la base de datos y los ciclos o cargas perezosas al serializar relaciones.
Lo habitual es tener DTOs distintos para la entrada y la salida: la petición de creación no lleva id ni fechas generadas, la respuesta sí. Los records son perfectos para ello: inmutables, concisos y compatibles con Jackson y con Bean Validation.
El mapeo entre entidad y DTO puede ser un método estático, un mapper dedicado o una librería como MapStruct. Lo importante es que viva en la capa de aplicación o de adaptadores, no dentro de la entidad.
Aísla la API pública de la estructura de las tablas de base de datos.
Devolver directamente la entidad @Entity de JPA en el @GetMapping.
Idéntico en 17 y 21.
Se conecta conRecordsSeguridad de APIsClean Architecture
2
ProblemDetail (RFC 7807): El Estándar Oficial de Errores
Especificación estándar de la industria que define un formato JSON predecible para errores HTTP: type, title, status, detail, instance e invalidParameters.
En una líneaProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Tarea no encontrada");
Una respuesta de error estándarjson
HTTP/1.1404 Not Found
Content-Type: application/problem+json
{"type":"https://api.acme.com/problemas/tarea-no-encontrada","title":"Tarea no encontrada","status":404,"detail":"No existe ninguna tarea con id 42","instance":"/api/tareas/42","tareaId":42,"timestamp":"2026-03-01T10:15:30Z"}
Cómo funciona por dentro
Antes de este estándar, cada API devolvía errores con su propio formato: {"error": ...}, {"message": ...}, {"code": ...}. El RFC 7807 (actualizado por el RFC 9457) define un formato común con el tipo de contenido application/problem+json y cinco campos: type (URI que identifica el tipo de problema), title (resumen legible), status (código HTTP), detail (explicación de este caso concreto) e instance (URI de la petición). Se pueden añadir campos propios.
Spring 6 incluye la clase ProblemDetail y la excepción ErrorResponseException. Con spring.mvc.problemdetails.enabled=true, las excepciones propias de Spring MVC (400 por JSON inválido, 405, 415...) se devuelven ya en este formato.
Para un cliente, el beneficio es poder escribir un único manejador de errores para todas tus APIs y distinguir casos por type en lugar de analizar mensajes de texto.
Permite que cualquier cliente móvil, web u otro microservicio procese errores sin adivinar formatos caseros.
Crear una clase MiErrorResponse propia con campos inventados como codigoInterno o devolver un simple Map<String, String>.
Introducido nativamente en Spring Framework 6 / Spring Boot 3.
Se conecta conRFC 7807Spring Boot 3HTTP Status Codes
3
Manejo Centralizado con @RestControllerAdvice
Una clase central intercepta todas las excepciones lanzadas por cualquier controlador o servicio mediante métodos @ExceptionHandler.
En una línea@ExceptionHandler(RecursoNoEncontradoException.class) public ProblemDetail manejar(...)
Traducción centralizada de erroresjava
@RestControllerAdviceclassApiExceptionHandlerextendsResponseEntityExceptionHandler {
privatestaticfinalLoggerlog= LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(TareaNoEncontradaException.class)ProblemDetailnoEncontrada(TareaNoEncontradaException ex) {
varpd= ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.acme.com/problemas/tarea-no-encontrada"));
pd.setTitle("Tarea no encontrada");
pd.setProperty("tareaId", ex.id());
return pd;
}
@OverrideprotectedResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
varpd= ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "La petición tiene campos no válidos");
pd.setProperty("errores", ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("campo", e.getField(), "mensaje", e.getDefaultMessage()))
.toList());
return ResponseEntity.badRequest().body(pd);
}
@ExceptionHandler(Exception.class)ProblemDetailinesperada(Exception ex) {
log.error("Error no controlado", ex); // detalle solo en el logreturn ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Error interno. Si persiste, contacta con soporte.");
}
}
Cómo funciona por dentro
@RestControllerAdvice declara una clase cuyos métodos @ExceptionHandler se aplican a todos los controladores. Así los controladores solo contienen el camino feliz y la traducción de excepciones a respuestas HTTP vive en un único sitio, coherente para toda la API.
Si la clase extiende ResponseEntityExceptionHandler, hereda el tratamiento de todas las excepciones internas de Spring MVC en formato ProblemDetail, y puedes sobrescribir solo las que quieras personalizar, típicamente handleMethodArgumentNotValid para devolver los errores de validación por campo.
Ten siempre un manejador genérico para Exception que devuelva un 500 sin detalles internos (nada de trazas ni mensajes de SQL hacia el cliente) y que registre el error completo en los logs.
Mantiene los controladores limpios de bloques try-catch y garantiza respuestas uniformes en toda la aplicación.
Poner try-catch dentro de cada método de controlador para construir ResponseEntity de error a mano.
Idéntico en 17 y 21.
Se conecta conAspect Oriented ProgrammingProblemDetailManejo de Errores
4
Validación Declarativa con Jakarta Bean Validation
Uso de anotaciones @NotNull, @NotBlank, @Size, @Min, @Email en los componentes de los records DTO, junto con @Valid en el controlador.
En una líneapublic record LoginRequest(@Email @NotBlank String email, @Size(min=8) String password) {}
Validación en el cuerpo, en la ruta y una regla propiajava
@RestController@RequestMapping("/api/proyectos")@Validated// necesario para @PathVariable / @RequestParamclassProyectoController {
@PostMapping("/{proyectoId}/tareas")ResponseEntity<TareaResponse> crear(
@PathVariable@Positivelong proyectoId,
@RequestBody@Valid CrearTareaRequest request) { /* ... */returnnull; }
}
// Regla que afecta a dos campos@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = RangoFechasValidator.class)@interface RangoFechasValido {
Stringmessage()default"La fecha de fin debe ser posterior a la de inicio";
Class<?>[] groups()default {};
Class<? extends Payload>[] payload()default {};
}
classRangoFechasValidatorimplementsConstraintValidator<RangoFechasValido, SprintRequest> {
@OverridepublicbooleanisValid(SprintRequest s, ConstraintValidatorContext ctx) {
return s.inicio() == null || s.fin() == null || s.fin().isAfter(s.inicio());
}
}
@RangoFechasValidorecordSprintRequest(@NotBlank String nombre, @NotNull LocalDate inicio, @NotNull LocalDate fin) {}
Cómo funciona por dentro
Jakarta Bean Validation permite declarar reglas con anotaciones en los campos (@NotBlank, @Size, @Email, @Positive, @Past, @Pattern). Spring las ejecuta automáticamente sobre un @RequestBody si lo marcas con @Valid; si falla, lanza MethodArgumentNotValidException antes de entrar en tu método.
Para validar @PathVariable o @RequestParam hace falta @Validated en la clase del controlador. En ese caso, desde Spring 6.1, la excepción es HandlerMethodValidationException. Para objetos anidados, añade @Valid también en el campo anidado o no se validará en cascada.
Cuando una regla depende de varios campos o del negocio (la fecha de fin posterior a la de inicio), puedes crear una anotación propia con su ConstraintValidator. Las reglas que necesitan consultar la base de datos (unicidad) es mejor dejarlas en el servicio.
Filtra peticiones corruptas antes de que lleguen a la lógica de negocio, devolviendo automáticamente HTTP 400 Bad Request.
Validar a mano con if (dto.titulo() == null) dentro del servicio de negocio.
Idéntico en 17 y 21.
Se conecta conJakarta EEDTOsDefensive Programming
5
Códigos de Estado HTTP Correctos y Semántica REST
201 Created para POST exitoso con cabecera Location; 200 OK para lecturas/actualizaciones; 204 No Content para borrados; 400 para validaciones fallidas; 404 para recursos inexistentes.
En una líneareturn ResponseEntity.created(uri).body(dto);
Códigos correctos en un CRUDjava
@PostMappingResponseEntity<TareaResponse> crear(@RequestBody@Valid CrearTareaRequest req) {
varcreada= TareaResponse.desde(servicio.crear(req));
URIubicacion= ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(creada.id()).toUri();
return ResponseEntity.created(ubicacion).body(creada); // 201 + Location
}
@PutMapping("/{id}")TareaResponsereemplazar(@PathVariablelong id, @RequestBody@Valid CrearTareaRequest req) {
return TareaResponse.desde(servicio.reemplazar(id, req)); // 200
}
@DeleteMapping("/{id}")@ResponseStatus(HttpStatus.NO_CONTENT)// 204voidborrar(@PathVariablelong id) {
servicio.borrar(id); // idempotente: si no existe, también 204
}
@PostMapping("/{id}/completar")// acción que no encaja en CRUDTareaResponsecompletar(@PathVariablelong id) {
return TareaResponse.desde(servicio.completar(id)); // 409 si ya estaba completada
}
Cómo funciona por dentro
Cada método HTTP tiene una semántica que clientes, proxies y cachés dan por supuesta. GET lee y es seguro (no cambia nada) e idempotente. PUT reemplaza un recurso completo y es idempotente: repetirlo deja el mismo estado. PATCH modifica parcialmente. DELETE borra y también es idempotente. POST crea o lanza acciones y no es idempotente: repetirlo puede crear dos recursos.
Los códigos más útiles: 200 OK con cuerpo, 201 Created con cabecera Location al crear, 204 No Content sin cuerpo (típico de DELETE o PUT), 400 petición mal formada o inválida, 401 falta autenticación o es inválida, 403 autenticado pero sin permiso, 404 no existe, 409 Conflict choque con el estado actual (duplicado, versión antigua) y 422 petición bien formada que viola una regla de negocio.
Usar el código correcto permite que el cliente reaccione sin leer el mensaje: reintentar un 503, pedir credenciales con un 401 o refrescar los datos con un 409.
Cumple el estándar de arquitectura REST permitiendo interoperabilidad y caching HTTP.
Devolver siempre HTTP 200 con un campo { "error": true } en el cuerpo (anti-patrón gravísimo).
Idéntico en 17 y 21.
Se conecta conRESTHTTP ProtocolResponseEntity
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
Bloque 1: Controladores y DTOs con Records (0 - 25 min)
Pregunta a responder. ¿Por qué los DTOs deben ser records inmutables?
Resultado esperado. Endpoints GET y POST para un recurso Tarea usando DTOs Records.
Bloque 2: Jakarta Validation (25 - 50 min)
Qué estudiar. Uso de @Valid, @NotBlank, @Size y validaciones complejas.
Pregunta a responder. ¿Qué excepción lanza Spring cuando falla @Valid en el cuerpo JSON?
Resultado esperado. DTO con validaciones probadas enviando JSON inválido.
Bloque 3: RFC 7807 ProblemDetail (50 - 80 min)
Qué estudiar. Clase ProblemDetail y @RestControllerAdvice.
Pregunta a responder. ¿Cómo agregar propiedades personalizadas (e.g. timestamp, errores de campo) a un ProblemDetail?
Resultado esperado. Manejador global que devuelve ProblemDetail ante errores 404 y 400.
Cierre y Verificación (80 - 90 min)
Qué estudiar. Prueba de endpoints con fichero .http o curl verificando cabeceras.
Pregunta a responder. Verificar que POST devuelve 201 y cabecera Location.
Resultado esperado. Progreso guardado en la base de datos de estudio.
Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.
Explícalo desde cero
Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.
Por qué ProblemDetail (RFC 7807) es un salto gigante frente a versiones anteriores
Lo que debes entender
Antes de Spring Boot 3, cada empresa inventaba su propio formato de error (CustomErrorResponse). Si consumías 5 microservicios distintos, tenías que parsear 5 formatos de error diferentes. RFC 7807 es un estándar IETF internacional unificado.
Analogía para contarlo
El enchufe eléctrico estándar europeo frente a que cada fabricante de electrodomésticos te obligue a usar una clavija diferente con forma triangular o hexagonal.
Lo que suele explicarse mal
Explicarlo como "otra clase más de Spring", sin mencionar que es un estándar oficial de internet (RFC).
Señal de que aún no lo dominas
Si en una app Spring Boot 3 devuelves un Map<String, Object> en tu manejador de excepciones.
Preguntas trampa
Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.
Nivel 1 (Básico)¿Cuál es la diferencia entre @NotNull, @NotEmpty y @NotBlank en Jakarta Validation?Ver respuesta
Respuesta razonada
Las tres rechazan null, pero difieren en qué más aceptan. @NotNull solo exige que no sea null: "" y " " son válidos. @NotEmpty exige no nulo y longitud o tamaño mayor que cero; vale para String, colecciones, mapas y arrays, y acepta " ". @NotBlank solo vale para cadenas y exige al menos un carácter que no sea espacio en blanco.
Regla práctica: @NotBlank para textos obligatorios como nombres o títulos, @NotEmpty para listas que deben tener al menos un elemento, @NotNull para el resto de tipos (números envueltos, fechas, enums, objetos anidados).
Qué está evaluando la pregunta
@NotNull solo comprueba que no sea null; @NotEmpty comprueba que no sea null ni tenga longitud 0; @NotBlank comprueba además que no esté compuesta únicamente por espacios en blanco (ideal para strings).
Nivel 2 (Intermedio)¿Qué cabecera HTTP debe incluir obligatoriamente una respuesta HTTP 201 Created según la especificación REST y cómo se genera en Spring?Ver respuesta
Respuesta razonada
La cabecera Location, con la URI del recurso recién creado, para que el cliente sepa dónde consultarlo sin construir la URL a mano. Lo habitual es devolver también la representación del recurso en el cuerpo.
En Spring se construye a partir de la URL de la petición actual: ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(id).toUri(), y se devuelve con ResponseEntity.created(uri).body(dto). Así la URI es correcta aunque la aplicación esté detrás de un proxy (siempre que se configure server.forward-headers-strategy).
Qué está evaluando la pregunta
Debe incluir la cabecera Location con la URL del nuevo recurso creado, generada mediante ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(id).toUri().
Nivel 3 (Entrevista Senior)Si devuelves una entidad JPA directamente en un endpoint GET y esa entidad tiene una relación bidireccional @OneToMany / @ManyToOne, ¿qué ocurre durante la serialización con Jackson?Ver respuesta
Respuesta razonada
Jackson serializa la tarea, que tiene un proyecto, que tiene una lista de tareas, cada una con su proyecto... y entra en recursión infinita hasta un StackOverflowError, que Spring envuelve en HttpMessageNotWritableException. Antes de llegar ahí pueden aparecer otros problemas: LazyInitializationException si la sesión de Hibernate ya está cerrada, o decenas de consultas extra (N+1) si sigue abierta con open-in-view.
Se puede parchear con @JsonManagedReference y @JsonBackReference o con @JsonIgnore, pero eso mete decisiones de presentación en la entidad. La solución correcta es no serializar entidades: mapear a un DTO que contiene exactamente los campos que la API quiere exponer, por ejemplo el id y el nombre del proyecto, no el objeto entero.
Qué está evaluando la pregunta
Jackson entrará en una recursión infinita intentando serializar el padre, que contiene al hijo, que contiene al padre... hasta provocar un StackOverflowError que tira el hilo de Tomcat. Por eso los DTOs con records son obligatorios.
Reto del tutor
Un candidato dice: "Para avisar que no encontré un usuario, lanzo throw new ResponseStatusException(HttpStatus.NOT_FOUND) dentro de mi método UsuarioService.buscar()". ¿Qué opinas de esta práctica?
Lo que una respuesta suele tener bien
Produce el código HTTP 404 deseado en la respuesta web.
Lo que suele quedarse en la superficie
Decir que "funciona bien porque Spring lo soporta".
El concepto de fondo que suele faltar
No entender que acopla la capa de negocio al protocolo web HTTP: si ese mismo servicio se usa desde un consumidor de eventos Kafka, un comando CLI o un batch, los conceptos de HttpStatus no tienen ningún sentido.
Cómo lo respondería alguien senior en una entrevista
Es una violación de la separación de capas: los servicios deben lanzar excepciones de negocio puras (UsuarioNoEncontradoException extends RuntimeException). Debe ser el @RestControllerAdvice de la capa web quien atrape la excepción de dominio y decida traducirla a un HTTP 404 ProblemDetail. Así el servicio de negocio queda 100% puro y reutilizable en cualquier contexto no web.
Proyecto «Pedidos» · paso 13 de 30
API REST de pedidos
Exponer los casos de uso por HTTP con un contrato claro y errores estándar.
De qué va el proyecto
El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.
Qué hacer
Crea POST /pedidos, GET /pedidos/{id}, POST /pedidos/{id}/confirmar y POST /pedidos/{id}/cancelar con DTOs en records.
Valida la entrada con Bean Validation (al menos una línea, cantidades positivas).
Traduce las excepciones de dominio a ProblemDetail en un @RestControllerAdvice (404, 409, 400).
Está listo cuando
Crear un pedido devuelve 201 con la cabecera Location.
Confirmar un pedido cancelado devuelve 409 con un ProblemDetail que explica el motivo.
Katas: ejercicios cortos
Reglas de oro
Nunca expongas entidades @Entity en tu API REST; usa siempre records DTO.
Lanza excepciones puras de dominio en servicios y usa @RestControllerAdvice para mapearlas a ProblemDetail.
Usa @NotBlank para strings que no deben ser vacíos ni contener solo espacios.