Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado cerrar el primer proyecto. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Ya has separado la salida con DTO. Hoy separarás también los datos que el cliente puede enviar y reunirás las conversiones repetidas. Un mapper es una clase que transforma un tipo de objeto en otro; no decide reglas de negocio ni atiende peticiones.
La puerta que sigue abierta
Ayer cerraste la salida. La entrada sigue exactamente como estaba:
@PostMapping
public ResponseEntity<TareaResponse> crear(@RequestBody Tarea tarea) {
Ese @RequestBody Tarea significa, literalmente: «cliente, rellena tú mi objeto interno». Cualquier campo que exista en la clase Tarea, el cliente puede intentar enviarlo.
Hoy da lo mismo, porque Tarea tiene cuatro campos inocentes. Cuando tenga creadoPor, fechaDeCreacion o esDePago, dejará de darlo.
El fallo que esto produce tiene nombre
Se llama mass assignment: el cliente envía un campo que tú no esperabas que enviara, y como el objeto lo tiene, se asigna solo.
El caso clásico: un formulario de registro que manda {"nombre":"Ana","email":"...","rol":"ADMIN"}. Nadie ha puesto rol en el formulario, pero la clase Usuario lo tiene, y Jackson lo asigna encantado. Aparece en listas de vulnerabilidades reales todos los años.
Entrada y salida no son simétricas
Es la idea de la sesión, y se ve mejor con una tabla que con una explicación:
| Campo | ¿Puede enviarlo el cliente? | ¿Se lo devuelves? |
|---|---|---|
titulo |
Sí | Sí |
prioridad |
Sí | Sí |
id |
No, lo asigna el servidor | Sí |
fechaDeCreacion |
No, la pone el servidor | Sí |
notaInterna |
No | No |
contraseña (en un usuario) |
Sí, al registrarse | Nunca |
Mira las dos últimas filas: hay campos que entran y no salen, y campos que salen y no entran. Con una sola clase para las dos cosas, ninguna de las dos columnas se puede respetar.
TareaRequest· lo que aceptoTarea· lo que manejoTareaResponse· lo que publico
Tres clases por recurso, ¿no es demasiado?
Es la objeción sensata, y la respuesta honesta es «depende».
Cuándo sobra
Un recurso pequeño, interno, con los mismos campos de entrada y de salida y sin nada que ocultar. Ahí tres clases son ceremonia.
Cuándo salva
En cuanto haya un campo que el cliente no deba tocar, uno que no deba ver, o el modelo pase a ser una entidad de base de datos. Es decir: casi siempre, y siempre en la UD5.
En este módulo se hacen las tres siempre, por el mismo motivo por el que en la UD1 se escribía el HTML a mano antes de usar Emmet: primero se aprende a hacerlo, y después se decide cuándo saltárselo.
Cuenta los sitios
Abre tu controlador de tareas y busca todas las líneas que pasan datos de una clase a otra. Vas a encontrar tres zonas:
- En
TareaResponse.desde(), un método estático dentro del DTO - En el
POST, construyendo unTareaa mano campo a campo - En el
PATCH, con una cadena deifque asigna uno a uno
Tres estilos distintos para el mismo trabajo, en dos archivos. Ninguno responde a una decisión: han ido apareciendo.
La dirección de las dependencias
Esto importa más de lo que parece, y es una pregunta habitual en una defensa:
- DTO · no conoce a nadie
- Mapper · conoce a los dos
- Modelo · no conoce a nadie
El modelo no sabe que existe una API. Los DTO no saben que existe un modelo. Solo el mapper sabe de ambos, y por eso es el único archivo que hay que tocar cuando cambia la traducción.
Si mañana publicas la misma aplicación por otro canal, el modelo se reutiliza entero. Si mañana cambias de base de datos, los DTO no se enteran.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre los DTO de salida, el modelo y los métodos POST, PUT y PATCH. Localiza qué campos recibe todavía el modelo directamente.
- Escribe qué campos puede enviar el cliente al crear y cuáles solo asigna el servidor. Define también qué significa omitir un campo en PATCH.
- Busca conversiones repetidas entre modelo y DTO. Esas asignaciones son el punto de partida del mapper; mantén la colección disponible para comprobar la refactorización.
Paso 2 · El DTO de entrada es una lista blanca
Crea TareaRequest.java en dto antes de modificar el POST. Sustituye el tipo del argumento por TareaRequest y construye una Tarea copiando solo los campos admitidos; la identidad la sigue asignando el servidor. El import del modelo permanece porque aún se almacena una Tarea. Si en la sesión 5 activaste el rechazo de campos desconocidos, el intento de enviar id dará 400; si lo desactivaste, se ignorará. En ambos casos el cliente no debe imponer el id.
package com.ejemplo.gestor.dto;
public class TareaRequest {
private String titulo;
private String prioridad;
private Integer proyectoId;
public TareaRequest() {
}
public String getTitulo() {
return titulo;
}
public void setTitulo(String titulo) {
this.titulo = titulo;
}
public String getPrioridad() {
return prioridad;
}
public void setPrioridad(String prioridad) {
this.prioridad = prioridad;
}
public Integer getProyectoId() {
return proyectoId;
}
public void setProyectoId(Integer proyectoId) {
this.proyectoId = proyectoId;
}
}
Conviene observar lo que no aparece: no hay id ni completada. En esa ausencia está la decisión de diseño:
Lo que no existe no se puede asignar
Si el cliente envía {"id": 999, "titulo": "Algo"}, Jackson busca un setter para id en TareaRequest, no lo encuentra, y —como aprendiste en la UD2— lo ignora en silencio.
Ese silencio que en la UD2 era un peligro, aquí es exactamente la protección que necesitas. El DTO de entrada define qué campos existen para el cliente, y todo lo demás deja de ser un problema, sin escribir una sola comprobación.
Otro detalle tampoco es casual: se trata de una clase, no de un record. Jackson necesita construirla vacía y rellenarla con los setters, y en la sesión 12 le colgaremos anotaciones de validación campo a campo.
El controlador
@PostMapping
public ResponseEntity<TareaResponse> crear(@RequestBody TareaRequest peticion) {
Tarea tarea = new Tarea();
tarea.setId(siguienteId);
siguienteId = siguienteId + 1;
tarea.setTitulo(peticion.getTitulo());
tarea.setPrioridad(peticion.getPrioridad());
tarea.setCompletada(false);
tareas.add(tarea);
URI ubicacion = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(tarea.getId())
.toUri();
return ResponseEntity.created(ubicacion).body(TareaResponse.desde(tarea));
}
Lee la línea tarea.setCompletada(false). Es una decisión de negocio que antes estaba en manos del cliente: una tarea recién creada nace sin completar. Antes, si el cliente mandaba "completada": true, nacía terminada. Nadie lo había decidido; simplemente el campo estaba ahí.
POST /tareas
{ "id": 999, "titulo": "Colarme un id", "completada": true }
Responde 201, con id asignado por el servidor y completada en false. Los dos campos que sobraban se han ignorado, sin una sola línea de comprobación.
Paso 3 · El DTO que arregla el PATCH
Crea dto/TareaPatchRequest.java con los campos del bloque, un constructor público sin argumentos y getters/setters para cada campo. Para Boolean completada, utiliza getCompletada() y setCompletada(Boolean completada): no lo conviertas a boolean. Sustituye el argumento del PATCH existente por ese DTO y aplica solo valores no nulos. Prueba separadamente un campo omitido y "completada": false para verificar que producen decisiones distintas.
public class TareaPatchRequest {
private String titulo;
private String prioridad;
private Boolean completada;
// constructor vacío, getters y setters
}
Boolean, con mayúscula. El envoltorio sí puede valer null, así que ahora sí se distingue:
| Cuerpo enviado | completada vale |
Significa |
|---|---|---|
{"titulo":"X"} |
null |
No me lo han enviado: no lo toques |
{"completada":true} |
true |
Pónmelo a verdadero |
{"completada":false} |
false |
Pónmelo a falso |
@PatchMapping("/{id}")
public ResponseEntity<TareaResponse> modificar(
@PathVariable(name = "id") int id,
@RequestBody TareaPatchRequest cambios) {
for (Tarea tarea : tareas) {
if (tarea.getId() == id) {
if (cambios.getTitulo() != null) {
tarea.setTitulo(cambios.getTitulo());
}
if (cambios.getPrioridad() != null) {
tarea.setPrioridad(cambios.getPrioridad());
}
if (cambios.getCompletada() != null) {
tarea.setCompletada(cambios.getCompletada());
}
return ResponseEntity.ok(TareaResponse.desde(tarea));
}
}
return ResponseEntity.notFound().build();
}
Una limitación se ha resuelto, la otra no
Ya puedes marcar y desmarcar una tarea. Lo que sigue sin poderse es vaciar un campo a propósito: {"prioridad": null} llega como null igual que si no lo hubieras enviado.
Se resuelve, y la solución habitual es declarar los campos como Optional<String>: entonces «no enviado» llega como null y «enviado como nulo» llega como Optional.empty(). No lo vamos a implementar, pero sabe que existe y por qué hace falta: es una pregunta de entrevista frecuente.
Paso 4 · La entrada de proyectos
- Crea
ProyectoRequestcon solo lo que un cliente puede enviar al crear un proyecto. - Crea
ProyectoPatchRequestcon tipos envoltorio. - Cambia el controlador para usarlos en
POST,PUTyPATCH. - Comprueba con Postman que un
POSTcon unidy con un campo interno responde201e ignora los dos. - Anota en las decisiones técnicas qué campos dejaste fuera de la entrada y por qué.
Del JSON al modelo y del modelo al JSON
Paso 5 · Reproducir una conversión inconsistente entre endpoints
No es un problema estético. Vamos a producirlo a propósito para que lo veas.
Añade proyectoId a Tarea y a TareaResponse, con sus accesos.
Actualiza TareaResponse.desde() para que lo incluya. A continuación, omite deliberadamente el listado: deja el GET /tareas construyendo la respuesta a mano con null en el nuevo componente. Omitir el quinto argumento impediría compilar; el fallo que vamos a observar es pasar un valor incorrecto:
@GetMapping
public List<TareaResponse> lista() {
List<TareaResponse> respuesta = new ArrayList<>();
for (Tarea tarea : tareas) {
respuesta.add(new TareaResponse(
tarea.getId(),
tarea.getTitulo(),
tarea.getPrioridad(),
tarea.isCompletada(),
null)); // Fallo intencionado: debería copiar tarea.getProyectoId()
}
return respuesta;
}
GET /tareas → [{"id":1,"titulo":"Revisar","prioridad":"alta","completada":false,"proyectoId":null}]
GET /tareas/1 → {"id":1,"titulo":"Revisar","prioridad":"alta","completada":false,"proyectoId":7}
El mismo recurso, dos representaciones distintas. El cliente que pinta la lista no ve el proyecto; el que abre el detalle sí. Ningún mecanismo falla ni advierte del problema: los dos endpoints responden 200.
Por qué este fallo es de los peores
No lo detecta el compilador, no lo detecta el linter y no lo detecta tu colección, porque las dos respuestas son válidas. Lo detecta un cliente, semanas después, preguntando «¿por qué a veces viene el proyecto y a veces no?».
La causa no es un descuido: existían dos lugares donde aplicar el mismo cambio y solo uno era obligatorio.
Paso 6 · Reunir la conversión de objetos en un mapper
Crea mapper/TareaMapper.java con el bloque completo y revisa los imports de los tres DTO y del modelo. Antes de usarlo, comprueba que proyectoId existe tanto en Tarea como en TareaResponse y que el constructor del record recibe sus cinco componentes en el mismo orden. Después sustituye las conversiones del controlador por llamadas al mapper y elimina TareaResponse.desde cuando ya no tenga usos: la conversión tendrá una sola implementación.
Mapper
Una clase cuyo único trabajo es convertir entre representaciones y modelo. Ni valida, ni guarda, ni decide: traduce. Constituye además el único punto del proyecto donde se traduce.
Crea el paquete com.ejemplo.gestor.mapper:
package com.ejemplo.gestor.mapper;
import com.ejemplo.gestor.dto.TareaPatchRequest;
import com.ejemplo.gestor.dto.TareaRequest;
import com.ejemplo.gestor.dto.TareaResponse;
import com.ejemplo.gestor.model.Tarea;
import java.util.ArrayList;
import java.util.List;
public class TareaMapper {
private TareaMapper() {
}
/** De lo que envía el cliente a un modelo nuevo. El id lo pone quien llame. */
public static Tarea aModelo(TareaRequest peticion) {
Tarea tarea = new Tarea();
tarea.setTitulo(peticion.getTitulo());
tarea.setPrioridad(peticion.getPrioridad());
tarea.setProyectoId(peticion.getProyectoId());
tarea.setCompletada(false);
return tarea;
}
/** Del modelo a lo que publica la API. */
public static TareaResponse aRespuesta(Tarea tarea) {
return new TareaResponse(
tarea.getId(),
tarea.getTitulo(),
tarea.getPrioridad(),
tarea.isCompletada(),
tarea.getProyectoId());
}
public static List<TareaResponse> aRespuestas(List<Tarea> tareas) {
List<TareaResponse> respuesta = new ArrayList<>();
for (Tarea tarea : tareas) {
respuesta.add(aRespuesta(tarea));
}
return respuesta;
}
/** Aplica sobre una tarea existente solo los campos que traiga el cambio. */
public static void aplicar(TareaPatchRequest cambios, Tarea tarea) {
if (cambios.getTitulo() != null) {
tarea.setTitulo(cambios.getTitulo());
}
if (cambios.getPrioridad() != null) {
tarea.setPrioridad(cambios.getPrioridad());
}
if (cambios.getCompletada() != null) {
tarea.setCompletada(cambios.getCompletada());
}
}
}
- Por qué una clase aparte y no un método en el DTO
- Porque entonces el DTO tendría que conocer el modelo. Los DTO describen el contrato con el exterior y deben poder cambiar sin arrastrar nada. El mapper es el único que conoce a los dos, y esa es precisamente su función.
- Por qué el constructor privado
- Es una clase de utilidad: todos sus métodos son estáticos y no tiene sentido crear una instancia. El constructor privado lo deja claro y evita que alguien escriba
new TareaMapper(). - Por qué
aplicarno devuelve nada - Porque no crea una tarea: modifica una que ya existe. El nombre y la firma dicen lo mismo que hace, y así nadie espera un objeto nuevo.
- Por qué estático, de momento
- Porque todavía no hemos visto inyección de dependencias. En la UD4 esta clase pasará a ser un componente que Spring construye e inyecta, y entonces entenderás qué se gana. Hoy, estático y funcionando.
Paso 7 · El controlador, después
Sustituye los métodos de listado, detalle y alta existentes por los que se muestran; no crees otra clase Controller. El método buscar(id) es un auxiliar privado dentro del controlador: recorre su lista, devuelve la tarea si coincide el id y devuelve null al terminar si no encuentra ninguna. Reutilízalo en detalle, PUT y PATCH y conserva sus comprobaciones de ausencia. Ejecuta la colección después de cambiar cada método.
@GetMapping
public List<TareaResponse> lista() {
return TareaMapper.aRespuestas(tareas);
}
@GetMapping("/{id}")
public ResponseEntity<TareaResponse> detalle(@PathVariable(name = "id") int id) {
Tarea tarea = buscar(id);
if (tarea == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(TareaMapper.aRespuesta(tarea));
}
@PostMapping
public ResponseEntity<TareaResponse> crear(@RequestBody TareaRequest peticion) {
Tarea tarea = TareaMapper.aModelo(peticion);
tarea.setId(siguienteId);
siguienteId = siguienteId + 1;
tareas.add(tarea);
URI ubicacion = ServletUriComponentsBuilder
.fromCurrentRequest().path("/{id}")
.buildAndExpand(tarea.getId()).toUri();
return ResponseEntity.created(ubicacion).body(TareaMapper.aRespuesta(tarea));
}
Compáralo con el de ayer. Ahora el controlador se lee como el contrato de la API: qué ruta, qué recibe, qué código devuelve. La traducción ha desaparecido de la vista, y con ella la posibilidad de hacerla dos veces distintas.
Ese método buscar(id) privado que aparece ahí es tuyo: sácalo también, porque lo estabas repitiendo en cuatro sitios.
¿No hay librerías que hagan esto solas?
Sí. MapStruct genera el mapper a partir de una interfaz, en tiempo de compilación; ModelMapper lo hace en ejecución por reflexión. En una aplicación grande ahorran mucho código repetitivo.
Aquí no las usamos por dos razones. La primera es que un mapper generado esconde justo la decisión que estás aprendiendo a tomar: qué campo va a dónde y qué se queda fuera. La segunda es que cuando algo falla —y falla—, hay que saber leer el código que se generó.
Sabe que existen. Úsalas cuando escribir mappers a mano te resulte aburrido, que es la señal de que ya entiendes lo que hacen.
Paso 8 · La prueba de ida y vuelta
Un mapper roto no lanza excepciones: pierde datos en silencio. Si te olvidas de una línea, ese campo llega vacío y nadie protesta.
La forma de detectarlo es recorrer el circuito completo y comparar los extremos. Con las herramientas que tienes:
POST /tareas
{ "titulo": "Revisar el login", "prioridad": "alta", "proyectoId": 7 }
Usa la cabecera Location, o {{tareaId}} si ya lo tienes encadenado en la colección.
Añade a esa petición de la colección una comprobación por cada campo enviado:
pm.test("El título sobrevive al viaje", function () {
pm.expect(pm.response.json().titulo).to.eql("Revisar el login");
});
pm.test("El proyecto sobrevive al viaje", function () {
pm.expect(pm.response.json().proyectoId).to.eql(7);
});
Qué acabas de construir
Una prueba que recorre JSON → DTO de entrada → modelo → DTO de salida → JSON y comprueba que lo que entró es lo que sale. Si mañana alguien añade un campo al mapper y olvida una de las direcciones, esta prueba falla.
Es la primera prueba de tu proyecto que comprueba una transformación y no un código de estado. En la UD4 esto mismo se escribirá en Java y se ejecutará sin Postman, pero la idea no cambiará.
Paso 9 · El mapper de proyectos
- Crea
ProyectoMappercon los cuatro métodos equivalentes. - Vacía el controlador de proyectos de toda conversión.
- Extrae también su
buscar(id)privado si lo estabas repitiendo. - Añade a la colección la prueba de ida y vuelta para proyectos, con una comprobación por campo.
- Comprueba que la colección entera sigue en verde.
Paso 10 · Comprobar y registrar el resultado del proyecto
- Intenta enviar un campo que solo pueda asignar el servidor y comprueba que no modifica ese dato interno.
- Prueba creación, lectura y modificación parcial: las conversiones deben conservar los datos esperados y el PATCH no debe borrar los campos omitidos.
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 · La API de usuarios
Diseña, sin escribir código todavía, las tres clases de un recurso Usuario con estos campos internos:
id, nombre, email, contraseñaCifrada, rol, fechaDeAlta, ultimoAcceso, activo
Requisitos del negocio:
- Al registrarse, el usuario envía nombre, email y contraseña en claro.
- El rol lo asigna siempre un administrador, nunca el propio usuario.
- Nadie, jamás, puede leer la contraseña por la API.
- El perfil público muestra nombre y fecha de alta.
- El propio usuario, al consultarse a sí mismo, ve además su email y su último acceso.
Entrega una tabla con una fila por campo y una columna por clase, marcando dónde aparece cada uno:
| Campo | UsuarioRequest |
Usuario |
UsuarioResponse |
UsuarioPublico |
|---|
Responde a dos preguntas:
- Hay un campo que aparece en la entrada y no existe en el modelo con ese nombre. ¿Cuál, y qué pasa con él entre una clase y otra?
- ¿Qué habría ocurrido con el campo
rolsi hubieras usado el modelo como cuerpo de la petición?
TareaRequest y TareaPatchRequest en uso, con el id del cliente ya ignorado.PATCH capaz de marcar y desmarcar.Usuario repartidas campo a campo, con las dos preguntas respondidas.Ver respuestas
1 · Porque Jackson solo asigna las claves para las que encuentra un setter. Si el campo no existe en la clase de entrada, se ignora en silencio, así que la propia clase decide qué puede enviar el cliente.
2 · Porque el primitivo no puede valer null y siempre llega como false, de modo que no se distingue «no me lo han enviado» de «pónmelo a falso». El envoltorio sí admite null.
3 · Entra y no sale: la contraseña. Sale y no entra: el id, o la fecha de creación.
4 · Vaciar un campo a propósito. Se resolvería declarándolo como Optional, que distingue el campo ausente del campo enviado con valor nulo.
Reto · Encuentra el campo perdido
Haz este ejercicio en pareja, y hazlo en las dos direcciones.
- En el proyecto de tu compañero, borra una sola línea de su mapper: una asignación de campo, la que quieras. Compila y arranca.
- Que ejecute su colección tal como está.
- Si sale verde, su colección tiene un agujero: apunta cuál es el campo y qué prueba le faltaba.
- Que la amplíe hasta detectarlo, y que después restaure la línea y compruebe que vuelve a verde.
Responde por escrito:
- ¿Cuántos campos de tu API están hoy comprobados de verdad, y cuántos solo aparecen en respuestas que nadie verifica?
- ¿Qué te costaría más: mantener la prueba de ida y vuelta al añadir cada campo, o descubrir el fallo en producción?
- ¿Habría alguna forma de que el fallo se detectara sin escribir una comprobación por campo?
TareaMapper creado y el controlador sin una sola línea de conversión.Ver respuestas
1 · Que un cambio se aplique solo en uno de los dos, y el mismo recurso acabe publicándose de dos formas distintas según el endpoint. Las dos respuestas son válidas, así que nada lo detecta.
2 · Porque obligaría al DTO a conocer el modelo. El DTO describe el contrato exterior y debe poder cambiar por su cuenta; el mapper es el único que conoce ambos lados.
3 · Porque modifica una tarea que ya existe en lugar de crear una nueva. La firma dice lo que hace y evita que alguien espere un objeto distinto de vuelta.
4 · Que los datos sobreviven a la transformación. Un 200 solo dice que la petición se atendió; no dice que los campos hayan llegado ni que valgan lo que valían.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
Identificadores y valores calculados conservan el control del servidor; los mapeos de ambas entidades son coherentes.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.