Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado el presupuesto de calidad. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Tu API ya tiene rutas y respuestas comprobadas. Sin embargo, devuelve los mismos objetos que utiliza internamente. Añadir al modelo un getter para un dato interno puede hacer que Jackson lo incluya en el JSON sin haber cambiado el controlador. Hoy definirás explícitamente los datos públicos de salida.
Modelo y representación
El modelo es el objeto que utiliza el servidor: una instancia de Tarea guardada en la lista. La representación es el JSON que recibe el cliente. Pueden contener datos distintos. Por ejemplo, una observación interna de revisión puede pertenecer al modelo sin formar parte de la respuesta pública.
Un DTO, del inglés Data Transfer Object, es un tipo destinado a transportar datos. TareaResponse describirá los campos que devolvemos. Un método de mapeo copiará esos campos desde Tarea al DTO. Si un dato no se incluye en esa conversión, no aparecerá por accidente en la respuesta.
Lista en memoria → Tarea → conversión a TareaResponse → Jackson → JSON
Un DTO de salida no cambia por sí mismo las entradas: POST, PUT y PATCH todavía reciben el modelo. Separaremos esas entradas en la sesión 11. Tampoco sustituye a las comprobaciones: hay que aplicar el DTO en todas las rutas que devuelven ese recurso.
Un record para la respuesta
Hasta ahora, tus entidades (Tarea, Proyecto, Responsable) son clases estándar de Java con atributos privados, constructor y métodos getter y setter. Para un DTO de salida que solo transporta datos y no cambia una vez creado, Java ofrece una estructura mucho más concisa y segura: el record.
Fíjate en la diferencia entre una clase clásica y un record:
// Clase tradicional (más de 20 líneas de código repetitivo solo para transportar datos):
public class ResponsableResponse {
private final String nombre;
private final String email;
public ResponsableResponse(String nombre, String email) {
this.nombre = nombre;
this.email = email;
}
public String getNombre() { return nombre; }
public String getEmail() { return email; }
}
// Record en Java moderno (una sola línea para exactamente lo mismo):
public record ResponsableResponse(String nombre, String email) {}
¿Qué hace un record entre bambalinas?
- Componentes: los parámetros declarados entre paréntesis
(String nombre, String email)definen los datos del record. - Lo que Java genera solo: atributos
private final, el constructor canónico con todos los argumentos, y las implementaciones deequals(),hashCode()ytoString(). - Diferencia clave en los métodos accesores: en un record no se usa el prefijo
get. El método de lectura se llama igual que el componente:responsable.nombre()yresponsable.email(). Jackson comprende esta convención sin configuración adicional y genera las claves JSON"nombre"y"email". - Inmutabilidad: sus componentes no se reasignan tras la construcción (inmutabilidad superficial). No tienen setters. (Ten en cuenta que si un componente es una lista mutable, la lista en sí puede modificarse; el record no la hace inmutable mágicamente).
- Cómo crearlo en el IDE: en IntelliJ IDEA, Eclipse o VS Code, haz clic derecho sobre el paquete
dto→ New → Java Class → selecciona Record (o crea un archivo.javay sustituye la palabra claveclassporrecord).
El modelo interno (Tarea) sigue siendo una clase mutable ordinaria con getters y setters, porque nuestras operaciones modifican sus campos en memoria. Los records los reservamos para las representaciones (DTO).
Mantener el contrato al separar la salida
Introducir DTO no debe eliminar campos públicos por descuido. Debes conservar también filtros, códigos, Location y referencias entre entidades. La colección anterior sirve como prueba de regresión: detecta si algo que funcionaba deja de hacerlo.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Registrar las salidas actuales — 10 minutos
- Abre una rama de trabajo y ejecuta la colección actual.
- Crea un registro completo, con los campos añadidos en la sesión 5 y su relación de la sesión 8. Conserva su JSON de petición para recrearlo cuando reinicies.
- Compara las respuestas de listado, detalle, POST, PUT, PATCH y consulta anidada. Haz una lista de los campos públicos que deben mantenerse.
- En el ejemplo son
id,titulo,prioridad,completada,proyectoId,etiquetas,vencimientoyresponsable. Adapta esta lista al contrato real de tu producto.
Paso 2 · Reproducir una exposición accidental — 10 minutos
Añade a Tarea un dato ficticio y su getter:
private String notaInterna = "pendiente de revisión interna";
public String getNotaInterna() {
return notaInterna;
}
Reinicia. La lista está vacía: vuelve a crear el registro con tu petición guardada antes de consultar. Comprueba la respuesta del POST y después el GET de detalle. La nota debe aparecer aunque no hayas cambiado esos métodos.
Este comportamiento se observa en tu servidor local; no hace falta publicar datos reales para demostrarlo. Conserva el campo durante el ejercicio para comprobar que el DTO lo excluye.
Paso 3 · Definir la representación y su conversión — 25 minutos
- Bajo tu paquete base crea la carpeta o paquete
dto. Los siguientes archivos utilizancom.ejemplo.gestor; sustituye ese prefijo si tu aplicación usa otro. - El objeto
responsabletambién necesita una representación si lo mantienes público. Crea el recorddto/ResponsableResponse.java:
package com.ejemplo.gestor.dto;
import com.ejemplo.gestor.model.Responsable;
public record ResponsableResponse(String nombre, String email) {
public static ResponsableResponse desde(Responsable responsable) {
if (responsable == null) {
return null;
}
return new ResponsableResponse(
responsable.getNombre(), responsable.getEmail());
}
}
Fíjate en dos decisiones de diseño clave:
- El método estático
desde(...)es una función de factoría: concentra la traducción del modelo a la representación en un único punto, evitando tener que instanciar el DTO campo a campo en cada controlador. - La comprobación de
nullpermite que una tarea sin responsable siga siendo representable sin provocar un errorNullPointerException. Devolver directamenteResponsabletrasladaría el problema de exposición accidental de campos al objeto anidado.
- Crea el record
dto/TareaResponse.java. El constructor canónico generado por el record exige pasar los argumentos exactamente en el mismo orden en que declaraste los componentes:
package com.ejemplo.gestor.dto;
import com.ejemplo.gestor.model.Tarea;
import java.time.LocalDate;
import java.util.List;
public record TareaResponse(
int id,
String titulo,
String prioridad,
boolean completada,
int proyectoId,
List<String> etiquetas,
LocalDate vencimiento,
ResponsableResponse responsable) {
public static TareaResponse desde(Tarea tarea) {
return new TareaResponse(
tarea.getId(),
tarea.getTitulo(),
tarea.getPrioridad(),
tarea.isCompletada(),
tarea.getProyectoId(),
tarea.getEtiquetas(),
tarea.getVencimiento(),
ResponsableResponse.desde(tarea.getResponsable()));
}
}
- Comprueba que cada getter existe en tu modelo. Si tu dominio usa otros campos o tipos, adapta conjuntamente los componentes y el mapeo; no borres campos públicos solo para copiar el ejemplo.
notaInternaqueda fuera deliberadamente.
Paso 4 · Convertir todas las respuestas de tareas — 35 minutos
Importa TareaResponse en los dos controladores que devuelven tareas. Conserva las anotaciones y rutas de la sesión 9. Cambia una operación cada vez y compruébala.
Para visualizar la mecánica de sustitución, observa cómo se transforma un endpoint individual de detalle:
// ── Detalle: GET /tareas/{id} ──
// Antes (exponía directamente la entidad interna del modelo):
@GetMapping("/tareas/{id}")
public ResponseEntity<Tarea> detalle(@PathVariable int id) {
Tarea tarea = buscarPorId(id);
if (tarea == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(tarea);
}
// Ahora (devuelve la representación desacoplada y protegida por el DTO):
@GetMapping("/tareas/{id}")
public ResponseEntity<TareaResponse> detalle(@PathVariable int id) {
Tarea tarea = buscarPorId(id);
if (tarea == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(TareaResponse.desde(tarea));
}
En las operaciones de creación (POST /tareas y POST /proyectos/{id}/tareas), la cabecera Location sigue apuntando a la URI de detalle canónico del nuevo recurso y el cuerpo devuelto se envuelve con el DTO:
// Retorno ResponseEntity<TareaResponse>:
return ResponseEntity.created(ubicacion).body(TareaResponse.desde(tarea));
Listado: convierte cada elemento que supera el filtro. Dentro de TareaController, cuya anotación de clase ya no añade /tareas, el método queda así:
@GetMapping("/tareas")
public List<TareaResponse> lista(
@RequestParam(name = "completada", required = false) Boolean completada) {
List<TareaResponse> respuesta = new ArrayList<>();
for (Tarea tarea : tareas) {
if (completada == null || tarea.isCompletada() == completada) {
respuesta.add(TareaResponse.desde(tarea));
}
}
return respuesta;
}
Conserva los imports de List, ArrayList y RequestParam. Si tu listado tenía produces, mantén esa restricción en la anotación. El filtro se ejecuta antes de convertir: separar la representación no debe hacer que el listado ignore sus parámetros.
Operaciones restantes: utiliza esta tabla para localizar cada salida. Mantén el modelo dentro de las listas; solo se convierte al responder.
| Operación | Cambio en la respuesta |
|---|---|
| POST general y POST dentro del proyecto | Retorno ResponseEntity<TareaResponse> y ResponseEntity.created(ubicacion).body(TareaResponse.desde(tarea)), usando el nombre real de la variable creada |
| PUT y PATCH | Retorno ResponseEntity<TareaResponse> y conversión del objeto actualizado dentro de ResponseEntity.ok(...) |
GET de tareas del proyecto, en ProyectoController |
Retorno ResponseEntity<List<TareaResponse>>; lista resultado de DTO y resultado.add(TareaResponse.desde(tarea)) tras comprobar el filtro |
| DELETE | Sin cambios: sigue devolviendo 204 sin cuerpo |
| Caso de recurso inexistente | Conserva el 404 sin cuerpo |
Al terminar, busca métodos que todavía devuelvan Tarea o List<Tarea>. Las listas internas y los cuerpos de entrada siguen usando el modelo; las respuestas públicas de recursos deben usar DTO. Retira los endpoints espejo temporales de la sesión 5 si siguen presentes: devolvían el modelo únicamente para diagnosticar su conversión.
Paso 5 · Comprobar campos y detectar una regresión — 25 minutos
- Reinicia y recrea los datos completos mediante la colección. Verifica cada salida de la tabla: la nota interna no aparece y los campos públicos mantienen sus valores y tipos.
- Añade una prueba de ausencia de la nota y otra de conservación de una referencia en las respuestas individuales (detalle, POST, PUT).
- En Postman:
pm.expect(pm.response.json()).not.to.have.property("notaInterna"); - En Bruno:
expect(res.getBody()).not.to.have.property("notaInterna");
- En Postman:
- Para los listados (
GET /tareas), es imprescindible aplicar esa condición a cada elemento del array, no al array en sí. Si comprobaras directamente la lista, el test pasaría siempre como un falso positivo (porque un array[]nunca tiene esa propiedad; la tienen los objetos que contiene):
En Postman (pestaña Scripts / Post-response o Tests):
pm.test("Ninguna tarea del listado expone notaInterna", function () {
const tareas = pm.response.json();
tareas.forEach(function (tarea) {
pm.expect(tarea).not.to.have.property("notaInterna");
});
});
En Bruno (pestaña Tests):
test("Ninguna tarea del listado expone notaInterna", function() {
const tareas = res.getBody();
tareas.forEach(tarea => {
expect(tarea).not.to.have.property("notaInterna");
});
});
Repite esta misma comprobación con la consulta anidada (GET /proyectos/{id}/tareas).
4. Crea una tarea completada y otra pendiente. Comprueba los filtros true, false y la ausencia de filtro; utiliza los ids capturados para distinguirlas.
5. En una modificación temporal, haz que el POST vuelva a devolver el modelo. Adapta temporalmente también su tipo de retorno para que compile. La prueba de ausencia de notaInterna debe fallar. Recupera el DTO, reinicia, recrea los datos y verifica que vuelve a pasar. No guardes la avería en la PR.
Alcance de la separación
No renombres todavía los getters de entrada para demostrar esta separación: POST, PUT y PATCH siguen recibiendo el modelo y podrían cambiar su contrato. La prueba de hoy demuestra que añadir información interna no altera las salidas protegidas. La sesión 11 separa también las entradas y organiza el mapeo.
Paso 6 · Aplicarlo a la otra entidad y revisar — 35 minutos
- Crea el DTO de respuesta de la segunda entidad. Conserva los campos públicos de tu inventario, incluidas las listas y fechas añadidas previamente.
- Añade su método
desdey convierte listado, detalle y respuestas de escritura. Mantén los filtros y los estados; no sustituyas las listas internas por listas de DTO. - Añade un campo interno ficticio al modelo y repite las pruebas de ausencia en todas sus salidas.
- Ejecuta la colección completa: incluye las dos formas de crear tareas, las relaciones y las comprobaciones de la sesión 9. Verifica también que
Locationsigue resolviendo el detalle correcto. - Guarda y sube los cambios de la rama. En la PR explica qué datos se publican, cuáles quedan fuera y qué prueba detectaría una salida directa del modelo.
Ampliación si has completado el trabajo
Crea una representación resumida con id, título y estado. Pruébala en una ruta experimental de listado de tu rama y compara el tamaño del JSON con la representación completa usando los mismos registros. Decide qué datos necesitaría el cliente y qué pruebas habría que actualizar si adoptases ese contrato. Retira la ruta experimental antes de integrar si no va a formar parte del contrato acordado.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión: ambas entidades utilizan DTO en todas sus salidas de recursos, conservan los campos públicos y mantienen filtros, relaciones y códigos. La colección comprueba la ausencia de datos internos. Puedes señalar el método de conversión y explicar por qué las entradas todavía requieren el trabajo de la sesión 11.