Fa tres lliçons que assenyalem el mateix deute. A 03-02, un exercici va demostrar que exposar el record Estacio directament obliga a contaminar-lo amb anotacions de Jackson i a mantenir una fràgil llista d'exclusions. A 03-03 vam haver d'inventar NovaEstacio perquè el client no pot enviar l'id. A 03-04 la vam reanomenar a CrearEstacioRequest i li vam penjar les restriccions de validació. El projecte ja té dues representacions d'una estació convivint sense que ningú no hagi ordenat aquesta convivència. Aquesta lliçó l'ordena: separa el domini —el que CicloUrbana sap sobre la xarxa de Ribalta— del contracte —el que l'API promet als seus clients—, dissenya la jerarquia completa de DTO del projecte i compara les estratègies per traslladar dades d'una capa a una altra sense escriure codi repetitiu ni introduir errors silenciosos.

Contingut

  1. Per què no s'exposen les entitats del domini
  2. Quatre fallades concretes, amb exemple
  3. Tipus de DTO: petició i resposta
  4. Per què record immutables
  5. La jerarquia de DTO de CicloUrbana
  6. Mapatge manual
  7. MapStruct
  8. ModelMapper i per què el curs el descarta
  9. On viu el mapatge
  10. Projeccions i respostes parcials
  11. DTO imbricats i agregats
  12. Evolucionar el contracte sense trencar clients
  13. Errors Comuns i Consells
  14. Exercicis

  1. Per què no s'exposen les entitats del domini

Un DTO (Data Transfer Object) és un objecte l'únic propòsit del qual és transportar dades a través d'una frontera. No té comportament, no té regles i no viu al domini: pertany al contracte.

La temptació de saltar-se'ls és forta. Retornar Estacio directament estalvia una classe, un mapatge i una línia al controlador. Multiplicat per vint endpoints, sembla molt estalvi. El que s'estalvia el primer mes es paga amb interessos a partir del tercer, per quatre motius:

Problema Què passa Quan apareix
Acoblament Reanomenar un camp Java canvia el JSON i trenca els clients Al primer refactor
Fuita de dades Un camp nou es publica sense que ningú no ho decideixi En afegir qualsevol camp
Referències circulars La serialització entra en bucle infinit En modelar relacions (mòdul 4)
Evolució bloquejada Domini i contracte no poden canviar per separat Quan el negoci evoluciona

L'argument de fons és de disseny: el model de domini i el contracte públic canvien per raons diferents i a ritmes diferents. El domini canvia quan canvia el negoci de Ribalta; el contracte canvia quan el negocies amb els equips client. Lligar-los fa que cada canvi d'un arrossegui l'altre.

  1. Quatre fallades concretes, amb exemple

Fallada 1: la fuita de dades. És la més greu i la més silenciosa. Suposa que al mòdul 5 hi afegim autenticació i el record Usuari(Long id, String nom, String correu, String hashContrasenya, String dni, LocalDate dataAlta) creix amb aquests dos últims camps sensibles. Si UsuariController retorna Usuari, la resposta de GET /api/v1/usuaris/1 inclou el hash de la contrasenya i el DNI d'un ciutadà de Ribalta. Ningú no ho ha decidit: ha passat perquè el mecanisme per defecte és publicar. Es pot tapar amb @JsonIgnore, però això és una llista d'exclusions, i les llistes d'exclusions fallen per omissió: el dia que s'afegeixi un camp i ningú no recordi anotar-lo, es publica. Un DTO és una llista d'inclusions: només surt el que enumeres, i oblidar-se'n produeix com a molt un camp que falta, detectable a l'instant.

Fallada 2: l'acoblament invisible. L'equip decideix que capacitat es digui placesTotals perquè és més clar al domini. És un refactor de dos segons a l'IDE. I trenca l'app mòbil de tots els ciutadans de Ribalta que ja la tenen instal·lada, perquè el JSON passa de "capacitat" a "placesTotals" sense que ningú no ho adverteixi.

Fallada 3: les referències circulars. Al mòdul 4, Estacio tindrà una llista de Bicicleta i cada Bicicleta una referència a la seva Estacio. En serialitzar, el recorregut Estacio 1 → bicicletes → Bicicleta 42 → estacio → Estacio 1 → ... produeix un StackOverflowError o una resposta de diversos megabytes. Es pot pedaçar amb @JsonManagedReference i @JsonBackReference, però això és ficar decisions de serialització dins del model de dades. Amb DTO el problema no existeix: EstacioDetallResponse conté BicicletaResum, i BicicletaResum no conté l'estació.

Fallada 4: l'evolució bloquejada. L'ajuntament demana que l'API exposi bicicletesDisponibles, una dada que no és a l'entitat perquè es calcula comptant bicicletes. Sense DTO hi ha dues males sortides: afegir un camp calculat a l'entitat de domini —contaminant-la amb necessitats de presentació— o retornar un Map sense tipus. Amb DTO és trivial: EstacioResponse té aquest camp i el mapejador l'omple.

graph LR
    subgraph Contracte["Contracte públic (API)"]
        RQ["CrearEstacioRequest"]
        RS["EstacioResponse"]
    end
    subgraph Domini["Domini (CicloUrbana)"]
        E["Estacio"] --- S["EstacioService"]
    end
    RQ -->|mapeja| E
    E -->|mapeja| RS

  1. Tipus de DTO: petició i resposta

Els DTO es divideixen en dues famílies amb regles diferents.

DTO de petició (request): el que el client envia. No duen identificadors generats pel servidor —l'id va a la ruta, no al cos—, no duen camps derivats —l'import d'un lloguer el calcula el servidor—, duen les restriccions de validació de 03-04 i són mínims: com menys camps accepti l'API, menys superfície d'atac.

DTO de resposta (response): el que el servidor retorna. No duen validació —ningú no valida el que un mateix genera—, sí que duen camps calculats (bicicletesDisponibles, esPlena), poden agregar dades de diverses fonts i solen existir en dues mides: resum per als llistats i detall per a la consulta individual.

Un error comú és fer servir el mateix DTO per a entrada i sortida. Sembla que estalvia codi i produeix dos problemes: la classe acaba amb camps que només tenen sentit en una direcció (un id que és nul en crear i obligatori en llegir), i les restriccions de validació s'apliquen a objectes que no les necessiten. Val la pena la classe extra.

  1. Per què record immutables

Tots els DTO de CicloUrbana són record. Les raons, comparades amb l'alternativa clàssica d'una classe amb getters i setters:

Aspecte record Classe amb setters
Línies de codi 1 per DTO 5 per camp
Mutabilitat Immutable Mutable: qualsevol el pot canviar
equals/hashCode/toString Generats i correctes A mà o amb Lombok
Seguretat entre fils Garantida Cal raonar-la
Bean Validation Sobre el component Sobre el camp
Normalització Constructor compacte Dispersa pels setters

La immutabilitat és el que més aporta. Un DTO mutable es pot modificar entre el moment en què es valida i el moment en què es fa servir, i això ha causat vulnerabilitats reals. Amb un record, el que s'ha validat és exactament el que arriba al servei.

El constructor compacte és a més el lloc natural per normalitzar, i s'executa sempre, vingui l'objecte d'on vingui:

public record CrearEstacioRequest(
        @NotBlank @Size(min = 3, max = 80) String nom,
        @NotBlank @Size(max = 120) String adreca,
        @Positive @Max(60) int capacitat,
        @DecimalMin("-90.0")  @DecimalMax("90.0")  double latitud,
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitud) {

    public CrearEstacioRequest {
        // Normalitza abans de validar: "  Plaça Major  " -> "Plaça Major"
        nom = nom == null ? null : nom.trim();
        adreca = adreca == null ? null : adreca.trim();
    }
}

Aquest trim evita que el nom "Plaça Major " es consideri diferent de "Plaça Major" a la comprovació de duplicats. És una neteja que en una classe amb setters s'hauria de repetir a cadascun.

Els DTO viuen al costat del seu agregat, en un subpaquet dto: com.ciclourbana.estacions conté Estacio, EstacioService, EstacioController i EstacioMapper, i com.ciclourbana.estacions.dto conté CrearEstacioRequest, ActualitzarEstacioRequest, EstacioResponse i EstacioDetallResponse.

  1. La jerarquia de DTO de CicloUrbana

DTO Direcció Endpoints Camps
CrearEstacioRequest Entrada POST /estacions nom, adreca, capacitat, latitud, longitud
ActualitzarEstacioRequest Entrada PUT /estacions/{id} nom, adreca, capacitat
PedacEstacioRequest Entrada PATCH /estacions/{id} els anteriors, opcionals
EstacioResponse Sortida GET /estacions id, nom, adreca, capacitat, bicicletesDisponibles, ubicacio
EstacioDetallResponse Sortida GET /estacions/{id} l'anterior + ancoratgesLliures, esPlena, bicicletes
BicicletaResum Sortida imbricat al detall id, matricula, bateria, estat
CrearBicicletaRequest Entrada POST /bicicletes matricula, nivellBateria, estacioId
BicicletaResponse Sortida GET /bicicletes, /bicicletes/{id} id, matricula, bateria, estat, estacioId, estacioNom
IniciarLloguerRequest Entrada POST /lloguers usuariId, bicicletaId
FinalitzarLloguerRequest Entrada POST /lloguers/{id}/finalitzar estacioDestiId
LloguerResponse Sortida GET /lloguers/{id} i els dos POST id, matricula, origen, desti, inici, fi, duradaMinuts, importTotal, estat

Dues observacions sobre el disseny. ActualitzarEstacioRequest no inclou coordenades, i és deliberat: una estació física no es mou de lloc, i corregir-ne la georeferenciació seria una operació administrativa diferent. Els DTO de petició són la manera més neta d'expressar què es pot modificar i què no: el que no és al DTO, no es pot tocar. I LloguerResponse no inclou bicicletaId sinó matricula, perquè l'identificador intern no serveix a l'app mòbil, que vol mostrar "RB-0142" a l'usuari; un DTO pot substituir identificadors per dades llegibles i reduir així el nombre de crides del client.

Els DTO de resposta:

package com.ciclourbana.estacions.dto;

/** Resum d'estació per als llistats. */
public record EstacioResponse(
        Long id, String nom, String adreca, int capacitat,
        int bicicletesDisponibles,     // calculat: no és a Estacio
        UbicacioResponse ubicacio      // agrupa latitud i longitud
) {}

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

/** Vista detallada: inclou les bicicletes ancorades. */
public record EstacioDetallResponse(
        Long id, String nom, String adreca, int capacitat,
        int bicicletesDisponibles,
        int ancoratgesLliures,         // calculat
        boolean esPlena,               // calculat
        UbicacioResponse ubicacio,
        List<BicicletaResum> bicicletes
) {}

/** Vista mínima de bicicleta, imbricada al detall d'estació. */
public record BicicletaResum(Long id, String matricula,
                             int bateria, EstatBicicleta estat) {}

BicicletaResum no té referència a l'estació, i aquí rau la clau de per què els DTO eliminen d'arrel el problema de les referències circulars: la jerarquia de DTO és un arbre per construcció.

  1. Mapatge manual

La manera més simple de mapejar és escriure el codi. Amb record, cap en un mètode:

package com.ciclourbana.estacions;

@Component
public class EstacioMapper {

    private final BicicletaMapper bicicletaMapper;    // injectat per constructor

    /** Domini -> DTO de llistat. bicicletesDisponibles l'aporta el servei. */
    public EstacioResponse aResponse(Estacio estacio, int bicicletesDisponibles) {
        return new EstacioResponse(
                estacio.id(), estacio.nom(), estacio.adreca(),
                estacio.capacitat(), bicicletesDisponibles,
                new UbicacioResponse(estacio.latitud(), estacio.longitud()));
    }

    /** Domini + agregats -> DTO de detall, amb els camps calculats. */
    public EstacioDetallResponse aDetall(Estacio estacio, List<Bicicleta> ancorades) {
        int disponibles = (int) ancorades.stream()
                .filter(b -> b.estat() == EstatBicicleta.DISPONIBLE)
                .count();

        return new EstacioDetallResponse(
                estacio.id(), estacio.nom(), estacio.adreca(),
                estacio.capacitat(), disponibles,
                estacio.capacitat() - ancorades.size(),         // ancoratgesLliures
                ancorades.size() >= estacio.capacitat(),        // esPlena
                new UbicacioResponse(estacio.latitud(), estacio.longitud()),
                ancorades.stream().map(bicicletaMapper::aResum).toList());
    }

    /** DTO de petició -> domini. L'id és null: l'assigna el repositori. */
    public Estacio aDomini(CrearEstacioRequest peticio) {
        return new Estacio(null, peticio.nom(), peticio.adreca(),
                peticio.capacitat(), peticio.latitud(), peticio.longitud());
    }

    /** Aplica una actualització parcial preservant el que no ve. */
    public Estacio aplicar(Estacio actual, ActualitzarEstacioRequest peticio) {
        return new Estacio(actual.id(),
                peticio.nom() != null ? peticio.nom() : actual.nom(),
                peticio.adreca() != null ? peticio.adreca() : actual.adreca(),
                peticio.capacitat() != null ? peticio.capacitat() : actual.capacitat(),
                actual.latitud(), actual.longitud());   // les coordenades no es toquen
    }
}
Avantatges del mapatge manual Inconvenients
Zero dependències i zero màgia Repetitiu amb DTO de molts camps
Es depura amb un punt d'interrupció Fàcil oblidar un camp nou, sense avís
Permet lògica arbitrària (esPlena) Creix linealment amb el nombre de DTO

L'inconvenient seriós és el segon: si EstacioResponse guanya un camp zona, el compilador sí que avisa perquè el constructor del record canvia d'aritat, però si el camp nou és del mateix tipus que un altre, un error d'ordre passa desapercebut. És exactament el que MapStruct elimina.

Una alternativa sense bean són els mètodes de fàbrica estàtics al DTO mateix: EstacioResponse.de(estacio, disponibles). És més compacte, però acobla el DTO al domini i impedeix injectar dependències al mapatge. CicloUrbana fa servir el mapejador com a @Component.

  1. MapStruct

MapStruct és un processador d'anotacions que genera el codi de mapatge en temps de compilació. No fa servir reflexió, així que és tan ràpid com el codi manual, i com que el resultat és Java compilat, qualsevol incoherència és un error de compilació.

La configuració al pom.xml va al maven-compiler-plugin, al costat de Lombok si el fessis servir:

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

<!-- ... i al 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ó si un camp del destí queda sense mapejar -->
        <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
        <arg>-Amapstruct.defaultComponentModel=spring</arg>
    </compilerArgs>
</configuration>

unmappedTargetPolicy=ERROR és l'opció que fa MapStruct valuós: si afegeixes un camp al DTO de resposta i no li dius d'on surt, el projecte no compila. És l'oblit silenciós del mapatge manual convertit en error de compilació.

El mapejador és una interfície:

package com.ciclourbana.estacions;

@Mapper(componentModel = "spring",              // genera un @Component injectable
        uses = BicicletaMapper.class,           // delega el mapatge de bicicletes
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface EstacioMapper {

    /** Els camps amb el mateix nom es mapegen sols; la resta es declara. */
    @Mapping(target = "ubicacio.latitud",  source = "estacio.latitud")
    @Mapping(target = "ubicacio.longitud", source = "estacio.longitud")
    EstacioResponse aResponse(Estacio estacio, int bicicletesDisponibles);

    @Mapping(target = "id", ignore = true)      // l'assigna el repositori
    Estacio aDomini(CrearEstacioRequest peticio);

    List<EstacioResponse> aResponses(List<Estacio> estacions);   // col·leccions, de franc

    /** Els càlculs que MapStruct no pot inferir s'escriuen com a default. */
    default EstacioDetallResponse aDetall(Estacio estacio, List<Bicicleta> ancorades) {
        int disponibles = (int) ancorades.stream()
                .filter(b -> b.estat() == EstatBicicleta.DISPONIBLE).count();
        return new EstacioDetallResponse(estacio.id(), estacio.nom(),
                estacio.adreca(), estacio.capacitat(), disponibles,
                estacio.capacitat() - ancorades.size(),
                ancorades.size() >= estacio.capacitat(),
                new UbicacioResponse(estacio.latitud(), estacio.longitud()),
                aResums(ancorades));
    }

    List<BicicletaResum> aResums(List<Bicicleta> bicicletes);
}

En compilar amb ./mvnw compile, MapStruct escriu a target/generated-sources/annotations una classe EstacioMapperImpl:

@Component
public class EstacioMapperImpl implements EstacioMapper {

    @Override
    public EstacioResponse aResponse(Estacio estacio, int bicicletesDisponibles) {
        if (estacio == null) {
            return null;
        }
        UbicacioResponse ubicacio = new UbicacioResponse(
                estacio.latitud(), estacio.longitud());
        return new EstacioResponse(estacio.id(), estacio.nom(),
                estacio.adreca(), estacio.capacitat(),
                bicicletesDisponibles, ubicacio);
    }

    @Override
    public List<EstacioResponse> aResponses(List<Estacio> estacions) {
        if (estacions == null) {
            return null;
        }
        List<EstacioResponse> llista = new ArrayList<>(estacions.size());
        for (Estacio estacio : estacions) {
            llista.add(aResponse(estacio, 0));
        }
        return llista;
    }
}

Llegir el codi generat és el millor hàbit que pots adquirir amb MapStruct. Deixa de ser màgia: és exactament el codi que hauries escrit a mà, incloses les comprovacions de nul. I quan alguna cosa no mapeja com esperaves, la resposta és allà, en un fitxer Java llegible.

Per a les actualitzacions parcials, MapStruct ofereix @MappingTarget, que modifica un objecte existent en lloc de crear-ne un de nou. Amb record immutables no s'hi aplica directament, així que a CicloUrbana el PATCH continua amb el mètode default que ja hem escrit. Amb classes mutables seria:

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void actualitzar(ActualitzarEstacioRequest peticio, @MappingTarget EstacioMutable desti);

NullValuePropertyMappingStrategy.IGNORE significa "si l'origen és nul, no toquis el destí": exactament la semàntica de PATCH que tanta feina ens va donar a 03-03.

  1. ModelMapper i per què el curs el descarta

ModelMapper fa el mapatge en temps d'execució mitjançant reflexió, deduint les correspondències pel nom. El seu atractiu és que no requereix escriure res:

ModelMapper mapper = new ModelMapper();
EstacioResponse resposta = mapper.map(estacio, EstacioResponse.class);

Els riscos, que són la raó de descartar-lo:

Risc Conseqüència
Correspondències per reflexió Fallen en execució, no en compilació
Coincidència de noms "intel·ligent" Pot aparellar camps que no volies
Rendiment Ordres de magnitud més lent que el codi generat
Depuració El mapatge passa dins de la llibreria, no al teu codi
Refactorització L'IDE no veu les correspondències: reanomenar trenca en silenci

El segon risc és el pitjor: amb el mode de coincidència laxa, ModelMapper pot aparellar estacio.nom amb resposta.nomOperari perquè comparteixen un prefix, i publicar una dada al camp equivocat sense cap avís.

Criteri Manual MapStruct ModelMapper
Errors detectats en Compilació Compilació Execució
Rendiment Màxim Màxim Baix
Codi a escriure Molt Poc Cap
Depurable Sí Sí (codi generat) Difícil
Camp oblidat Silenciós Error de compilació Silenciós
Corba d'aprenentatge Cap Mitjana Baixa

La recomanació del curs: mapatge manual quan hi ha pocs DTO o la lògica de conversió és substancial —és el cas de CicloUrbana en aquest mòdul, i és el codi que apareix a les lliçons—; MapStruct tan bon punt el projecte creix, per la garantia en temps de compilació; i ModelMapper, mai en producció.

  1. On viu el mapatge

Tres posicions defensables:

Opció Argument a favor Argument en contra
Al controlador El servei no coneix el contracte HTTP i és reutilitzable El controlador s'omple de codi de conversió
Al servei El controlador queda mínim El servei queda lligat al contracte de l'API
En un mapejador dedicat Responsabilitat única, provable per separat Una classe més

La política de CicloUrbana: mapejador dedicat, invocat des del controlador. El servei parla el llenguatge del domini —EstacioService.crear(...) rep i retorna Estacio, no DTO—, de manera que quan al mòdul 7 arribi un consumidor de missatges podrà cridar-lo sense construir objectes de l'API. El controlador tradueix, que és el seu paper des de 03-02. I el mapejador concentra la conversió, es prova amb proves unitàries ràpides (mòdul 6) i no obliga a aixecar el context de Spring. El controlador queda així:

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

    // Injectats per constructor: EstacioService, BicicletaService, EstacioMapper

    @GetMapping
    public List<EstacioResponse> llistar(
            @RequestParam(required = false) @Size(max = 80) String nom,
            @RequestParam(defaultValue = "0")  @Min(0) int pagina,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int mida) {

        return estacioService.cercar(nom, null, pagina, mida).stream()
                .map(e -> estacioMapper.aResponse(e,
                        bicicletaService.comptarDisponibles(e.id())))
                .toList();
    }

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

        return estacioService.cercarPerId(id)
                .map(e -> estacioMapper.aDetall(e,
                        bicicletaService.cercarPerEstacioSenseComprovar(id)))
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<EstacioResponse> crear(
            @Valid @RequestBody CrearEstacioRequest peticio) {

        Estacio creada = estacioService.crear(estacioMapper.aDomini(peticio));
        URI ubicacio = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(creada.id()).toUri();
        return ResponseEntity.created(ubicacio)
                .body(estacioMapper.aResponse(creada, 0));
    }
}

Observa un detall important: EstacioService.crear ara rep una Estacio, no un DTO. La signatura del servei ha deixat d'esmentar el contracte de l'API, que és justament l'objectiu.

I la resposta que veu el client:

{
  "id": 1,
  "nom": "Plaça Major",
  "adreca": "Plaça Major, 1",
  "capacitat": 24,
  "bicicletesDisponibles": 7,
  "ubicacio": { "latitud": 41.3851, "longitud": 2.1734 }
}

Compara-la amb la de 03-02, que era l'abocament literal del record Estacio. Aquesta és una decisió de disseny: agrupa les coordenades, exposa una dada calculada i no revela ni un camp que no vulguem publicar.

  1. Projeccions i respostes parcials

De vegades el client només vol uns pocs camps. Un mapa de Ribalta amb 4 estacions no necessita les adreces ni la capacitat: en té prou amb el nom i les coordenades. Descarregar l'objecte complet és trànsit malgastat, i en un llistat de centenars d'elements importa.

Les tres estratègies:

Estratègia Com funciona Avantatges Inconvenients
DTO específics EstacioMapaResponse amb 3 camps Tipat, documentat a OpenAPI Una classe per vista
Selecció de camps ?camps=id,nom filtrat dinàmicament Flexible Sense tipus, sense documentar, difícil de desar a la memòria cau
Projeccions de Spring Data Interfícies que la consulta omple La base de dades només llegeix el demanat Mòdul 4

CicloUrbana tria DTO específics per la seva claredat:

/** Vista mínima per al mapa interactiu de la ciutat. */
public record EstacioMapaResponse(Long id, String nom,
                                  double latitud, double longitud,
                                  int bicicletesDisponibles) {}

L'endpoint GET /api/v1/estacions/mapa retorna List<EstacioMapaResponse> i no arrossega ni adreces ni capacitats. La regla per no acabar amb quinze DTO per recurs: crea una vista nova només quan un client real la demana i la diferència de mida és significativa. Dues o tres vistes per recurs —resum, detall i potser una d'especialitzada— cobreixen gairebé tots els casos.

La selecció dinàmica de camps (?camps=id,nom) és temptadora i gairebé sempre un error en una API REST: trenca el tipatge, impossibilita documentar la resposta a OpenAPI, complica la memòria cau —cada combinació és una resposta diferent— i acaba sent un GraphQL casolà mal fet. Si el projecte necessita de veritat aquesta flexibilitat, la resposta és GraphQL, com vam veure a 03-01.

  1. DTO imbricats i agregats

EstacioDetallResponse és un agregat: una resposta que combina dades de diverses fonts en una sola crida.

curl -s http://localhost:8080/api/v1/estacions/1 | jq
{
  "id": 1,
  "nom": "Plaça Major",
  "adreca": "Plaça Major, 1",
  "capacitat": 24,
  "bicicletesDisponibles": 7,
  "ancoratgesLliures": 15,
  "esPlena": false,
  "ubicacio": { "latitud": 41.3851, "longitud": 2.1734 },
  "bicicletes": [
    { "id": 12, "matricula": "RB-0142", "bateria": 87, "estat": "DISPONIBLE" },
    { "id": 13, "matricula": "RB-0143", "bateria": 15, "estat": "MANTENIMENT" }
  ]
}

El benefici és concret: l'app de Ribalta obté tota la pantalla de detall amb una petició en lloc de dues. En una xarxa mòbil lenta, cada anada i tornada costa centenars de mil·lisegons.

El perill també és concret: agregar massa. Si EstacioDetallResponse inclogués a més els últims cent lloguers i l'històric d'incidències, la resposta pesaria megabytes i seria lenta per a tothom, inclosos els clients que només volien el nom. Els tres criteris per decidir si una dada s'imbrica són: el client la necessita gairebé sempre en aquesta pantalla? (les bicicletes ancorades, sí); té la mida acotada? (com a màxim hi ha capacitat bicicletes; els lloguers històrics creixen sense límit); i canvia al mateix ritme?, perquè ajuntar una dada que s'actualitza cada segon amb una altra que canvia cada mes impedeix desar el conjunt a la memòria cau.

Els lloguers fallen els tres criteris, així que no s'imbriquen: es consulten amb GET /api/v1/estacions/1/lloguers?pagina=0, paginats i sota demanda.

  1. Evolucionar el contracte sense trencar clients

Els DTO són el que fa possible a la pràctica la política de versionatge de 03-01. Quatre escenaris reals:

Afegir un camp és compatible: s'afegeix a EstacioResponse, s'omple al mapejador i els clients antics l'ignoren gràcies a fail-on-unknown-properties: false. Reanomenar un camp del domini deixa d'afectar el contracte: si Estacio.capacitat passa a dir-se placesTotals, es canvia una línia del mapejador i el JSON continua dient "capacitat". Aquest és, en una frase, el retorn de tota la inversió d'aquesta lliçó.

Retirar un camp del contracte requereix transició: marcar-lo obsolet a la documentació (@Schema(deprecated = true), lliçó 03-07), mesurar quants clients el fan servir amb Actuator (mòdul 7) i retirar-lo a /api/v2 quan l'ús arribi a zero.

Canviar la forma d'un camp —les coordenades soltes passant a un objecte ubicacio— es resol amb duplicitat temporal:

public record EstacioResponse(
        Long id, String nom, String adreca, int capacitat,
        int bicicletesDisponibles,
        UbicacioResponse ubicacio,

        /** @deprecated des de 1.4.0, retirar a /api/v2. Feu servir ubicacio.latitud. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double latitud,

        /** @deprecated des de 1.4.0, retirar a /api/v2. Feu servir ubicacio.longitud. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double longitud
) {}

Els tres camps conviuen, el canvi és compatible i el mapejador omple totes dues formes. Quan les mètriques diguin que ningú no llegeix latitud solta, es retira. Sense DTO això seria impossible sense duplicar l'entitat de domini.

Errors Comuns i Consells

Exposar l'entitat "només en aquest endpoint". L'excepció es converteix en norma. Al mòdul 4, aquest endpoint serà el que provoqui la LazyInitializationException.

Fer servir el mateix DTO per a petició i resposta. Acaba amb camps nuls en una direcció i validacions inútils en l'altra. En la mateixa línia, posar validació als DTO de resposta només afegeix soroll: no es valida el que un mateix genera.

Mapejar al servei. Lliga la lògica de negoci al contracte HTTP. Quan arribi un consumidor de cua haurà de construir DTO d'API per cridar el servei.

Acceptar l'id al cos d'un POST. L'identificador l'assigna el servidor. Si el DTO l'inclou, un client pot intentar fixar-lo.

Oblidar @Mapping a MapStruct sense unmappedTargetPolicy=ERROR. El camp es queda a nul silenciosament. Activa la política al pom.xml des del primer dia.

Imbricar col·leccions sense cota. EstacioDetallResponse amb tots els lloguers històrics creix sense límit. Aplica els tres criteris de l'apartat 11.

Consell: anomena els DTO pel seu ús, no per la seva forma. CrearEstacioRequest i EstacioDetallResponse diuen on es fan servir. EstacioDTO i EstacioDTO2 no diuen res.

Consell: prova el mapejador a part. Un EstacioMapperTest sense context de Spring corre en mil·lisegons i detecta a l'instant un camp mal col·locat. És la prova amb millor relació cost-benefici del projecte (mòdul 6).

Exercicis

Exercici 1: Dissenyar i mapejar LloguerResponse

LloguerController retorna encara el record Lloguer del domini, que exposa usuariId, bicicletaId i els identificadors d'estació en cru. Dissenya LloguerResponse pensant en el que l'app mòbil necessita mostrar a l'historial del ciutadà, i implementa LloguerMapper. Justifica quins camps del domini no surten i quins camps calculats hi afegeixes.

Exercici 2: Vista d'operari amb dades que no es publiquen

El tauler intern dels operaris de Ribalta necessita, per a cada bicicleta, la matrícula, la bateria, l'estat, el codiAncoratgeIntern i el nombre d'incidències obertes. Cap dels tres últims no ha d'aparèixer a l'API pública. Dissenya la solució i explica com evites que un canvi futur filtri dades internes a l'endpoint públic.

Exercici 3: Migrar el mapejador d'EstacioMapper a MapStruct

Converteix l'EstacioMapper manual de l'apartat 6 en una interfície MapStruct. Resol els tres casos difícils: el camp bicicletesDisponibles que no és al domini, l'agrupació de latitud i longitud a ubicacio, i els camps calculats ancoratgesLliures i esPlena.

Solucions

Solució 1.

package com.ciclourbana.lloguers.dto;

public record LloguerResponse(
        Long id,
        String matriculaBicicleta,      // no bicicletaId: l'app mostra "RB-0142"
        String estacioOrigen,           // nom, no id
        String estacioDesti,            // nom, no id; null si continua en curs
        LocalDateTime inici,
        LocalDateTime fi,               // null mentre estigui en curs
        long duradaMinuts,              // calculat
        BigDecimal importTotal,         // null mentre estigui en curs
        EstatLloguer estat) {}

Què no surt i per què:

Camp del domini Decisió Motiu
usuariId No surt L'usuari ja sap qui és; publicar-ho permetria enumerar usuaris
bicicletaId Se substitueix per matriculaBicicleta L'identificador intern no serveix a l'app
estacioOrigenId/estacioDestiId Se substitueixen pel nom Evita una segona crida només per resoldre el nom

Camps calculats: duradaMinuts estalvia a cada client reimplementar el càlcul, i fer-ho al servidor garanteix que tots obtinguin el mateix número.

@Component
public class LloguerMapper {

    // Injectats: BicicletaRepositori, EstacioRepositori i el Clock de ConfiguracioComuna

    public LloguerResponse aResponse(Lloguer lloguer) {

        // Si continua en curs, la durada es compta fins ARA
        LocalDateTime finsA = lloguer.fi() != null
                ? lloguer.fi() : LocalDateTime.now(clock);

        return new LloguerResponse(
                lloguer.id(),
                bicicletaRepositori.cercarPerId(lloguer.bicicletaId())
                        .map(Bicicleta::matricula).orElse("desconeguda"),
                nomEstacio(lloguer.estacioOrigenId()),
                nomEstacio(lloguer.estacioDestiId()),
                lloguer.inici(), lloguer.fi(),
                Duration.between(lloguer.inici(), finsA).toMinutes(),
                lloguer.importTotal(), lloguer.estat());
    }

    private String nomEstacio(Long id) {
        return id == null ? null
                : estacioRepositori.cercarPerId(id).map(Estacio::nom).orElse(null);
    }
}

Advertiment de disseny: aquest mapejador consulta repositoris, i això el converteix en alguna cosa més que un mapejador. En un llistat de 100 lloguers provocaria 300 consultes —el problema N+1 del mòdul 4—. Les sortides són que el servei retorni un agregat amb els noms ja resolts, o que el repositori els porti en una sola consulta. Si el teu mapejador necessita el repositori, és senyal que el servei no està retornant prou.

Solució 2.

// Públic: només el que pot veure qualsevol ciutadà
public record BicicletaResponse(Long id, String matricula,
                                int bateria, EstatBicicleta estat,
                                Long estacioId, String estacioNom) {}

// Intern: inclou dades operatives. Viu en un paquet diferent.
public record BicicletaOperariResponse(Long id, String matricula,
                                       int bateria, EstatBicicleta estat,
                                       Long estacioId, String estacioNom,
                                       String codiAncoratgeIntern,
                                       int incidenciesObertes,
                                       LocalDateTime ultimaRevisio) {}

Amb rutes separades i mapejadors separats:

GET /api/v1/bicicletes             -> BicicletaResponse         (públic)
GET /api/v1/intern/bicicletes      -> BicicletaOperariResponse   (rol OPERARI, mòdul 5)

Com s'evita la fuita futura, que és el fons de l'exercici:

  1. Són classes diferents, no una amb camps condicionals. Un if (esOperari) dins del mapejador acaba fallant el dia que algú inverteixi la condició o l'oblidi.
  2. La @Schema d'OpenAPI (03-07) documenta cadascuna per separat, i una revisió de la documentació pública deixa veure de seguida si apareix un camp que no hi hauria de ser.
  3. La prova de contracte és la xarxa de seguretat definitiva. Al mòdul 6 escriurem una prova que afirma exactament quines claus retorna GET /api/v1/bicicletes; si algú afegeix un camp intern a la resposta pública, la prova falla abans del desplegament.
  4. No reutilitzar mai el DTO intern "perquè ho té tot". És la temptació que provoca totes les fuites.

Un antipatró habitual que convé descartar explícitament: fer servir @JsonView per servir dues vistes des d'una sola classe. Funciona, però deixa el camp perillós dins de l'objecte que es serialitza, depenent d'una anotació per no sortir. És de nou una llista d'exclusions. Dues classes són més codi i molt més segur.

Solució 3.

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

    // Cas 1: una dada que no és a l'origen arriba com a paràmetre extra.
    // MapStruct l'aparella per nom amb el component del record destí.
    @Mapping(target = "ubicacio", source = "estacio")
    EstacioResponse aResponse(Estacio estacio, int bicicletesDisponibles);

    // Cas 2: agrupar dos camps plans en un objecte imbricat.
    // Un mètode auxiliar que MapStruct fa servir automàticament per la signatura.
    default UbicacioResponse aUbicacio(Estacio estacio) {
        return estacio == null ? null
                : new UbicacioResponse(estacio.latitud(), estacio.longitud());
    }

    @Mapping(target = "id", ignore = true)
    Estacio aDomini(CrearEstacioRequest peticio);

    List<BicicletaResum> aResums(List<Bicicleta> bicicletes);

    // Cas 3: càlculs que depenen de diverses fonts.
    // S'escriuen com a default: MapStruct no els pot inferir, i forçar-ho
    // amb @Mapping i expressions java() produeix codi il·legible.
    default EstacioDetallResponse aDetall(Estacio estacio, List<Bicicleta> ancorades) {
        int disponibles = (int) ancorades.stream()
                .filter(b -> b.estat() == EstatBicicleta.DISPONIBLE).count();
        return new EstacioDetallResponse(estacio.id(), estacio.nom(),
                estacio.adreca(), estacio.capacitat(), disponibles,
                estacio.capacitat() - ancorades.size(),
                ancorades.size() >= estacio.capacitat(),
                aUbicacio(estacio), aResums(ancorades));
    }
}

Les tres lliçons de l'exercici:

  • Cas 1. Quan un valor no és a l'objecte d'origen, es passa com a paràmetre addicional del mètode. MapStruct l'aparella amb el component del destí pel nom, així que int bicicletesDisponibles omple el camp bicicletesDisponibles. Si els noms no coincidissin, caldria @Mapping(target = "...", source = "...").
  • Cas 2. Un mètode default la signatura del qual va del tipus origen al tipus destí es converteix en un convertidor que MapStruct fa servir automàticament allà on necessiti aquesta transformació. És el mecanisme més útil i menys conegut de la llibreria.
  • Cas 3. Quan un mapatge requereix lògica real, escriu-lo com a mètode default. MapStruct permet expressions inline amb @Mapping(target = "esPlena", expression = "java(...)"), però això fica codi Java dins d'una cadena de text: sense comprovació de tipus, sense autocompletat i sense refactorització. Un default és Java normal i corrent.

I la comprovació final, que és la raó d'haver migrat: si demà algú afegeix el camp zona a EstacioResponse i no diu d'on surt, ./mvnw compile falla amb Unmapped target property: "zona". Al mapejador manual, aquest camp s'hauria quedat a nul fins que un usuari de Ribalta ho notés.

Conclusió

El domini de CicloUrbana i el seu contracte públic són per fi dues coses separades. Saps per què exposar les entitats és una mala idea i ho pots justificar amb quatre fallades concretes: la fuita silenciosa d'un hash de contrasenya o un DNI, l'acoblament que converteix un canvi de nom innocent en una ruptura de l'app mòbil, les referències circulars que provoquen un StackOverflowError tan bon punt hi hagi relacions, i la impossibilitat d'exposar una dada calculada com bicicletesDisponibles sense contaminar el model. Entens la diferència entre un DTO de petició —mínim, validat, sense identificadors generats— i un de resposta —amb camps calculats, sense validació, en variants de resum i detall—, i per què són record immutables el constructor compacte dels quals és el lloc natural per normalitzar. Tens la taula completa dels onze DTO del projecte, amb dues decisions de disseny que val la pena recordar: ActualitzarEstacioRequest omet les coordenades perquè una estació física no es mou, i LloguerResponse substitueix el bicicletaId intern per la matrícula que veu el ciutadà.

Saps mapejar de tres maneres i triar-ne una: manual quan hi ha pocs DTO, MapStruct tan bon punt creix —amb unmappedTargetPolicy=ERROR, que converteix l'oblit silenciós en un error de compilació— i mai ModelMapper. Has llegit el codi que MapStruct genera i saps que no és màgia. Tens la política del projecte sobre on viu el mapatge —mapejador dedicat invocat des del controlador, amb el servei parlant només el llenguatge del domini— i els criteris per decidir quan imbricar un agregat i quan paginar-lo a part. I saps com evolucionar el contracte sense trencar clients, amb la duplicitat temporal de camps que fa compatible gairebé qualsevol canvi.

Queda un últim buit, i és el més visible des de fora. Quan alguna cosa va malament, CicloUrbana respon amb {"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content."}, que no diu quin camp ha fallat. Una ConstraintViolationException de 03-04 surt com a 500. Els 404 els construïm a mà a cada controlador amb ResponseEntity.notFound(). I les regles de negoci de 03-03 llancen IllegalStateException que es converteixen en errors del servidor amb la traça de pila a dins, quan haurien de ser 409 nets. Tot aquest deute fa tres lliçons que s'acumula amb etiquetes TODO.

La lliçó 03-06, Gestió d'Excepcions a REST, el salda del tot. Veurem què fa Spring Boot per defecte i per què no n'hi ha prou per a una API pública; construirem la jerarquia d'excepcions de CicloUrbana amb el seu mapatge a codis HTTP; centralitzarem tot en un @RestControllerAdvice; adoptarem el format estàndard RFC 7807 Problem Details amb el suport natiu de Spring Boot 3; convertirem els errors de validació de 03-04 en una resposta amb la llista exacta de camps erronis; gestionarem les excepcions del framework mateix; i afegirem un identificador de rastreig que permeti correlacionar qualsevol error amb els logs del servidor.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats