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
Las rutas ya están revisadas, pero devolver directamente tu modelo permite que un cambio interno altere la respuesta. Un DTO, objeto para transferir datos, define qué campos cruzan la API. Hoy separarás la representación de salida del objeto que utilizas dentro del servidor.
Un recurso no es un objeto
Representación
Lo que viaja por la red cuando alguien pide un recurso. No constituye el recurso, sino una descripción suya en un formato concreto, con los datos que se ha decidido incluir.
Es la segunda idea de la interfaz uniforme que viste en la sesión 9, y ahora se puede decir con precisión:
- El recurso: «la tarea 41», una idea del dominio
- El modelo: la clase
Tareaen tu memoria, con lo que tu código necesita - La representación: el JSON que envías, con lo que el cliente necesita
Ahora mismo tienes las dos últimas acopladas: tu representación es tu modelo, de modo que al modificar una de las dos cambias la otra sin advertirlo.
Los tres daños concretos
No es una cuestión de pureza. Son tres problemas que vas a sufrir:
- 1 · Publicas lo que no querías
- Lo acabas de ver con
notaInterna. Cuando en la UD9 la claseUsuariotenga la contraseña, el mismo mecanismo la publicará sin preguntar. No es hipotético: es una de las filtraciones de datos más habituales que existen. - 2 · No puedes tocar tu código sin romper el de otros
- Renombrar
tituloanombrees una mejora interna de dos segundos. Con el modelo publicado, es un cambio del contrato que rompe a todos los clientes. Acabas no renombrando nada por miedo, y el código se pudre. - 3 · No puedes dar respuestas distintas
- El listado quiere pocos campos y rápido; el detalle quiere todo. Con una sola clase publicada, o mandas de más en el listado o mandas de menos en el detalle.
La regla, de una vez
Tu modelo interno es asunto tuyo. Tu contrato es un compromiso con otros. Son dos cosas con motivos de cambio distintos, y por eso tienen que ser dos clases distintas.
Cuando en la UD5 el modelo pase a ser una entidad de base de datos, con relaciones y carga perezosa, publicarlo directamente pasará de ser incómodo a ser inviable. Lo separamos ahora, mientras es barato.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre el modelo, el controlador y una respuesta GET guardada. Anota exactamente sus campos actuales.
- Localiza qué método devuelve directamente el modelo. Crea el paquete
dtobajo tu paquete base para los tipos de respuesta del ejercicio. - Elige un dato interno de prueba que no deba aparecer en la respuesta. No utilices contraseñas ni datos reales para demostrar el problema.
Paso 2 · Observar qué campos internos se exponen al devolver el modelo
Abre la clase Tarea y añade lo siguiente:
private String notaInterna = "revisar con el jefe de proyecto";
public String getNotaInterna() {
return notaInterna;
}
Reinicia y pide GET /tareas.
[{"id":1,"titulo":"Revisar el login","prioridad":"alta",
"completada":false,"notaInterna":"revisar con el jefe de proyecto"}]
Ahí está, publicado en internet. Tú no has tocado el controlador. No has decidido publicarlo, no lo has añadido a ninguna respuesta y nadie te ha avisado.
Esa es la situación real de tu API desde la UD1: publica exactamente lo que tenga la clase, y la clase la tocas por motivos que no tienen nada que ver con lo que quieres publicar.
Paso 3 · Crear el DTO de respuesta con los campos públicos
DTO
Data Transfer Object. Una clase cuyo único trabajo es transportar datos entre dos sitios. No tiene lógica, no tiene reglas: define qué campos viajan y con qué nombres.
Crea el paquete com.ejemplo.gestor.dto y dentro:
package com.ejemplo.gestor.dto;
public record TareaResponse(
int id,
String titulo,
String prioridad,
boolean completada) {
}
Cuatro líneas. En este caso sí corresponde un record, a diferencia del modelo. La diferencia importa y conviene entenderla:
El modelo · clase
Cambia con el tiempo, se modifica campo a campo, y en la UD5 tendrá que ser una clase con constructor vacío para que JPA la construya.
La respuesta · record
Se crea, se envía y se olvida. Nadie la modifica después. Un record es exactamente eso: datos inmutables y sin ceremonia.
Jackson serializa un record igual de bien, leyendo sus componentes.
Paso 4 · Convertir el modelo al DTO de respuesta
- Abre
dto/TareaResponse.javay añade el método estáticodesdedentro del record. Importacom.ejemplo.gestor.model.Tarea; conserva la lista de componentes del record. - En
TareaController, importaTareaResponsey sustituye detalle y listado por sus versiones con DTO. Si la lista necesitaArrayList, conserva su import dejava.util. - Crea un registro después del reinicio y compara listado y detalle: ambos deben contener los mismos campos públicos y ninguno debe exponer la nota interna.
public record TareaResponse(
int id,
String titulo,
String prioridad,
boolean completada) {
public static TareaResponse desde(Tarea tarea) {
return new TareaResponse(
tarea.getId(),
tarea.getTitulo(),
tarea.getPrioridad(),
tarea.isCompletada());
}
}
El controlador cambia lo mínimo:
@GetMapping("/{id}")
public ResponseEntity<TareaResponse> detalle(@PathVariable(name = "id") int id) {
for (Tarea tarea : tareas) {
if (tarea.getId() == id) {
return ResponseEntity.ok(TareaResponse.desde(tarea));
}
}
return ResponseEntity.notFound().build();
}
Para la colección, se convierte cada elemento:
@GetMapping
public List<TareaResponse> lista() {
List<TareaResponse> respuesta = new ArrayList<>();
for (Tarea tarea : tareas) {
respuesta.add(TareaResponse.desde(tarea));
}
return respuesta;
}
La versión corta, con streams
Lo mismo se escribe en una línea:
return tareas.stream().map(TareaResponse::desde).toList();
Si ya manejas streams, úsalo. Si no, el bucle es igual de correcto y se entiende mejor: no cambies a una sintaxis que no sabrías explicar en una defensa.
Paso 5 · Comprobar que un cambio interno no altera el JSON público
Esta es la prueba de que ha servido para algo, y hay que hacerla:
GET /tareas. El campo notaInterna ha desaparecido, sin haberlo borrado de la clase. Sigue ahí, para tu código, y ya no se publica.
En la clase Tarea, renombra el atributo titulo a nombre, y su getter a getNombre(). El proyecto dejará de compilar en un sitio: el método de conversión. Arréglalo ahí, cambiando tarea.getTitulo() por tarea.getNombre().
{"id":1,"titulo":"Revisar el login","prioridad":"alta","completada":false}
La clave sigue llamándose titulo. Has renombrado un campo del modelo y el contrato no se ha enterado. Ejecuta la colección de Postman: sigue en verde, sin tocar una sola petición.
Lo que acaba de pasar
El compilador te ha llevado al único sitio donde había que tocar, y los clientes no se han enterado de nada. Eso es lo que compras con la separación: libertad para cambiar por dentro.
Antes de esta sesión, ese mismo renombrado habría roto silenciosamente a todo el que consumiera tu API, y no te habrías enterado hasta que alguien se quejara.
Deja el modelo con titulo, como estaba, antes de seguir.
Paso 6 · Identificar las conversiones que requiere mantener DTO separados
Sería deshonesto vendértelo como gratis:
| Ganas | Pagas |
|---|---|
| Decides exactamente qué publicas | Una clase más por cada recurso |
| Puedes refactorizar el modelo sin miedo | Código de conversión que mantener |
| Puedes dar vistas distintas del mismo recurso | Un sitio más que tocar al añadir un campo |
En una aplicación de tres clases el coste resulta perceptible y la ganancia no. En una de treinta sucede lo contrario, y para entonces la separación tiene un coste muy superior.
Y todavía falta la mitad
Hoy solo has separado la salida. La entrada sigue recibiendo el modelo directamente con @RequestBody Tarea, con todo lo que eso implica: el cliente todavía puede mandarte el id, o campos que no debería poder tocar.
Esa es la sesión 11. El código de conversión, escrito aquí a mano y destinado a crecer, se ordena en la 17.
Paso 7 · La representación de proyectos
En dto/ProyectoResponse.java, declara primero los componentes que utiliza el cliente. Añade un método desde(Proyecto proyecto) que copie esos campos; después cambia el detalle y convierte uno a uno los elementos del listado. Busca todos los return proyecto del controlador para no dejar una salida directa del modelo. Comprueba el campo interno con un registro creado después de reiniciar y restaura cualquier renombrado experimental antes de continuar.
- Crea
ProyectoResponsecon los campos que decidas publicar, y justifica en un comentario cuál dejas fuera y por qué. - Cambia el controlador de proyectos para devolverlo, en el elemento y en la colección.
- Añade a
Proyectoun campo interno que no deba publicarse —por ejemplopresupuestoInterno— y comprueba que no aparece. - Repite el paso 2 de la comprobación: renombra un campo del modelo y verifica que la colección de Postman sigue en verde.
Paso 8 · Comprobar y registrar el resultado del proyecto
- Añade el dato interno al modelo y verifica que el JSON público sigue conteniendo solo los campos del DTO.
- Comprueba listado y detalle, tanto para la entidad del ejemplo adaptada como para otra de tu dominio. Actualiza la colección si has decidido cambiar el contrato.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Dos vistas del mismo recurso
El listado de tareas de un proyecto grande devuelve doscientas tareas con todos sus campos. Es lento y el cliente solo necesita pintar una lista con el título y el estado.
- Crea una segunda representación,
TareaResumen, con solo lo imprescindible. - Úsala en la colección
GET /tareasy dejaTareaResponsepara el detalleGET /tareas/{id}. - Comprueba en Postman la diferencia de tamaño entre las dos respuestas.
- Responde por escrito, y esta es la parte importante:
- ¿Qué campos son «imprescindibles» y quién debería decidirlo, tú o quien consume la API?
- Si el cliente necesita un campo más en el listado, ¿qué tiene que pasar? ¿Es eso un problema?
- ¿Qué alternativa se te ocurre a tener dos clases, y qué inconveniente tendría?
La tercera pregunta no tiene una respuesta cerrada. Existen APIs que dejan al cliente elegir los campos con un parámetro, y otras que exponen dos rutas. Las dos decisiones son defendibles; lo que se evalúa es que veas que hay una decisión.
TareaResponse creado y usado, con la nota interna ya fuera del JSON.Ver respuestas
1 · El recurso es la idea del dominio; el modelo es la clase que tu código necesita en memoria; la representación es el JSON concreto que envías, con los campos que hayas decidido publicar.
2 · Publicas datos que no querías, no puedes cambiar el modelo sin romper a tus clientes, y no puedes dar respuestas distintas del mismo recurso.
3 · El modelo cambia campo a campo y tendrá que ser construible por JPA; la respuesta se crea, se envía y nadie la modifica, así que la inmutabilidad de un record encaja y ahorra código.
4 · La entrada. El cuerpo de las peticiones sigue llegando directamente al modelo, y eso permite al cliente mandar campos que no debería poder tocar.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
Una modificación interna no añade campos accidentalmente a la respuesta JSON.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.