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
- Per què no s'exposen les entitats del domini
- Quatre fallades concretes, amb exemple
- Tipus de DTO: petició i resposta
- Per què
recordimmutables - La jerarquia de DTO de CicloUrbana
- Mapatge manual
- MapStruct
- ModelMapper i per què el curs el descarta
- On viu el mapatge
- Projeccions i respostes parcials
- DTO imbricats i agregats
- Evolucionar el contracte sense trencar clients
- Errors Comuns i Consells
- Exercicis
- 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.
- 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
- 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.
- Per què
record immutables
record immutablesTots 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.
- 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ó.
- 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.
- 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.
- 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ó.
- 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.
- 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.
- DTO imbricats i agregats
EstacioDetallResponse és un agregat: una resposta que combina dades de diverses fonts en una sola crida.
{
"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.
- 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:
- 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. - La
@Schemad'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. - 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. - 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 bicicletesDisponiblesomple el campbicicletesDisponibles. Si els noms no coincidissin, caldria@Mapping(target = "...", source = "..."). - Cas 2. Un mètode
defaultla 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ó. Undefaulté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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
