Llevamos tres lecciones señalando la misma deuda. En 03-02, un ejercicio demostró que exponer el record Estacion directamente obliga a contaminarlo con anotaciones de Jackson y a mantener una frágil lista de exclusiones. En 03-03 tuvimos que inventar NuevaEstacion porque el cliente no puede enviar el id. En 03-04 la renombramos a CrearEstacionRequest y le colgamos las restricciones de validación. El proyecto ya tiene dos representaciones de una estación conviviendo sin que nadie haya ordenado esa convivencia. Esta lección la ordena: separa el dominio —lo que CicloUrbana sabe sobre la red de Ribalta— del contrato —lo que la API promete a sus clientes—, diseña la jerarquía completa de DTOs del proyecto y compara las estrategias para trasladar datos de una capa a otra sin escribir código repetitivo ni introducir errores silenciosos.

Contenido

  1. Por qué no se exponen las entidades del dominio
  2. Cuatro fallos concretos, con ejemplo
  3. Tipos de DTO: petición y respuesta
  4. Por qué record inmutables
  5. La jerarquía de DTOs de CicloUrbana
  6. Mapeo manual
  7. MapStruct
  8. ModelMapper y por qué el curso lo descarta
  9. Dónde vive el mapeo
  10. Proyecciones y respuestas parciales
  11. DTOs anidados y agregados
  12. Evolucionar el contrato sin romper clientes
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. Por qué no se exponen las entidades del dominio

Un DTO (Data Transfer Object) es un objeto cuyo único propósito es transportar datos a través de una frontera. No tiene comportamiento, no tiene reglas y no vive en el dominio: pertenece al contrato.

La tentación de saltárselos es fuerte. Devolver Estacion directamente ahorra una clase, un mapeo y una línea en el controlador. Multiplicado por veinte endpoints, parece mucho ahorro. Lo que se ahorra en el primer mes se paga con intereses a partir del tercero, por cuatro motivos:

Problema Qué ocurre Cuándo aparece
Acoplamiento Renombrar un campo Java cambia el JSON y rompe a los clientes En el primer refactor
Fuga de datos Un campo nuevo se publica sin que nadie lo decida Al añadir cualquier campo
Referencias circulares La serialización entra en bucle infinito Al modelar relaciones (módulo 4)
Evolución bloqueada Dominio y contrato no pueden cambiar por separado Cuando el negocio evoluciona

El argumento de fondo es de diseño: el modelo de dominio y el contrato público cambian por razones distintas y a ritmos distintos. El dominio cambia cuando cambia el negocio de Ribalta; el contrato cambia cuando lo negocias con los equipos cliente. Atarlos hace que cada cambio de uno arrastre al otro.

  1. Cuatro fallos concretos, con ejemplo

Fallo 1: la fuga de datos. Es el más grave y el más silencioso. Supón que en el módulo 5 añadimos autenticación y el record Usuario(Long id, String nombre, String correo, String hashContrasena, String dni, LocalDate fechaAlta) crece con esos dos últimos campos sensibles. Si UsuarioController devuelve Usuario, la respuesta de GET /api/v1/usuarios/1 incluye el hash de la contraseña y el DNI de un ciudadano de Ribalta. Nadie lo decidió: pasó porque el mecanismo por defecto es publicar. Se puede tapar con @JsonIgnore, pero eso es una lista de exclusiones, y las listas de exclusiones fallan por omisión: el día que se añada un campo y nadie recuerde anotarlo, se publica. Un DTO es una lista de inclusiones: solo sale lo que enumeras, y olvidarse produce como mucho un campo que falta, detectable al instante.

Fallo 2: el acoplamiento invisible. El equipo decide que capacidad se llame plazasTotales porque es más claro en el dominio. Es un refactor de dos segundos en el IDE. Y rompe la app móvil de todos los ciudadanos de Ribalta que ya la tienen instalada, porque el JSON pasa de "capacidad" a "plazasTotales" sin que nadie lo advierta.

Fallo 3: las referencias circulares. En el módulo 4, Estacion tendrá una lista de Bicicleta y cada Bicicleta una referencia a su Estacion. Al serializar, el recorrido Estacion 1 → bicicletas → Bicicleta 42 → estacion → Estacion 1 → ... produce un StackOverflowError o una respuesta de varios megabytes. Se puede parchear con @JsonManagedReference y @JsonBackReference, pero eso es meter decisiones de serialización dentro del modelo de datos. Con DTOs el problema no existe: EstacionDetalleResponse contiene BicicletaResumen, y BicicletaResumen no contiene la estación.

Fallo 4: la evolución bloqueada. El ayuntamiento pide que la API exponga bicicletasDisponibles, un dato que no está en la entidad porque se calcula contando bicicletas. Sin DTO hay dos malas salidas: añadir un campo calculado a la entidad de dominio —contaminándola con necesidades de presentación— o devolver un Map sin tipo. Con DTO es trivial: EstacionResponse tiene ese campo y el mapeador lo rellena.

graph LR
    subgraph Contrato["Contrato público (API)"]
        RQ["CrearEstacionRequest"]
        RS["EstacionResponse"]
    end
    subgraph Dominio["Dominio (CicloUrbana)"]
        E["Estacion"] --- S["EstacionService"]
    end
    RQ -->|mapea| E
    E -->|mapea| RS

  1. Tipos de DTO: petición y respuesta

Los DTOs se dividen en dos familias con reglas distintas.

DTOs de petición (request): lo que el cliente envía. No llevan identificadores generados por el servidor —el id va en la ruta, no en el cuerpo—, no llevan campos derivados —el importe de un alquiler lo calcula el servidor—, llevan las restricciones de validación de 03-04 y son mínimos: cuantos menos campos acepte la API, menos superficie de ataque.

DTOs de respuesta (response): lo que el servidor devuelve. No llevan validación —nadie valida lo que uno mismo genera—, sí llevan campos calculados (bicicletasDisponibles, estaLlena), pueden agregar datos de varias fuentes y suelen existir en dos tamaños: resumen para los listados y detalle para la consulta individual.

Un error común es usar el mismo DTO para entrada y salida. Parece ahorrar código y produce dos problemas: la clase acaba con campos que solo tienen sentido en una dirección (un id que es nulo al crear y obligatorio al leer), y las restricciones de validación se aplican a objetos que no las necesitan. Merece la pena la clase extra.

  1. Por qué record inmutables

Todos los DTOs de CicloUrbana son record. Las razones, comparadas con la alternativa clásica de una clase con getters y setters:

Aspecto record Clase con setters
Líneas de código 1 por DTO 5 por campo
Mutabilidad Inmutable Mutable: cualquiera puede cambiarlo
equals/hashCode/toString Generados y correctos A mano o con Lombok
Seguridad entre hilos Garantizada Hay que razonarla
Bean Validation Sobre el componente Sobre el campo
Normalización Constructor compacto Dispersa por los setters

La inmutabilidad es lo que más aporta. Un DTO mutable puede modificarse entre el momento en que se valida y el momento en que se usa, y eso ha causado vulnerabilidades reales. Con un record, lo que se validó es exactamente lo que llega al servicio.

El constructor compacto es además el sitio natural para normalizar, y se ejecuta siempre, venga el objeto de donde venga:

public record CrearEstacionRequest(
        @NotBlank @Size(min = 3, max = 80) String nombre,
        @NotBlank @Size(max = 120) String direccion,
        @Positive @Max(60) int capacidad,
        @DecimalMin("-90.0")  @DecimalMax("90.0")  double latitud,
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitud) {

    public CrearEstacionRequest {
        // Normaliza antes de validar: "  Plaza Mayor  " -> "Plaza Mayor"
        nombre = nombre == null ? null : nombre.trim();
        direccion = direccion == null ? null : direccion.trim();
    }
}

Ese trim evita que el nombre "Plaza Mayor " se considere distinto de "Plaza Mayor" en la comprobación de duplicados. Es una limpieza que en una clase con setters habría que repetir en cada uno.

Los DTOs viven junto a su agregado, en un subpaquete dto: com.ciclourbana.estaciones contiene Estacion, EstacionService, EstacionController y EstacionMapper, y com.ciclourbana.estaciones.dto contiene CrearEstacionRequest, ActualizarEstacionRequest, EstacionResponse y EstacionDetalleResponse.

  1. La jerarquía de DTOs de CicloUrbana

DTO Dirección Endpoints Campos
CrearEstacionRequest Entrada POST /estaciones nombre, direccion, capacidad, latitud, longitud
ActualizarEstacionRequest Entrada PUT /estaciones/{id} nombre, direccion, capacidad
ParcheEstacionRequest Entrada PATCH /estaciones/{id} los anteriores, opcionales
EstacionResponse Salida GET /estaciones id, nombre, direccion, capacidad, bicicletasDisponibles, ubicacion
EstacionDetalleResponse Salida GET /estaciones/{id} lo anterior + anclajesLibres, estaLlena, bicicletas
BicicletaResumen Salida anidado en el detalle id, matricula, bateria, estado
CrearBicicletaRequest Entrada POST /bicicletas matricula, nivelBateria, estacionId
BicicletaResponse Salida GET /bicicletas, /bicicletas/{id} id, matricula, bateria, estado, estacionId, estacionNombre
IniciarAlquilerRequest Entrada POST /alquileres usuarioId, bicicletaId
FinalizarAlquilerRequest Entrada POST /alquileres/{id}/finalizar estacionDestinoId
AlquilerResponse Salida GET /alquileres/{id} y los dos POST id, matricula, origen, destino, inicio, fin, duracionMinutos, importe, estado

Dos observaciones sobre el diseño. ActualizarEstacionRequest no incluye coordenadas, y es deliberado: una estación física no se mueve de sitio, y corregir su georreferenciación sería una operación administrativa distinta. Los DTOs de petición son la forma más limpia de expresar qué se puede modificar y qué no: lo que no está en el DTO, no se puede tocar. Y AlquilerResponse no incluye bicicletaId sino matricula, porque el identificador interno no le sirve a la app móvil, que quiere mostrar "RB-0142" al usuario; un DTO puede sustituir identificadores por datos legibles y reducir así el número de llamadas del cliente.

Los DTOs de respuesta:

package com.ciclourbana.estaciones.dto;

/** Resumen de estación para los listados. */
public record EstacionResponse(
        Long id, String nombre, String direccion, int capacidad,
        int bicicletasDisponibles,     // calculado: no está en Estacion
        UbicacionResponse ubicacion    // agrupa latitud y longitud
) {}

public record UbicacionResponse(double latitud, double longitud) {}

/** Vista detallada: incluye las bicicletas ancladas. */
public record EstacionDetalleResponse(
        Long id, String nombre, String direccion, int capacidad,
        int bicicletasDisponibles,
        int anclajesLibres,            // calculado
        boolean estaLlena,             // calculado
        UbicacionResponse ubicacion,
        List<BicicletaResumen> bicicletas
) {}

/** Vista mínima de bicicleta, anidada en el detalle de estación. */
public record BicicletaResumen(Long id, String matricula,
                               int bateria, EstadoBicicleta estado) {}

BicicletaResumen no tiene referencia a la estación, y ahí está la clave de por qué los DTOs eliminan de raíz el problema de las referencias circulares: la jerarquía de DTOs es un árbol por construcción.

  1. Mapeo manual

La forma más simple de mapear es escribir el código. Con record, cabe en un método:

package com.ciclourbana.estaciones;

@Component
public class EstacionMapper {

    private final BicicletaMapper bicicletaMapper;    // inyectado por constructor

    /** Dominio -> DTO de listado. bicicletasDisponibles lo aporta el servicio. */
    public EstacionResponse aResponse(Estacion estacion, int bicicletasDisponibles) {
        return new EstacionResponse(
                estacion.id(), estacion.nombre(), estacion.direccion(),
                estacion.capacidad(), bicicletasDisponibles,
                new UbicacionResponse(estacion.latitud(), estacion.longitud()));
    }

    /** Dominio + agregados -> DTO de detalle, con los campos calculados. */
    public EstacionDetalleResponse aDetalle(Estacion estacion, List<Bicicleta> ancladas) {
        int disponibles = (int) ancladas.stream()
                .filter(b -> b.estado() == EstadoBicicleta.DISPONIBLE)
                .count();

        return new EstacionDetalleResponse(
                estacion.id(), estacion.nombre(), estacion.direccion(),
                estacion.capacidad(), disponibles,
                estacion.capacidad() - ancladas.size(),         // anclajesLibres
                ancladas.size() >= estacion.capacidad(),        // estaLlena
                new UbicacionResponse(estacion.latitud(), estacion.longitud()),
                ancladas.stream().map(bicicletaMapper::aResumen).toList());
    }

    /** DTO de petición -> dominio. El id es null: lo asigna el repositorio. */
    public Estacion aDominio(CrearEstacionRequest peticion) {
        return new Estacion(null, peticion.nombre(), peticion.direccion(),
                peticion.capacidad(), peticion.latitud(), peticion.longitud());
    }

    /** Aplica una actualización parcial preservando lo que no viene. */
    public Estacion aplicar(Estacion actual, ActualizarEstacionRequest peticion) {
        return new Estacion(actual.id(),
                peticion.nombre() != null ? peticion.nombre() : actual.nombre(),
                peticion.direccion() != null ? peticion.direccion() : actual.direccion(),
                peticion.capacidad() != null ? peticion.capacidad() : actual.capacidad(),
                actual.latitud(), actual.longitud());   // las coordenadas no se tocan
    }
}
Ventajas del mapeo manual Inconvenientes
Cero dependencias y cero magia Repetitivo con DTOs de muchos campos
Se depura con un punto de interrupción Fácil olvidar un campo nuevo, sin aviso
Permite lógica arbitraria (estaLlena) Crece linealmente con el número de DTOs

El inconveniente serio es el segundo: si EstacionResponse gana un campo zona, el compilador sí avisa porque el constructor del record cambia de aridad, pero si el campo nuevo es del mismo tipo que otro, un error de orden pasa desapercibido. Es exactamente lo que MapStruct elimina.

Una alternativa sin bean son los métodos de fábrica estáticos en el propio DTO: EstacionResponse.de(estacion, disponibles). Es más compacto, pero acopla el DTO al dominio e impide inyectar dependencias en el mapeo. CicloUrbana usa el mapeador como @Component.

  1. MapStruct

MapStruct es un procesador de anotaciones que genera el código de mapeo en tiempo de compilación. No usa reflexión, así que es tan rápido como el código manual, y como el resultado es Java compilado, cualquier incoherencia es un error de compilación.

La configuración en el pom.xml va en el maven-compiler-plugin, junto a Lombok si lo usaras:

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.6.3</version>
</dependency>

<!-- ... y en el maven-compiler-plugin: -->
<configuration>
    <annotationProcessorPaths>
        <path>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>1.6.3</version>
        </path>
    </annotationProcessorPaths>
    <compilerArgs>
        <!-- Falla la compilación si un campo del destino queda sin mapear -->
        <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
        <arg>-Amapstruct.defaultComponentModel=spring</arg>
    </compilerArgs>
</configuration>

unmappedTargetPolicy=ERROR es la opción que hace a MapStruct valioso: si añades un campo al DTO de respuesta y no le dices de dónde sale, el proyecto no compila. Es el olvido silencioso del mapeo manual convertido en error de compilación.

El mapeador es una interfaz:

package com.ciclourbana.estaciones;

@Mapper(componentModel = "spring",              // genera un @Component inyectable
        uses = BicicletaMapper.class,           // delega el mapeo de bicicletas
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface EstacionMapper {

    /** Los campos con el mismo nombre se mapean solos; el resto se declara. */
    @Mapping(target = "ubicacion.latitud",  source = "estacion.latitud")
    @Mapping(target = "ubicacion.longitud", source = "estacion.longitud")
    EstacionResponse aResponse(Estacion estacion, int bicicletasDisponibles);

    @Mapping(target = "id", ignore = true)      // lo asigna el repositorio
    Estacion aDominio(CrearEstacionRequest peticion);

    List<EstacionResponse> aResponses(List<Estacion> estaciones);   // colecciones, gratis

    /** Los cálculos que MapStruct no puede inferir se escriben como default. */
    default EstacionDetalleResponse aDetalle(Estacion estacion, List<Bicicleta> ancladas) {
        int disponibles = (int) ancladas.stream()
                .filter(b -> b.estado() == EstadoBicicleta.DISPONIBLE).count();
        return new EstacionDetalleResponse(estacion.id(), estacion.nombre(),
                estacion.direccion(), estacion.capacidad(), disponibles,
                estacion.capacidad() - ancladas.size(),
                ancladas.size() >= estacion.capacidad(),
                new UbicacionResponse(estacion.latitud(), estacion.longitud()),
                aResumenes(ancladas));
    }

    List<BicicletaResumen> aResumenes(List<Bicicleta> bicicletas);
}

Al compilar con ./mvnw compile, MapStruct escribe en target/generated-sources/annotations una clase EstacionMapperImpl:

@Component
public class EstacionMapperImpl implements EstacionMapper {

    @Override
    public EstacionResponse aResponse(Estacion estacion, int bicicletasDisponibles) {
        if (estacion == null) {
            return null;
        }
        UbicacionResponse ubicacion = new UbicacionResponse(
                estacion.latitud(), estacion.longitud());
        return new EstacionResponse(estacion.id(), estacion.nombre(),
                estacion.direccion(), estacion.capacidad(),
                bicicletasDisponibles, ubicacion);
    }

    @Override
    public List<EstacionResponse> aResponses(List<Estacion> estaciones) {
        if (estaciones == null) {
            return null;
        }
        List<EstacionResponse> lista = new ArrayList<>(estaciones.size());
        for (Estacion estacion : estaciones) {
            lista.add(aResponse(estacion, 0));
        }
        return lista;
    }
}

Leer el código generado es el mejor hábito que puedes adquirir con MapStruct. Deja de ser magia: es exactamente el código que habrías escrito a mano, incluidas las comprobaciones de nulo. Y cuando algo no mapea como esperabas, la respuesta está ahí, en un fichero Java legible.

Para las actualizaciones parciales, MapStruct ofrece @MappingTarget, que modifica un objeto existente en lugar de crear uno nuevo. Con record inmutables no aplica directamente, así que en CicloUrbana el PATCH sigue con el método default que ya escribimos. Con clases mutables sería:

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void actualizar(ActualizarEstacionRequest peticion, @MappingTarget EstacionMutable destino);

NullValuePropertyMappingStrategy.IGNORE significa "si el origen es nulo, no toques el destino": exactamente la semántica de PATCH que tanto trabajo nos dio en 03-03.

  1. ModelMapper y por qué el curso lo descarta

ModelMapper hace el mapeo en tiempo de ejecución mediante reflexión, deduciendo las correspondencias por nombre. Su atractivo es que no requiere escribir nada:

ModelMapper mapper = new ModelMapper();
EstacionResponse respuesta = mapper.map(estacion, EstacionResponse.class);

Los riesgos, que son la razón de descartarlo:

Riesgo Consecuencia
Correspondencias por reflexión Fallan en ejecución, no en compilación
Coincidencia de nombres "inteligente" Puede emparejar campos que no querías
Rendimiento Órdenes de magnitud más lento que el código generado
Depuración El mapeo ocurre dentro de la librería, no en tu código
Refactorización El IDE no ve las correspondencias: renombrar rompe en silencio

El segundo riesgo es el peor: con el modo de coincidencia laxo, ModelMapper puede emparejar estacion.nombre con respuesta.nombreOperario porque comparten un prefijo, y publicar un dato en el campo equivocado sin ningún aviso.

Criterio Manual MapStruct ModelMapper
Errores detectados en Compilación Compilación Ejecución
Rendimiento Máximo Máximo Bajo
Código a escribir Mucho Poco Ninguno
Depurable Sí Sí (código generado) Difícil
Campo olvidado Silencioso Error de compilación Silencioso
Curva de aprendizaje Ninguna Media Baja

La recomendación del curso: mapeo manual cuando hay pocos DTOs o la lógica de conversión es sustancial —es el caso de CicloUrbana en este módulo, y es el código que aparece en las lecciones—; MapStruct en cuanto el proyecto crece, por la garantía en tiempo de compilación; y ModelMapper, nunca en producción.

  1. Dónde vive el mapeo

Tres posiciones defendibles:

Opción Argumento a favor Argumento en contra
En el controlador El servicio no conoce el contrato HTTP y es reutilizable El controlador se llena de código de conversión
En el servicio El controlador queda mínimo El servicio queda atado al contrato de la API
En un mapeador dedicado Responsabilidad única, probable por separado Una clase más

La política de CicloUrbana: mapeador dedicado, invocado desde el controlador. El servicio habla el lenguaje del dominio —EstacionService.crear(...) recibe y devuelve Estacion, no DTOs—, de modo que cuando en el módulo 7 llegue un consumidor de mensajes podrá llamarlo sin construir objetos de la API. El controlador traduce, que es su papel desde 03-02. Y el mapeador concentra la conversión, se prueba con pruebas unitarias rápidas (módulo 6) y no obliga a levantar el contexto de Spring. El controlador queda así:

@RestController
@RequestMapping(path = "/api/v1/estaciones", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated
public class EstacionController {

    // Inyectados por constructor: EstacionService, BicicletaService, EstacionMapper

    @GetMapping
    public List<EstacionResponse> listar(
            @RequestParam(required = false) @Size(max = 80) String nombre,
            @RequestParam(defaultValue = "0")  @Min(0) int pagina,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int tamanio) {

        return estacionService.buscar(nombre, null, pagina, tamanio).stream()
                .map(e -> estacionMapper.aResponse(e,
                        bicicletaService.contarDisponibles(e.id())))
                .toList();
    }

    @GetMapping("/{id:\\d+}")
    public ResponseEntity<EstacionDetalleResponse> obtenerPorId(
            @PathVariable("id") @Positive Long id) {

        return estacionService.buscarPorId(id)
                .map(e -> estacionMapper.aDetalle(e,
                        bicicletaService.buscarPorEstacionSinComprobar(id)))
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<EstacionResponse> crear(
            @Valid @RequestBody CrearEstacionRequest peticion) {

        Estacion creada = estacionService.crear(estacionMapper.aDominio(peticion));
        URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(creada.id()).toUri();
        return ResponseEntity.created(ubicacion)
                .body(estacionMapper.aResponse(creada, 0));
    }
}

Nótese un detalle importante: EstacionService.crear ahora recibe una Estacion, no un DTO. La firma del servicio ha dejado de mencionar el contrato de la API, que es justamente el objetivo.

Y la respuesta que ve el cliente:

{
  "id": 1,
  "nombre": "Plaza Mayor",
  "direccion": "Plaza Mayor, 1",
  "capacidad": 24,
  "bicicletasDisponibles": 7,
  "ubicacion": { "latitud": 41.3851, "longitud": 2.1734 }
}

Compárala con la de 03-02, que era el volcado literal del record Estacion. Esta es una decisión de diseño: agrupa las coordenadas, expone un dato calculado y no revela ni un campo que no queramos publicar.

  1. Proyecciones y respuestas parciales

A veces el cliente solo quiere unos pocos campos. Un mapa de Ribalta con 4 estaciones no necesita las direcciones ni la capacidad: le basta con el nombre y las coordenadas. Descargar el objeto completo es tráfico desperdiciado, y en un listado de cientos de elementos importa.

Las tres estrategias:

Estrategia Cómo funciona Ventajas Inconvenientes
DTOs específicos EstacionMapaResponse con 3 campos Tipado, documentado en OpenAPI Una clase por vista
Selección de campos ?campos=id,nombre filtrado dinámicamente Flexible Sin tipos, sin documentar, difícil de cachear
Proyecciones de Spring Data Interfaces que la consulta rellena La base de datos solo lee lo pedido Módulo 4

CicloUrbana elige DTOs específicos por su claridad:

/** Vista mínima para el mapa interactivo de la ciudad. */
public record EstacionMapaResponse(Long id, String nombre,
                                   double latitud, double longitud,
                                   int bicicletasDisponibles) {}

El endpoint GET /api/v1/estaciones/mapa devuelve List<EstacionMapaResponse> y no arrastra ni direcciones ni capacidades. La regla para no acabar con quince DTOs por recurso: crea una vista nueva solo cuando un cliente real la pide y la diferencia de tamaño es significativa. Dos o tres vistas por recurso —resumen, detalle y quizá una especializada— cubren casi todos los casos.

La selección dinámica de campos (?campos=id,nombre) es tentadora y casi siempre un error en una API REST: rompe el tipado, imposibilita documentar la respuesta en OpenAPI, complica la caché —cada combinación es una respuesta distinta— y acaba siendo un GraphQL casero mal hecho. Si el proyecto necesita de verdad esa flexibilidad, la respuesta es GraphQL, como vimos en 03-01.

  1. DTOs anidados y agregados

EstacionDetalleResponse es un agregado: una respuesta que combina datos de varias fuentes en una sola llamada.

curl -s http://localhost:8080/api/v1/estaciones/1 | jq
{
  "id": 1,
  "nombre": "Plaza Mayor",
  "direccion": "Plaza Mayor, 1",
  "capacidad": 24,
  "bicicletasDisponibles": 7,
  "anclajesLibres": 15,
  "estaLlena": false,
  "ubicacion": { "latitud": 41.3851, "longitud": 2.1734 },
  "bicicletas": [
    { "id": 12, "matricula": "RB-0142", "bateria": 87, "estado": "DISPONIBLE" },
    { "id": 13, "matricula": "RB-0143", "bateria": 15, "estado": "MANTENIMIENTO" }
  ]
}

El beneficio es concreto: la app de Ribalta obtiene toda la pantalla de detalle con una petición en lugar de dos. En una red móvil lenta, cada ida y vuelta cuesta cientos de milisegundos.

El peligro también es concreto: agregar demasiado. Si EstacionDetalleResponse incluyera además los últimos cien alquileres y el histórico de incidencias, la respuesta pesaría megabytes y sería lenta para todos, incluidos los clientes que solo querían el nombre. Los tres criterios para decidir si un dato se anida son: ¿lo necesita el cliente casi siempre en esa pantalla? (las bicicletas ancladas, sí); ¿está acotado su tamaño? (como máximo hay capacidad bicicletas; los alquileres históricos crecen sin límite); y ¿cambia al mismo ritmo?, porque juntar un dato que se actualiza cada segundo con otro que cambia cada mes impide cachear el conjunto.

Los alquileres fallan los tres criterios, así que no se anidan: se consultan con GET /api/v1/estaciones/1/alquileres?pagina=0, paginados y bajo demanda.

  1. Evolucionar el contrato sin romper clientes

Los DTOs son lo que hace posible en la práctica la política de versionado de 03-01. Cuatro escenarios reales:

Añadir un campo es compatible: se añade a EstacionResponse, se rellena en el mapeador y los clientes antiguos lo ignoran gracias a fail-on-unknown-properties: false. Renombrar un campo del dominio deja de afectar al contrato: si Estacion.capacidad pasa a llamarse plazasTotales, se cambia una línea del mapeador y el JSON sigue diciendo "capacidad". Ese es, en una frase, el retorno de toda la inversión de esta lección.

Retirar un campo del contrato requiere transición: marcarlo obsoleto en la documentación (@Schema(deprecated = true), lección 03-07), medir cuántos clientes lo usan con Actuator (módulo 7) y retirarlo en /api/v2 cuando el uso llegue a cero.

Cambiar la forma de un campo —las coordenadas sueltas pasando a un objeto ubicacion— se resuelve con duplicidad temporal:

public record EstacionResponse(
        Long id, String nombre, String direccion, int capacidad,
        int bicicletasDisponibles,
        UbicacionResponse ubicacion,

        /** @deprecated desde 1.4.0, retirar en /api/v2. Usar ubicacion.latitud. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double latitud,

        /** @deprecated desde 1.4.0, retirar en /api/v2. Usar ubicacion.longitud. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double longitud
) {}

Los tres campos conviven, el cambio es compatible y el mapeador rellena ambas formas. Cuando las métricas digan que nadie lee latitud suelta, se retira. Sin DTOs esto sería imposible sin duplicar la entidad de dominio.

Errores Comunes y Consejos

Exponer la entidad "solo en este endpoint". La excepción se convierte en norma. En el módulo 4, ese endpoint será el que provoque la LazyInitializationException.

Usar el mismo DTO para petición y respuesta. Acaba con campos nulos en una dirección y validaciones inútiles en la otra. En la misma línea, poner validación en los DTOs de respuesta solo añade ruido: no se valida lo que uno mismo genera.

Mapear en el servicio. Ata la lógica de negocio al contrato HTTP. Cuando llegue un consumidor de cola tendrá que construir DTOs de API para llamar al servicio.

Aceptar el id en el cuerpo de un POST. El identificador lo asigna el servidor. Si el DTO lo incluye, un cliente puede intentar fijarlo.

Olvidar @Mapping en MapStruct sin unmappedTargetPolicy=ERROR. El campo se queda a nulo silenciosamente. Activa la política en el pom.xml desde el primer día.

Anidar colecciones sin cota. EstacionDetalleResponse con todos los alquileres históricos crece sin límite. Aplica los tres criterios del apartado 11.

Consejo: nombra los DTOs por su uso, no por su forma. CrearEstacionRequest y EstacionDetalleResponse dicen dónde se usan. EstacionDTO y EstacionDTO2 no dicen nada.

Consejo: prueba el mapeador aparte. Un EstacionMapperTest sin contexto de Spring corre en milisegundos y detecta al instante un campo mal colocado. Es la prueba con mejor relación coste-beneficio del proyecto (módulo 6).

Ejercicios

Ejercicio 1: Diseñar y mapear AlquilerResponse

AlquilerController devuelve todavía el record Alquiler del dominio, que expone usuarioId, bicicletaId y los identificadores de estación en crudo. Diseña AlquilerResponse pensando en lo que la app móvil necesita mostrar en el historial del ciudadano, e implementa AlquilerMapper. Justifica qué campos del dominio no salen y qué campos calculados añades.

Ejercicio 2: Vista de operario con datos que no se publican

El panel interno de los operarios de Ribalta necesita, para cada bicicleta, la matrícula, la batería, el estado, el codigoAnclajeInterno y el número de incidencias abiertas. Ninguno de los tres últimos debe aparecer en la API pública. Diseña la solución y explica cómo evitas que un cambio futuro filtre datos internos al endpoint público.

Ejercicio 3: Migrar el mapeador de EstacionMapper a MapStruct

Convierte el EstacionMapper manual del apartado 6 en una interfaz MapStruct. Resuelve los tres casos difíciles: el campo bicicletasDisponibles que no está en el dominio, la agrupación de latitud y longitud en ubicacion, y los campos calculados anclajesLibres y estaLlena.

Soluciones

Solución 1.

package com.ciclourbana.alquileres.dto;

public record AlquilerResponse(
        Long id,
        String matriculaBicicleta,      // no bicicletaId: la app muestra "RB-0142"
        String estacionOrigen,          // nombre, no id
        String estacionDestino,         // nombre, no id; null si sigue en curso
        LocalDateTime inicio,
        LocalDateTime fin,              // null mientras esté en curso
        long duracionMinutos,           // calculado
        BigDecimal importe,             // null mientras esté en curso
        EstadoAlquiler estado) {}

Qué no sale y por qué:

Campo del dominio Decisión Motivo
usuarioId No sale El usuario ya sabe quién es; publicarlo permitiría enumerar usuarios
bicicletaId Se sustituye por matriculaBicicleta El identificador interno no le sirve a la app
estacionOrigenId/estacionDestinoId Se sustituyen por el nombre Evita una segunda llamada solo para resolver el nombre

Campos calculados: duracionMinutos ahorra a cada cliente reimplementar el cálculo, y hacerlo en el servidor garantiza que todos obtengan el mismo número.

@Component
public class AlquilerMapper {

    // Inyectados: BicicletaRepositorio, EstacionRepositorio y el Clock de ConfiguracionComun

    public AlquilerResponse aResponse(Alquiler alquiler) {

        // Si sigue en curso, la duración se cuenta hasta AHORA
        LocalDateTime hasta = alquiler.fin() != null
                ? alquiler.fin() : LocalDateTime.now(clock);

        return new AlquilerResponse(
                alquiler.id(),
                bicicletaRepositorio.buscarPorId(alquiler.bicicletaId())
                        .map(Bicicleta::matricula).orElse("desconocida"),
                nombreEstacion(alquiler.estacionOrigenId()),
                nombreEstacion(alquiler.estacionDestinoId()),
                alquiler.inicio(), alquiler.fin(),
                Duration.between(alquiler.inicio(), hasta).toMinutes(),
                alquiler.importe(), alquiler.estado());
    }

    private String nombreEstacion(Long id) {
        return id == null ? null
                : estacionRepositorio.buscarPorId(id).map(Estacion::nombre).orElse(null);
    }
}

Advertencia de diseño: este mapeador consulta repositorios, y eso lo convierte en algo más que un mapeador. En un listado de 100 alquileres provocaría 300 consultas —el problema N+1 del módulo 4—. Las salidas son que el servicio devuelva un agregado con los nombres ya resueltos, o que el repositorio los traiga en una sola consulta. Si tu mapeador necesita el repositorio, es señal de que el servicio no está devolviendo lo suficiente.

Solución 2.

// Público: solo lo que puede ver cualquier ciudadano
public record BicicletaResponse(Long id, String matricula,
                                int bateria, EstadoBicicleta estado,
                                Long estacionId, String estacionNombre) {}

// Interno: incluye datos operativos. Vive en un paquete distinto.
public record BicicletaOperarioResponse(Long id, String matricula,
                                        int bateria, EstadoBicicleta estado,
                                        Long estacionId, String estacionNombre,
                                        String codigoAnclajeInterno,
                                        int incidenciasAbiertas,
                                        LocalDateTime ultimaRevision) {}

Con rutas separadas y mapeadores separados:

GET /api/v1/bicicletas             -> BicicletaResponse       (público)
GET /api/v1/interno/bicicletas     -> BicicletaOperarioResponse (rol OPERARIO, módulo 5)

Cómo se evita la fuga futura, que es el fondo del ejercicio:

  1. Son clases distintas, no una con campos condicionales. Un if (esOperario) dentro del mapeador acaba fallando el día que alguien invierta la condición o la olvide.
  2. La @Schema de OpenAPI (03-07) documenta cada una por separado, y una revisión de la documentación pública deja ver de inmediato si aparece un campo que no debería.
  3. La prueba de contrato es la red de seguridad definitiva. En el módulo 6 escribiremos una prueba que afirma exactamente qué claves devuelve GET /api/v1/bicicletas; si alguien añade un campo interno a la respuesta pública, la prueba falla antes del despliegue.
  4. Nunca reutilizar el DTO interno "porque tiene todo". Es la tentación que provoca todas las fugas.

Un antipatrón habitual que conviene descartar explícitamente: usar @JsonView para servir dos vistas desde una sola clase. Funciona, pero deja el campo peligroso dentro del objeto que se serializa, dependiendo de una anotación para no salir. Es de nuevo una lista de exclusiones. Dos clases es más código y mucho más seguro.

Solución 3.

@Mapper(componentModel = "spring",
        uses = BicicletaMapper.class,
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface EstacionMapper {

    // Caso 1: un dato que no está en el origen llega como parámetro extra.
    // MapStruct lo empareja por nombre con el componente del record destino.
    @Mapping(target = "ubicacion", source = "estacion")
    EstacionResponse aResponse(Estacion estacion, int bicicletasDisponibles);

    // Caso 2: agrupar dos campos planos en un objeto anidado.
    // Un método auxiliar que MapStruct usa automáticamente por su firma.
    default UbicacionResponse aUbicacion(Estacion estacion) {
        return estacion == null ? null
                : new UbicacionResponse(estacion.latitud(), estacion.longitud());
    }

    @Mapping(target = "id", ignore = true)
    Estacion aDominio(CrearEstacionRequest peticion);

    List<BicicletaResumen> aResumenes(List<Bicicleta> bicicletas);

    // Caso 3: cálculos que dependen de varias fuentes.
    // Se escriben como default: MapStruct no puede inferirlos, y forzarlo
    // con @Mapping y expresiones java() produce código ilegible.
    default EstacionDetalleResponse aDetalle(Estacion estacion, List<Bicicleta> ancladas) {
        int disponibles = (int) ancladas.stream()
                .filter(b -> b.estado() == EstadoBicicleta.DISPONIBLE).count();
        return new EstacionDetalleResponse(estacion.id(), estacion.nombre(),
                estacion.direccion(), estacion.capacidad(), disponibles,
                estacion.capacidad() - ancladas.size(),
                ancladas.size() >= estacion.capacidad(),
                aUbicacion(estacion), aResumenes(ancladas));
    }
}

Las tres lecciones del ejercicio:

  • Caso 1. Cuando un valor no está en el objeto de origen, se pasa como parámetro adicional del método. MapStruct lo empareja con el componente del destino por el nombre, así que int bicicletasDisponibles rellena el campo bicicletasDisponibles. Si los nombres no coincidieran, haría falta @Mapping(target = "...", source = "...").
  • Caso 2. Un método default cuya firma va del tipo origen al tipo destino se convierte en un conversor que MapStruct usa automáticamente allí donde necesite esa transformación. Es el mecanismo más útil y menos conocido de la librería.
  • Caso 3. Cuando un mapeo requiere lógica real, escríbelo como método default. MapStruct permite expresiones inline con @Mapping(target = "estaLlena", expression = "java(...)"), pero eso mete código Java dentro de una cadena de texto: sin comprobación de tipos, sin autocompletado y sin refactorización. Un default es Java normal y corriente.

Y la comprobación final, que es la razón de haber migrado: si mañana alguien añade el campo zona a EstacionResponse y no dice de dónde sale, ./mvnw compile falla con Unmapped target property: "zona". En el mapeador manual, ese campo se habría quedado a nulo hasta que un usuario de Ribalta lo notara.

Conclusión

El dominio de CicloUrbana y su contrato público son por fin dos cosas separadas. Sabes por qué exponer las entidades es una mala idea y puedes justificarlo con cuatro fallos concretos: la fuga silenciosa de un hash de contraseña o un DNI, el acoplamiento que convierte un renombrado inocente en una rotura de la app móvil, las referencias circulares que provocan un StackOverflowError en cuanto haya relaciones, y la imposibilidad de exponer un dato calculado como bicicletasDisponibles sin contaminar el modelo. Entiendes la diferencia entre un DTO de petición —mínimo, validado, sin identificadores generados— y uno de respuesta —con campos calculados, sin validación, en variantes de resumen y detalle—, y por qué son record inmutables cuyo constructor compacto es el sitio natural para normalizar. Tienes la tabla completa de los once DTOs del proyecto, con dos decisiones de diseño que merecen recordarse: ActualizarEstacionRequest omite las coordenadas porque una estación física no se mueve, y AlquilerResponse sustituye el bicicletaId interno por la matrícula que ve el ciudadano.

Sabes mapear de tres formas y elegir entre ellas: manual cuando hay pocos DTOs, MapStruct en cuanto crece —con unmappedTargetPolicy=ERROR, que convierte el olvido silencioso en un error de compilación— y nunca ModelMapper. Has leído el código que MapStruct genera y sabes que no es magia. Tienes la política del proyecto sobre dónde vive el mapeo —mapeador dedicado invocado desde el controlador, con el servicio hablando solo el lenguaje del dominio— y los criterios para decidir cuándo anidar un agregado y cuándo paginarlo aparte. Y sabes cómo evolucionar el contrato sin romper clientes, con la duplicidad temporal de campos que hace compatible casi cualquier cambio.

Queda un último hueco, y es el más visible desde fuera. Cuando algo va mal, CicloUrbana responde con {"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content."}, que no dice qué campo falló. Una ConstraintViolationException de 03-04 sale como 500. Los 404 los construimos a mano en cada controlador con ResponseEntity.notFound(). Y las reglas de negocio de 03-03 lanzan IllegalStateException que se convierten en errores del servidor con la traza de pila dentro, cuando deberían ser 409 limpios. Toda esa deuda lleva tres lecciones acumulándose con etiquetas TODO.

La lección 03-06, Manejo de Excepciones en REST, la salda por completo. Veremos qué hace Spring Boot por defecto y por qué no basta para una API pública; construiremos la jerarquía de excepciones de CicloUrbana con su mapeo a códigos HTTP; centralizaremos todo en un @RestControllerAdvice; adoptaremos el formato estándar RFC 7807 Problem Details con el soporte nativo de Spring Boot 3; convertiremos los errores de validación de 03-04 en una respuesta con la lista exacta de campos erróneos; manejaremos las excepciones del propio framework; y añadiremos un identificador de traza que permita correlacionar cualquier error con los logs del servidor.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados