Portem dues lliçons escrivint entitats i relacions, i hem anat fent servir als exemples un estacioRepositori que encara no existeix. Mentrestant, EstacioRepositoriEnMemoria continua allà des de 02-01, amb el seu ConcurrentHashMap, el seu AtomicLong i els seus sis mètodes escrits a mà. Aquesta lliçó el jubila.
I ho fa de la forma més cridanera possible: esborrant codi. Spring Data JPA converteix una interfície buida en un repositori completament funcional, amb una vintena de mètodes ja implementats, paginació, ordenació i traducció d'excepcions. És probablement el moment del curs en què la relació entre esforç i resultat és més favorable. Però convé entendre què passa per sota, perquè uns quants d'aquests mètodes heretats tenen una semàntica que no és la que el seu nom suggereix —save no sempre insereix, getReferenceById no consulta la base de dades— i confondre-les produeix errors difícils de rastrejar.
Contingut
- La jerarquia d'interfícies de Spring Data
- Com Spring Data crea la implementació
- Jubilar
EstacioRepositoriEnMemoria - Els mètodes heretats i la seva semàntica exacta
findByIdenfront degetReferenceByIdsave: inserir o fusionarOptional<T>i el tractament de l'absència- Paginació i ordenació
- Integrar la paginació a l'API REST
- Repositoris amb mètodes propis
@Repositoryi la traducció d'excepcionsExampleiSpecification: una primera mirada- Errors Comuns i Consells
- Exercicis
- La jerarquia d'interfícies de Spring Data
graph TD
R["Repository<T, ID><br/>marcador, sense mètodes"]
C["CrudRepository<T, ID><br/>save, findById, delete..."]
LC["ListCrudRepository<T, ID><br/>retorna List en lloc d'Iterable"]
P["PagingAndSortingRepository<T, ID><br/>findAll(Pageable), findAll(Sort)"]
J["JpaRepository<T, ID><br/>flush, saveAndFlush, getReferenceById"]
R --> C
C --> LC
R --> P
LC --> J
P --> J
| Interfície | Què aporta | Quan triar-la |
|---|---|---|
Repository<T, ID> |
Res: només marca la interfície perquè Spring Data la detecti | Quan vols exposar només els mètodes que tu declaris |
CrudRepository<T, ID> |
CRUD bàsic; retorna Iterable<T> |
Aplicacions senzilles, sense paginació |
ListCrudRepository<T, ID> |
Igual, però retorna List<T> |
Gairebé sempre millor que CrudRepository |
PagingAndSortingRepository<T, ID> |
findAll(Pageable) i findAll(Sort) |
Quan només necessites paginar |
JpaRepository<T, ID> |
Tot l'anterior més flush, saveAndFlush, getReferenceById, deleteAllInBatch |
L'opció per defecte amb JPA |
Què triar a CicloUrbana. JpaRepository per a gairebé tot: és l'esperat i no hi ha cost pels mètodes que no facis servir. L'alternativa que mereix consideració és estendre Repository<T, ID> declarant només els mètodes permesos:
public interface LloguerRepositori extends Repository<Lloguer, Long> {
Lloguer save(Lloguer lloguer);
Optional<Lloguer> findById(Long id);
List<Lloguer> findByUsuariIdOrderByIniciDesc(Long usuariId);
// Deliberadament NO s'exposa deleteById: els lloguers no s'esborren
}És una decisió de disseny defensable: un històric de lloguers no s'ha d'esborrar mai, i no exposar el mètode fa impossible l'accident. El preu és escriure a mà les signatures que sí que vols. (Nota sobre ListCrudRepository: és una incorporació de Spring Data 3 que retorna List<T> on CrudRepository retornava Iterable<T>; JpaRepository ja l'estén.)
- Com Spring Data crea la implementació
Aquesta és la pregunta que tothom es fa: qui implementa una interfície que ningú no implementa?
A l'arrencada, JpaRepositoriesAutoConfiguration (02-06) activa l'escaneig de repositoris des del paquet de @SpringBootApplication. Per cada interfície que estengui Repository:
JpaRepositoryFactoryBeanl'analitza i determina l'entitat i el tipus del seu id.- Crea una instància de
SimpleJpaRepository<T, ID>, la implementació estàndard que conté el codi real desave,findById,findAll, etc., escrita sobre l'EntityManager. - Embolcalla aquesta instància en un proxy dinàmic de Java que implementa la teva interfície.
- Registra el proxy com a bean del context.
sequenceDiagram
participant A as Arrencada de Spring
participant F as JpaRepositoryFactoryBean
participant P as Proxy dinàmic
participant S as SimpleJpaRepository
participant EM as EntityManager
A->>F: trobada EstacioRepositori
F->>S: crear SimpleJpaRepository(Estacio.class, em)
F->>P: crear proxy que implementa EstacioRepositori
A->>A: registrar el proxy com a bean
Note over P,S: En execució
P->>S: findById(1L) -> delega
S->>EM: em.find(Estacio.class, 1L)
Quan crides un mètode, el proxy decideix a qui delegar: si és heretat (findById, save), a SimpleJpaRepository; si és una consulta derivada (findByNom), analitza el nom i genera la consulta (04-06); si té @Query, executa aquella consulta; i si pertany a una interfície Custom que tu implementes, a la teva implementació (apartat 10).
Aquest mecanisme de proxies és el mateix de 02-03, portat a l'extrem que no hi ha objecte embolcallat que tu hagis escrit: el proxy és l'única cosa que existeix de la teva interfície. I una conseqüència útil: els repositoris són beans singleton normals, injectables per constructor com qualsevol altre (02-02).
- Jubilar
EstacioRepositoriEnMemoria
EstacioRepositoriEnMemoriaAquest és el moment que vam preparar des de 02-01. La implementació en memòria era, resumida:
@Repository
public class EstacioRepositoriEnMemoria implements EstacioRepositori {
private final Map<Long, Estacio> magatzem = new ConcurrentHashMap<>();
private final AtomicLong sequencia = new AtomicLong(0);
@Override
public Estacio desar(Estacio estacio) {
Long id = estacio.id() != null ? estacio.id() : sequencia.incrementAndGet();
magatzem.put(id, new Estacio(id, estacio.nom(), /* ... */));
return magatzem.get(id);
}
@Override public Optional<Estacio> cercarPerId(Long id) { /* ... */ }
@Override public List<Estacio> cercarTotes() { /* ... */ }
@Override public boolean existeixPerId(Long id) { /* ... */ }
@Override public void eliminarPerId(Long id) { /* ... */ }
@Override public boolean existeixPerNom(String nom) { /* ... */ }
}Unes vuitanta línies comptant el fitxer real. El seu substitut complet:
package com.ciclourbana.estacions;
public interface EstacioRepositori extends JpaRepository<Estacio, Long> {
boolean existsByNom(String nom);
List<Estacio> findByActivaTrue();
}Què desapareix:
| S'elimina | Substituït per |
|---|---|
EstacioRepositoriEnMemoria sencera |
SimpleJpaRepository, generat |
ConcurrentHashMap i AtomicLong |
La seqüència de PostgreSQL (04-03) |
desar, cercarPerId, cercarTotes... |
Mètodes heretats de JpaRepository |
existeixPerNom a mà |
existsByNom, consulta derivada (04-06) |
CarregadorEstacionsDemo |
Migració de dades amb Flyway (04-08) |
Què cal ajustar a EstacioService. Els noms de mètode canvien del català de la nostra interfície pròpia als de Spring Data: desar → save, cercarPerId → findById, cercarTotes → findAll, existeixPerId → existsById, eliminarPerId → deleteById i existeixPerNom → existsByNom. És l'únic cost real del canvi.
Podries conservar els noms en català declarant-los a la interfície i anotant-los amb @Query, però no compensa: els noms de Spring Data són un vocabulari compartit que qualsevol desenvolupador Java reconeix a l'instant. Els identificadors del domini continuen en català —Estacio, EstacioService, EstacioRepositori—; el que s'adopta és l'API del framework, igual que s'escriu List i no Llista.
I l'important: EstacioController no canvia ni una línia. L'API REST de Ribalta continua exactament igual, amb els seus tretze endpoints, els seus DTOs i els seus codis d'estat. És la recompensa d'haver aïllat l'emmagatzematge darrere una interfície des del principi.
- Els mètodes heretats i la seva semàntica exacta
| Mètode | Què fa exactament | Consultes |
|---|---|---|
save(T) |
persist si és nova, merge si té id |
0-2 |
saveAll(Iterable<T>) |
save sobre cada element |
N |
saveAndFlush(T) |
save + flush immediat |
1-2 |
findById(ID) |
Optional amb l'entitat, o buit. Consulta ja |
0-1 |
getReferenceById(ID) |
Un proxy mandrós. No consulta | 0 |
findAll() |
Totes les files de la taula | 1 |
findAllById(Iterable<ID>) |
WHERE id IN (...) |
1 |
existsById(ID) |
SELECT count(*) ... WHERE id = ? |
1 |
count() |
SELECT count(*) |
1 |
deleteById(ID) |
Carrega l'entitat i l'esborra | 1-2 |
delete(T) |
Esborra una entitat ja carregada | 1 |
deleteAll() |
Carrega totes i esborra una a una | 1 + N |
deleteAllInBatch() |
Un sol DELETE FROM taula |
1 |
flush() |
Sincronitza el context amb la base de dades | Les pendents |
Tres avisos que eviten sorpreses:
deleteAll() enfront de deleteAllInBatch(). El primer carrega totes les entitats i emet un DELETE per cadascuna per poder aplicar cascades i callbacks: amb 100.000 lloguers, 100.001 sentències. deleteAllInBatch() executa un únic DELETE FROM lloguers, però se salta les cascades i el context de persistència, que pot quedar amb entitats ja inexistents. Ràpid i perillós.
deleteById executa un SELECT abans del DELETE, perquè necessita l'entitat per aplicar cascades. I si l'id no existeix, a Spring Data 3 no llança excepció: simplement no fa res. Per retornar el 404 correcte (03-06) cal comprovar abans amb existsById i llançar RecursNoTrobatException.
count() i existsById() no carreguen entitats. Són consultes d'agregació pures. Preferir existsById(id) a findById(id).isPresent() no és cosmètica: la segona porta totes les columnes de la fila per descartar-les.
findById enfront de getReferenceById
findById enfront de getReferenceByIdÉs la diferència més incompresa de l'API, i la que més rendiment pot estalviar.
findById(id) |
getReferenceById(id) |
|
|---|---|---|
| Consulta la base de dades | Sí, immediatament | No |
| Retorna | Optional<T> |
T (un proxy) |
| Si l'id no existeix | Optional.empty() |
Falla més tard, amb EntityNotFoundException |
| Ús típic | Llegir o modificar l'entitat | Només assignar-la com a clau forana |
El cas on getReferenceById brilla és exactament el de CicloUrbana en iniciar un lloguer:
@Transactional
public LloguerResponse iniciar(IniciarLloguerRequest peticio) {
Bicicleta bicicleta = bicicletaRepositori.findById(peticio.bicicletaId())
.orElseThrow(() -> new RecursNoTrobatException("Bicicleta", peticio.bicicletaId()));
if (!bicicleta.esPotLlogar(xarxaProperties.llindarBateria())) {
throw new BicicletaNoDisponibleException(bicicleta.getMatricula());
}
Lloguer lloguer = new Lloguer();
// Només necessitem l'id per a la clau forana: NO cal carregar l'usuari
lloguer.setUsuari(usuariRepositori.getReferenceById(peticio.usuariId()));
lloguer.setBicicleta(bicicleta);
lloguer.setEstacioOrigen(estacioRepositori.getReferenceById(peticio.estacioOrigenId()));
lloguer.setInici(Instant.now());
bicicleta.setEstat(EstatBicicleta.EN_US);
return mapper.aResponse(lloguerRepositori.save(lloguer));
}Bicicleta es carrega amb findById perquè cal llegir el seu estat i la seva bateria i modificar el seu estat. Usuari i Estacio només calen per omplir usuari_id i estacio_origen_id a l'INSERT, i per a això n'hi ha prou amb l'id que el proxy ja conté: ens estalviem dos SELECT a l'operació més freqüent de tota l'aplicació.
El risc, que cal conèixer: si l'usuari no existeix, getReferenceById no falla allà sinó més tard —en accedir a un camp del proxy, o en el commit, on falla la clau forana—, i l'error arriba descol·locat respecte a la seva causa. La regla: fes servir getReferenceById només per assignar associacions l'id de les quals ja has validat, per exemple perquè ve d'un usuari autenticat (mòdul 5).
save: inserir o fusionar
save: inserir o fusionarsave() sembla un simple «desar» i fa dues coses molt diferents segons l'estat de l'entitat (04-01):
graph TD
A["save(entitat)"] --> B{"l'id és null?"}
B -->|Sí| C["persist(): INSERT<br/>l'entitat passa a gestionada"]
B -->|No| D["merge(): SELECT + UPDATE<br/>retorna una CÒPIA gestionada"]
La conseqüència pràctica més important és a merge: retorna una instància diferent de la que li passes. La que li passes continua separada.
Estacio separada = new Estacio(...); // amb id = 1, venint de fora
Estacio gestionada = estacioRepositori.save(separada);
separada == gestionada; // false
separada.setNom("Un altre nom"); // NO es desa: continua separada
gestionada.setNom("Un altre nom"); // SÍ que es desa: està gestionadaFes servir sempre el valor retornat per save(). Ignorar-lo és una de les causes més comunes de «modifico i no es desa».
I el corol·lari, que ja vam veure a 04-01 i desenvoluparem a 04-07: sobre una entitat gestionada no cal cridar save(). Dins d'una transacció, carregar amb findById, fer estacio.setActiva(false) i no cridar res més n'hi ha prou i és correcte: el dirty checking genera l'UPDATE en el commit.
Un matís de rendiment en càrregues massives: save() sobre una entitat amb id executa un SELECT abans de l'UPDATE, per poder fusionar. Inserint milers de files amb ids ja assignats, aquest SELECT es paga per cadascuna. Si saps que l'entitat és nova, entityManager.persist() ho evita; Spring Data ho detecta sol quan l'id és nul o quan l'entitat implementa Persistable.
Optional<T> i el tractament de l'absència
Optional<T> i el tractament de l'absènciaSpring Data retorna Optional<T> a les cerques per identificador. És una decisió de disseny deliberada: fa impossible oblidar el cas «no existeix», que amb null s'oblida constantment.
L'ús correcte a CicloUrbana enllaça directament amb la jerarquia d'excepcions de 03-06:
@Transactional(readOnly = true)
public EstacioResponse cercarPerId(Long id) {
return estacioRepositori.findById(id)
.map(mapper::aResponse)
.orElseThrow(() -> new RecursNoTrobatException("Estació", id));
}Es llegeix de seguit: cerca, mapeja si hi és, i si no llança l'excepció que el @RestControllerAdvice converteix en un 404 amb ProblemDetail.
Formes incorrectes freqüents:
findById(id).get(); // MALAMENT: NoSuchElementException -> 500 en lloc de 404
findById(id).orElse(null); // MALAMENT: reintrodueix el null que Optional venia a eliminar
// MALAMENT: verbós i equivalent a orElseThrow
Optional<Estacio> opt = findById(id);
if (opt.isEmpty()) throw new RecursNoTrobatException("Estació", id);Mètodes útils d'Optional en aquest context: map per transformar, filter per condicionar, orElseThrow per exigir presència, orElseGet per a un valor per defecte calculat i ifPresentOrElse per a dues branques. I mai facis servir Optional com a paràmetre de mètode ni com a camp d'una entitat: està pensat per a valors de retorn.
- Paginació i ordenació
GET /api/v1/estacions retorna avui les quatre estacions de Ribalta. Quan la xarxa creixi a dues-centes, o quan es llistin els lloguers de l'any, retornar-ho tot deixarà de ser viable: memòria al servidor, amplada de banda i un client que no ho pot processar.
Les peces:
| Tipus | Què és |
|---|---|
Pageable |
Petició de pàgina: número, mida i ordenació |
PageRequest |
La seva implementació: PageRequest.of(0, 20, Sort.by("nom")) |
Sort |
Ordenació: Sort.by(Sort.Direction.DESC, "capacitat") |
Page<T> |
Resultat amb total d'elements i de pàgines |
Slice<T> |
Resultat sense total: només sap si hi ha pàgina següent |
| Retorn | Consultes SQL | Sap el total | Quan fer-lo servir |
|---|---|---|---|
List<T> |
1 | No | Quan no necessites metadades |
Slice<T> |
1 (demana mida + 1 files) |
No | Desplaçament infinit, taules grans |
Page<T> |
2 (una de dades i una de count) |
Sí | Taules amb numeració de pàgines |
La diferència de cost importa: Page executa una segona consulta SELECT count(*) que, sobre una taula de milions de lloguers amb filtres complexos, pot ser més cara que la mateixa consulta de dades. Slice l'evita demanant una fila de més i comprovant si va venir.
Al repositori no cal declarar res: JpaRepository ja hereta findAll(Pageable).
Page<Estacio> pagina = estacioRepositori.findAll(
PageRequest.of(0, 20, Sort.by("nom").ascending()));
pagina.getContent(); // List<Estacio> amb les 20 d'aquesta pàgina
pagina.getTotalElements(); // 200 pagina.getTotalPages(); // 10
pagina.getNumber(); // 0 pagina.hasNext(); // trueI el SQL que genera amb PostgreSQL:
select e1_0.id, e1_0.nom, ... from estacions e1_0
order by e1_0.nom asc
offset 0 rows fetch first 20 rows only;
select count(e1_0.id) from estacions e1_0; -- només amb Page, no amb SliceAvís sobre les pàgines profundes. OFFSET 100000 obliga la base de dades a llegir i descartar cent mil files abans de retornar-ne vint. Amb taules grans, la paginació per cursor —«dona'm els següents a partir d'aquest id»— és molt més eficient (09-01).
- Integrar la paginació a l'API REST
Spring MVC resol Pageable automàticament a partir dels paràmetres de consulta, gràcies a PageableHandlerMethodArgumentResolver, que Spring Boot registra sol.
@GetMapping
public PaginaResponse<EstacioResponse> llistar(
@PageableDefault(size = 20, sort = "nom") Pageable pageable) {
return estacioService.llistar(pageable);
}Ara l'API accepta GET /api/v1/estacions?page=0&size=20&sort=capacitat,desc.
@PageableDefault és important: sense ell, la mida per defecte és 20 però el client pot demanar size=100000 i tombar el servidor. Limita a més el màxim globalment:
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100
one-indexed-parameters: false # la primera pàgina és la 0Per què no es retorna Page directament. És temptador —funciona i Jackson ho serialitza— i és un error. En arrencar, Spring Boot 3 fins i tot avisa:
Serializing PageImpl instances as-is is not supported, meaning that there is no
guarantee about the stability of the resulting JSON structure!Tres motius concrets: l'estructura JSON no és estable —PageImpl és una classe interna de Spring Data la serialització de la qual ha canviat entre versions i pot tornar a canviar, trencant tots els clients de Ribalta en una actualització de dependències—; filtra detalls interns, perquè el JSON inclou un objecte pageable amb paged, unpaged i offset, conceptes del framework aliens al contracte públic; i contradiu la disciplina de 03-05, ja que si no exposem entitats, encara menys classes internes del framework.
La solució és un DTO propi, estable i documentable en OpenAPI (03-07):
package com.ciclourbana.comu.dto; // DTO de pàgina, estable i documentable
public record PaginaResponse<T>(
List<T> contingut,
int pagina,
int mida,
long totalElements,
int totalPagines,
boolean primera,
boolean ultima) {
public static <T> PaginaResponse<T> de(Page<T> page) {
return new PaginaResponse<>(
page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages(),
page.isFirst(), page.isLast());
}
}I al servei, amb el mapatge dins de la transacció com exigeix 04-04:
@Transactional(readOnly = true)
public PaginaResponse<EstacioResponse> llistar(Pageable pageable) {
Page<EstacioResponse> pagina = estacioRepositori.findAll(pageable)
.map(mapper::aResponse);
return PaginaResponse.de(pagina);
}Page.map() transforma el contingut conservant les metadades: no cal reconstruir res a mà.
La resposta al client:
{
"contingut": [ { "id": 1, "nom": "Plaça Major", "capacitat": 24 },
{ "id": 2, "nom": "Estació Nord", "capacitat": 30 } ],
"pagina": 0, "mida": 20, "totalElements": 4, "totalPagines": 1,
"primera": true, "ultima": true
}
- Repositoris amb mètodes propis
De vegades un mètode necessita lògica que ni les consultes derivades ni @Query poden expressar: accés directe a l'EntityManager, construcció dinàmica de criteris o crides al SQL natiu amb processament intermedi. Spring Data ho permet amb una convenció de noms molt concreta.
Pas 1: la interfície amb els mètodes propis.
package com.ciclourbana.estacions;
public interface EstacioRepositoriCustom {
List<Estacio> cercarAmbFiltres(String nom, Integer capacitatMinima, Boolean activa);
}Pas 2: la implementació. El nom és obligatori: <NomInterficie>Impl.
package com.ciclourbana.estacions;
public class EstacioRepositoriCustomImpl implements EstacioRepositoriCustom {
private final EntityManager entityManager;
public EstacioRepositoriCustomImpl(EntityManager em) { this.entityManager = em; }
@Override
public List<Estacio> cercarAmbFiltres(String nom, Integer capacitatMinima,
Boolean activa) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Estacio> consulta = cb.createQuery(Estacio.class);
Root<Estacio> arrel = consulta.from(Estacio.class);
List<Predicate> predicats = new ArrayList<>();
if (nom != null && !nom.isBlank())
predicats.add(cb.like(cb.lower(arrel.get("nom")),
"%" + nom.toLowerCase() + "%"));
if (capacitatMinima != null)
predicats.add(cb.greaterThanOrEqualTo(arrel.get("capacitat"), capacitatMinima));
if (activa != null)
predicats.add(cb.equal(arrel.get("activa"), activa));
consulta.where(predicats.toArray(Predicate[]::new))
.orderBy(cb.asc(arrel.get("nom")));
return entityManager.createQuery(consulta).getResultList();
}
}Pas 3: el repositori estén totes dues interfícies, amb extends JpaRepository<Estacio, Long>, EstacioRepositoriCustom.
Spring Data detecta que cercarAmbFiltres no és un mètode heretat ni derivable, busca una classe anomenada EstacioRepositoriCustomImpl i li delega. El sufix Impl és obligatori —és configurable amb repositoryImplementationPostfix, però no hi ha motiu per canviar-lo— i és l'error número u d'aquest mecanisme: amb qualsevol altre nom, l'arrencada falla amb No property cercarAmbFiltres found for type Estacio.
Fixa't en el valor real d'aquest patró: el client continua veient una sola interfície. EstacioService injecta EstacioRepositori i crida cercarAmbFiltres sense saber que la seva implementació és en una altra classe. La composició és invisible des de fora.
@Repository i la traducció d'excepcions
@Repository i la traducció d'excepcionsNo cal anotar les interfícies amb @Repository. Spring Data les detecta perquè estenen Repository. L'anotació sí que és necessària en classes d'accés a dades escrites a mà, i a EstacioRepositoriCustomImpl és opcional.
El que sí que importa és el que @Repository habilita: la traducció d'excepcions. Un PersistenceExceptionTranslationPostProcessor —un BeanPostProcessor com els de 02-03— embolcalla el bean i converteix les excepcions específiques del proveïdor en la jerarquia DataAccessException de Spring:
| Excepció original | Traduïda a |
|---|---|
ConstraintViolationException (Hibernate) |
DataIntegrityViolationException |
StaleObjectStateException (Hibernate) |
ObjectOptimisticLockingFailureException |
NoResultException (JPA) |
EmptyResultDataAccessException |
PSQLException de connexió |
DataAccessResourceFailureException |
El benefici és real: el teu codi captura excepcions de Spring i no depèn d'Hibernate. Si algun dia canviés la implementació de JPA, els catch continuarien sent vàlids.
A CicloUrbana això s'aprofita al gestor global de 03-06:
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail gestionarViolacioIntegritat(DataIntegrityViolationException ex) {
log.warn("Violació d'integritat: {}", ex.getMostSpecificCause().getMessage());
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"L'operació viola una restricció d'integritat de les dades.");
p.setTitle("Conflicte d'integritat");
p.setProperty("codi", "VIOLACIO_INTEGRITAT");
return p;
}És una xarxa de seguretat, no la via principal: el correcte continua sent comprovar existsByNom abans d'inserir i retornar un 409 explicant quin nom està repetit; aquest gestor cobreix les condicions de cursa que la comprovació prèvia no pot evitar. Cal notar a més que el missatge al client no inclou el detall de l'excepció, que conté noms de taula i de restricció: al registre sí, a la resposta no.
Example i Specification: una primera mirada
Example i Specification: una primera miradaSpring Data ofereix dos mecanismes més per a consultes dinàmiques. Aquí queda la panoràmica; el detall és a 04-06.
Example (consulta per exemple). Construeixes una entitat parcialment omplerta i Spring Data busca les que se li assemblin.
Estacio sonda = new Estacio();
sonda.setActiva(true);
sonda.setCapacitat(24);
ExampleMatcher criteris = ExampleMatcher.matching()
.withIgnoreNullValues()
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING).withIgnoreCase();
List<Estacio> resultat = estacioRepositori.findAll(Example.of(sonda, criteris));És còmode per a filtres d'igualtat simples, i molt limitat: no expressa rangs (capacitat > 20), ni OR, ni condicions sobre associacions. A CicloUrbana no el farem servir.
Specification (API Criteria empaquetada). Cada condició és un objecte componible amb and i or:
public class EstacioSpecs {
public static Specification<Estacio> nomConte(String text) {
return (arrel, consulta, cb) -> text == null ? null
: cb.like(cb.lower(arrel.get("nom")), "%" + text.toLowerCase() + "%");
}
public static Specification<Estacio> capacitatMinima(Integer minim) {
return (arrel, consulta, cb) -> minim == null ? null
: cb.greaterThanOrEqualTo(arrel.get("capacitat"), minim);
}
}// El repositori ha d'estendre també JpaSpecificationExecutor<Estacio>
Page<Estacio> resultat = estacioRepositori.findAll(
EstacioSpecs.nomConte("nord").and(EstacioSpecs.capacitatMinima(20)),
pageable);Retornar null quan el filtre no ve és la clau: Spring Data ignora aquests predicats, així que el cercador d'estacions admet qualsevol combinació de filtres opcionals sense ni un if de concatenació de SQL. És més net que l'EstacioRepositoriCustomImpl de l'apartat 10, i a 04-06 el desenvoluparem.
Errors Comuns i Consells
Ignorar el valor retornat per save(). Amb una entitat separada, save fa merge i retorna una còpia gestionada; l'original continua separada i els seus canvis es perden.
Cridar save() sobre una entitat gestionada. Inofensiu però innecessari: el dirty checking ja genera l'UPDATE. Delata manca de comprensió del context de persistència.
Fer servir findById(id).get(). Llança NoSuchElementException, que sense gestor acaba en un 500 en lloc del 404 correcte. Fes servir orElseThrow amb RecursNoTrobatException.
Retornar Page directament des del controlador. JSON inestable entre versions i detalls del framework al contracte públic. Fes servir un PaginaResponse propi.
Anomenar malament la implementació pròpia. Ha de ser exactament <NomInterficie>Impl i estar al mateix paquet. Amb un altre nom, l'arrencada falla.
Fer servir deleteAll() sobre taules grans. Carrega totes les entitats i emet un DELETE per cadascuna.
Consell: fes servir getReferenceById per assignar claus foranes. En un iniciar lloguer, estalvia dos SELECT a l'operació més freqüent de CicloUrbana.
Consell: prefereix existsById a findById(id).isPresent(). El primer és un count(*); el segon porta tota la fila.
Consell: limita max-page-size. Sense aquest límit, un client pot demanar un milió de registres en una sola petició.
Consell: no exposis deleteById on no hagi d'existir. Estendre Repository i declarar només els mètodes permesos converteix una regla de negoci en una impossibilitat tècnica.
Exercicis
Exercici 1: triar la interfície base
Per a cada repositori de CicloUrbana, tria la interfície base i justifica-ho en una o dues frases.
EstacioRepositori: CRUD complet amb llistats paginats.LloguerRepositori: es creen i es consulten, mai no s'esborren; llistats paginats per usuari.TarifaRepositori: cinc files fixes, carregades en arrencar, només lectura.IncidenciaRepositori: alta, consulta, tancament i esborrat de les que resultin falses.
Exercici 2: paginació completa d'extrem a extrem
Implementa l'endpoint GET /api/v1/lloguers?usuariId=7&page=0&size=20&sort=inici,desc que retorna els lloguers d'un usuari paginats. Escriu el repositori, el servei i el controlador, i explica per què tries Page o Slice.
Exercici 3: diagnosticar tres errades
Aquest servei té tres defectes. Troba'ls, explica el símptoma que produeix cadascun i corregeix-los.
@Service
public class EstacioService {
private final EstacioRepositori repositori;
private final EstacioMapper mapper;
public EstacioService(EstacioRepositori r, EstacioMapper m) {
this.repositori = r; this.mapper = m;
}
public EstacioResponse actualitzar(Long id, ActualitzarEstacioRequest peticio) {
Estacio estacio = repositori.findById(id).get();
estacio.setNom(peticio.nom());
estacio.setCapacitat(peticio.capacitat());
repositori.save(estacio);
return mapper.aResponse(estacio);
}
public void eliminar(Long id) {
repositori.deleteById(id);
}
public Page<EstacioResponse> llistar(Pageable pageable) {
return repositori.findAll(pageable).map(mapper::aResponse);
}
}Solucions
Solució 1.
JpaRepository<Estacio, Long>. Necessita CRUD complet i paginació; és el cas estàndard i no hi ha raó per restringir l'API.Repository<Lloguer, Long>amb mètodes declarats a mà. La regla «un lloguer no s'esborra mai» és de negoci, i la millor manera de garantir-la és no exposar el mètode:
public interface LloguerRepositori extends Repository<Lloguer, Long> {
Lloguer save(Lloguer lloguer);
Optional<Lloguer> findById(Long id);
Page<Lloguer> findByUsuariId(Long usuariId, Pageable pageable);
long countByUsuariIdAndFiIsNull(Long usuariId);
}Sense deleteById a la interfície, ningú no pot esborrar un lloguer per descuit: una regla de negoci convertida en impossibilitat tècnica.
ListCrudRepository<Tarifa, String>, o fins i totRepositoryamb nomésfindAllifindByCodi: cinc files fixes no necessiten paginació, i exposardeleteAllsobre el catàleg de tarifes és un risc innecessari.JpaRepository<Incidencia, Long>: necessita les quatre operacions, inclòs l'esborrat legítim de les falses.
Solució 2.
// Repositori
public interface LloguerRepositori extends JpaRepository<Lloguer, Long> {
Page<Lloguer> findByUsuariId(Long usuariId, Pageable pageable);
}// Servei
@Transactional(readOnly = true)
public PaginaResponse<LloguerResponse> llistarPerUsuari(Long usuariId, Pageable pageable) {
if (!usuariRepositori.existsById(usuariId)) {
throw new RecursNoTrobatException("Usuari", usuariId);
}
Page<LloguerResponse> pagina = lloguerRepositori
.findByUsuariId(usuariId, pageable)
.map(mapper::aResponse);
return PaginaResponse.de(pagina);
}// Controlador
@GetMapping("/api/v1/lloguers")
public PaginaResponse<LloguerResponse> llistar(
@RequestParam Long usuariId,
@PageableDefault(size = 20, sort = "inici",
direction = Sort.Direction.DESC) Pageable pageable) {
return lloguerService.llistarPerUsuari(usuariId, pageable);
}Page o Slice. Per a «els meus lloguers» en una app mòbil amb desplaçament infinit, Slice és millor: estalvia el SELECT count(*) i l'usuari no veu mai el total. Per a un tauler d'administració amb numeració de pàgines, Page és necessari perquè cal pintar «pàgina 3 de 47». Aquí triem Page perquè l'endpoint serveix tots dos consumidors i el volum per usuari és moderat —uns centenars de lloguers—, així que el count és barat; si l'històric creixés a milions de files per usuari, migraríem a Slice o a paginació per cursor (09-01).
Detalls que no cal passar per alt: @Transactional(readOnly = true) permet a Hibernate saltar-se el dirty checking (04-07); el mapatge a DTO passa dins de la transacció, evitant la LazyInitializationException amb open-in-view: false; i es comprova que l'usuari existeix, per retornar 404 en lloc d'una pàgina buida que mentiria sobre l'existència del recurs.
Solució 3. Els tres defectes:
1. findById(id).get(). Si l'estació no existeix, llança NoSuchElementException i el client rep un 500 en lloc del 404 amb ProblemDetail que defineix el contracte de 03-06.
2. Falten les anotacions @Transactional. És el més greu: actualitzar executa findById i save en dues transaccions diferents, així que entremig l'entitat queda separada, es perd el dirty checking, save ha de fer un merge amb el seu SELECT addicional i no hi ha atomicitat si alguna cosa falla pel mig.
3. deleteById sense comprovar l'existència. No llança excepció si l'id no existeix: el client rep un 204 No Content indicant que s'ha esborrat una cosa que mai no va existir. I un quart defecte menor: llistar retorna Page<EstacioResponse> al controlador, amb el problema d'estabilitat del JSON de l'apartat 9. Versió corregida:
@Service
@Transactional(readOnly = true) // per defecte per a tota la classe
public class EstacioService {
// constructor amb EstacioRepositori repositori i EstacioMapper mapper
@Transactional // sobreescriu readOnly: aquest mètode escriu
public EstacioResponse actualitzar(Long id, ActualitzarEstacioRequest peticio) {
Estacio estacio = repositori.findById(id)
.orElseThrow(() -> new RecursNoTrobatException("Estació", id));
estacio.setNom(peticio.nom());
estacio.setCapacitat(peticio.capacitat());
// sense save(): l'entitat està gestionada i el dirty checking fa l'UPDATE
return mapper.aResponse(estacio);
}
@Transactional
public void eliminar(Long id) {
if (!repositori.existsById(id)) {
throw new RecursNoTrobatException("Estació", id);
}
repositori.deleteById(id);
}
public PaginaResponse<EstacioResponse> llistar(Pageable pageable) {
return PaginaResponse.de(repositori.findAll(pageable).map(mapper::aResponse));
}
}El patró @Transactional(readOnly = true) a la classe amb @Transactional als mètodes que escriuen és una convenció excel·lent: per defecte tot és de només lectura i escriure requereix una decisió explícita. Ho desenvoluparem a 04-07.
Conclusió
EstacioRepositoriEnMemoria ja és història. Saps situar cada interfície de la jerarquia de Spring Data i triar amb criteri: JpaRepository com a opció per defecte i Repository amb mètodes declarats a mà quan vols que una regla de negoci —«un lloguer no s'esborra mai»— sigui una impossibilitat tècnica. Entens com apareix la implementació: JpaRepositoryFactoryBean crea un SimpleJpaRepository i l'embolcalla en un proxy dinàmic que implementa la teva interfície, el mateix mecanisme de proxies de 02-03 portat a l'extrem que no hi ha cap classe teva a sota. I has comprovat el resultat del canvi: vuitanta línies de mapa concurrent substituïdes per una interfície de quatre, sense tocar ni una línia d'EstacioController.
Coneixes la semàntica exacta dels mètodes heretats, incloses les tres que sorprenen: save fa merge si l'entitat té id i retorna una instància diferent que cal fer servir; getReferenceById no consulta la base de dades i estalvia dos SELECT cada vegada que CicloUrbana inicia un lloguer; i deleteById no falla quan l'id no existeix, així que el 404 cal provocar-lo comprovant abans. Manegues Optional enllaçant-lo amb RecursNoTrobatException en un orElseThrow que es llegeix de seguit. Has afegit paginació i ordenació a l'API de Ribalta, distingint Page de Slice pel cost del seu SELECT count(*), rebent Pageable amb @PageableDefault i limitant max-page-size perquè cap client no pugui demanar un milió de files. I retornes un PaginaResponse propi en lloc de serialitzar PageImpl, per la mateixa raó per la qual a 03-05 no exposaves entitats. Saps compondre un repositori amb mètodes propis respectant el sufix Impl, aprofitar la traducció d'excepcions de @Repository com a xarxa de seguretat davant de condicions de cursa, i tens una primera visió d'Example i Specification.
El que encara no saps fer és preguntar. Tot el que has consultat ha estat per identificador o la taula sencera. La xarxa de Ribalta necessita molt més: les estacions amb lloc lliure, les bicicletes amb bateria per sota del llindar, els lloguers d'un usuari entre dues dates, les estacions més properes a una coordenada, l'informe d'ocupació en una sola consulta. La lliçó 04-06, Mètodes de Consulta a Spring Data JPA, cobreix tot l'arsenal: les consultes derivades del nom del mètode amb la seva taula exhaustiva de paraules clau, @Query amb JPQL i projeccions a DTO, JOIN FETCH per resoldre d'una vegada el N+1 de 04-04, consultes natives de PostgreSQL per al que JPQL no abasta, @Modifying amb els seus paranys de sincronització, les projeccions per interfície i per record, @EntityGraph i les Specification per al cercador amb filtres opcionals combinables.
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
