# Día 13: APIs REST Robustas: DTOs con Records y ProblemDetail RFC 7807

> @RestController, Jakarta Validation Declarativa y Manejo Global de Errores en 17/21

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 13 de 30
- Fase 3: El Salto a Spring Boot 3 y APIs REST
- Dedicación: 4h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/apis-rest-dtos-problemdetail/

## Al terminar este día sabrás

- 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.


## Conceptos

### 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.

**Cómo funciona por dentro.**

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.

*Contrato de entrada y salida con records*

```java
public record CrearTareaRequest(
        @NotBlank @Size(max = 120) String titulo,
        @Size(max = 2000) String descripcion,
        @Min(1) @Max(10) int prioridad) {}

public record TareaResponse(
        long id,
        String titulo,
        String descripcion,
        int prioridad,
        boolean completada,
        Instant creadaEn) {

    static TareaResponse desde(Tarea t) {
        return new TareaResponse(t.getId(), t.getTitulo(), t.getDescripcion(),
                t.getPrioridad(), t.isCompletada(), t.getCreadaEn());
    }
}

@RestController
@RequestMapping("/api/tareas")
class TareaController {
    private final TareaService servicio;
    TareaController(TareaService servicio) { this.servicio = servicio; }

    @GetMapping("/{id}")
    TareaResponse obtener(@PathVariable long id) {
        return TareaResponse.desde(servicio.obtener(id));
    }
}
```

- **En una línea:** `public record CrearTareaRequest(@NotBlank String titulo) {}`
- **Por qué importa:** Aísla la API pública de la estructura de las tablas de base de datos.
- **Error típico:** Devolver directamente la entidad `@Entity` de JPA en el `@GetMapping`.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Records, Seguridad de APIs, Clean 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.

**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.

*Una respuesta de error estándar*

```json
HTTP/1.1 404 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"
}
```

- **En una línea:** `ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Tarea no encontrada");`
- **Por qué importa:** Permite que cualquier cliente móvil, web u otro microservicio procese errores sin adivinar formatos caseros.
- **Error típico:** Crear una clase `MiErrorResponse` propia con campos inventados como `codigoInterno` o devolver un simple `Map<String, String>`.
- **Java 17 frente a 21:** Introducido nativamente en Spring Framework 6 / Spring Boot 3.
- **Se conecta con:** RFC 7807, Spring Boot 3, HTTP Status Codes

### 3. Manejo Centralizado con @RestControllerAdvice

Una clase central intercepta todas las excepciones lanzadas por cualquier controlador o servicio mediante métodos `@ExceptionHandler`.

**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.

*Traducción centralizada de errores*

```java
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(TareaNoEncontradaException.class)
    ProblemDetail noEncontrada(TareaNoEncontradaException ex) {
        var pd = 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;
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        var pd = 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)
    ProblemDetail inesperada(Exception ex) {
        log.error("Error no controlado", ex);              // detalle solo en el log
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "Error interno. Si persiste, contacta con soporte.");
    }
}
```

- **En una línea:** `@ExceptionHandler(RecursoNoEncontradoException.class) public ProblemDetail manejar(...)`
- **Por qué importa:** Mantiene los controladores limpios de bloques try-catch y garantiza respuestas uniformes en toda la aplicación.
- **Error típico:** Poner try-catch dentro de cada método de controlador para construir ResponseEntity de error a mano.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Aspect Oriented Programming, ProblemDetail, Manejo 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.

**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.

*Validación en el cuerpo, en la ruta y una regla propia*

```java
@RestController
@RequestMapping("/api/proyectos")
@Validated                                             // necesario para @PathVariable / @RequestParam
class ProyectoController {

    @PostMapping("/{proyectoId}/tareas")
    ResponseEntity<TareaResponse> crear(
            @PathVariable @Positive long proyectoId,
            @RequestBody @Valid CrearTareaRequest request) { /* ... */ return null; }
}

// Regla que afecta a dos campos
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RangoFechasValidator.class)
@interface RangoFechasValido {
    String message() default "La fecha de fin debe ser posterior a la de inicio";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class RangoFechasValidator implements ConstraintValidator<RangoFechasValido, SprintRequest> {
    @Override
    public boolean isValid(SprintRequest s, ConstraintValidatorContext ctx) {
        return s.inicio() == null || s.fin() == null || s.fin().isAfter(s.inicio());
    }
}

@RangoFechasValido
record SprintRequest(@NotBlank String nombre, @NotNull LocalDate inicio, @NotNull LocalDate fin) {}
```

- **En una línea:** `public record LoginRequest(@Email @NotBlank String email, @Size(min=8) String password) {}`
- **Por qué importa:** Filtra peticiones corruptas antes de que lleguen a la lógica de negocio, devolviendo automáticamente HTTP 400 Bad Request.
- **Error típico:** Validar a mano con `if (dto.titulo() == null)` dentro del servicio de negocio.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Jakarta EE, DTOs, Defensive 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.

**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`.

*Códigos correctos en un CRUD*

```java
@PostMapping
ResponseEntity<TareaResponse> crear(@RequestBody @Valid CrearTareaRequest req) {
    var creada = TareaResponse.desde(servicio.crear(req));
    URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
        .path("/{id}").buildAndExpand(creada.id()).toUri();
    return ResponseEntity.created(ubicacion).body(creada);          // 201 + Location
}

@PutMapping("/{id}")
TareaResponse reemplazar(@PathVariable long id, @RequestBody @Valid CrearTareaRequest req) {
    return TareaResponse.desde(servicio.reemplazar(id, req));       // 200
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)                              // 204
void borrar(@PathVariable long id) {
    servicio.borrar(id);                                             // idempotente: si no existe, también 204
}

@PostMapping("/{id}/completar")                                      // acción que no encaja en CRUD
TareaResponse completar(@PathVariable long id) {
    return TareaResponse.desde(servicio.completar(id));             // 409 si ya estaba completada
}
```

- **En una línea:** `return ResponseEntity.created(uri).body(dto);`
- **Por qué importa:** Cumple el estándar de arquitectura REST permitiendo interoperabilidad y caching HTTP.
- **Error típico:** Devolver siempre HTTP 200 con un campo `{ "error": true }` en el cuerpo (anti-patrón gravísimo).
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** REST, HTTP Protocol, ResponseEntity


## Sesión de 90 minutos

### Bloque 1: Controladores y DTOs con Records (0 - 25 min)

- **Qué estudiar:** Anotaciones `@RestController`, `@RequestMapping`, `@GetMapping`, `@PostMapping`.
- **Pregunta a responder:** ¿Por qué los DTOs deben ser records inmutables?
- **Entregable:** 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?
- **Entregable:** 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?
- **Entregable:** 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.
- **Entregable:** Progreso guardado en la base de datos de estudio.


## Explícalo desde cero

### Por qué ProblemDetail (RFC 7807) es un salto gigante frente a versiones anteriores

- **Qué 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.
- **Qué suele explicarse mal:** Explicarlo como "otra clase más de Spring", sin mencionar que es un estándar oficial de internet (RFC).
- **Ejemplo sencillo:** 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.
- **Señal de alerta:** Si en una app Spring Boot 3 devuelves un `Map<String, Object>` en tu manejador de excepciones.


## Preguntas

### Nivel 1 (Básico): ¿Cuál es la diferencia entre `@NotNull`, `@NotEmpty` y `@NotBlank` en Jakarta Validation?

`@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).

**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).

### 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?

Debe incluir la cabecera `Location` con la URL del nuevo recurso creado, generada mediante `ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(id).toUri()`.

**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`).

### 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?

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.

**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.


## Reto del tutor

**Reto:** 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 está bien:** Produce el código HTTP 404 deseado en la respuesta web.
- **Respuesta superficial:** Decir que "funciona bien porque Spring lo soporta".
- **Lo que falta de fondo:** 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.
- **Respuesta senior:** 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.


## Katas

### Kata 13.1: Endpoint POST con ProblemDetail y cabecera Location

Implementa un endpoint POST `/api/v1/usuarios` con DTO validado con `@NotBlank` y `@Email`. Si los datos son inválidos, devuelve HTTP 400 ProblemDetail; si es válido, devuelve HTTP 201 con la URI en la cabecera Location.


## 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.


## Proyecto guiado «Pedidos»: API REST de pedidos

Exponer los casos de uso por HTTP con un contrato claro y errores estándar.

**Tareas:**

- 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.


## Fuentes oficiales

- [Spring Framework: respuestas de error en APIs REST](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html)
- [RFC 9457: Problem Details for HTTP APIs (sustituye a la RFC 7807)](https://www.rfc-editor.org/rfc/rfc9457)
- [Spring Boot: validación](https://docs.spring.io/spring-boot/reference/io/validation.html)

