Les dues lliçons anteriors van respondre a «està ben feta?» des de dos angles: què convé fer i què convé evitar. Falta el tercer, que no produeix cap error i tanmateix decideix el futur del projecte: com es llegeix.

Un LloguerService pot complir les trenta-vuit comprovacions de 10-01, no contenir ni un dels errors de 10-02 i ser tot i així un mètode de dues-centes línies amb tres banderes booleanes, variables anomenades data i temp, comentaris que repeteixen el que el codi ja diu i un domini que és una bossa de setters. Res d'això no falla avui. El que falla és d'aquí a sis mesos, quan l'ajuntament de Ribalta demani tarifes dinàmiques i ningú no gosi tocar aquell mètode.

Aquesta lliçó tracta d'això, amb un criteri únic que travessa tots els apartats i que convé fixar abans de començar: el destinatari del codi no és el compilador, és el proper que el llegeixi. I aquest proper, sovint, ets tu d'aquí a un any, sense recordar-te de res.

Contingut

  1. Què significa codi net aquí
  2. Noms
  3. Funcions i classes
  4. Una refactorització completa: LloguerService
  5. SOLID a Spring, amb exemples de Ribalta
  6. Comentaris
  7. Gestió d'errors
  8. Immutabilitat
  9. Optional ben fet servir
  10. El model de domini anèmic
  11. Arquitectura verificable amb ArchUnit
  12. Estil i automatització
  13. Refactorització segura
  14. Deute tècnic
  15. Errors Comuns i Consells
  16. Exercicis

  1. Què significa codi net aquí

«Codi net» se sol confondre amb «codi bonic», i no és el mateix. Un criteri operatiu, que es pot aplicar sense discutir de gustos:

Propietat Pregunta que respon Com es comprova
Llegible S'entén què fa sense executar-lo? Algú aliè el llegeix i l'explica
Localitzable Sé on tocar per canviar X? Es busca el lloc d'un canvi hipotètic
Modificable Canviar una cosa obliga a canviar-ne cinc? Es compta el radi d'un canvi real
Comprovable Puc escriure una prova sense acrobàcies? S'intenta escriure-la

La quarta és la més objectiva i la que més informació dona. Si per provar un mètode cal simular mètodes estàtics, instanciar sis col·laboradors o manipular el rellotge del sistema, el problema no és la prova: és el disseny. Va ser la raó d'injectar un Clock des de 02-01 i que SeguretatLloguers sigui un bean normal en lloc d'una expressió SpEL de tres línies. I un advertiment sobre l'abast: res d'aquesta lliçó no és qüestió d'estil personal —la indentació, les claus i la longitud de línia es resolen amb una eina a l'apartat 12 i no es discuteixen a les revisions—. El que segueix és disseny.

  1. Noms

Anomenar és l'activitat més freqüent en programar i la que més rendiment dona per minut invertit.

2.1. La convenció del curs

CicloUrbana té una regla explícita que convé enunciar perquè no és universal: el domini s'anomena en català —Estacio, Bicicleta, Lloguer, calcularImportTotal, bicicletesDisponibles— i només es conserva l'anglès en el que pertany al framework o a una llibreria: anotacions, tipus de Spring, findById, Pageable. El motiu no és patriòtic, és de llenguatge ubic: l'ajuntament de Ribalta parla d'estacions, ancoratges i tarifes, i quan el codi fa servir aquestes mateixes paraules, la traducció mental entre la conversa i el programa desapareix. El que cal evitar a tota costa és l'híbrid: EstacioService.getEstacioByNom() obliga a canviar d'idioma dues vegades en una línia.

Element Convenció Exemple de CicloUrbana
Classe Substantiu, PascalCase LloguerService, SelectorTarifa
Interfície Substantiu o capacitat, sense prefix I CalculadoraTarifa, ValidacioLloguer
Mètode Verb en infinitiu calcular, cercarAmbDisponibilitat, esPropietari
Booleà es..., te..., esPot... esPlena(), esPotLlogar()
Variable i constant Substantiu concret; MAJUSCULES_AMB_GUIO ancoratgesLliures, MINUTS_GRATIS
Paquet Minúscules, funcionalitat com.ciclourbana.lloguers
Prova Frase que descriu la regla ambQuinzeMinutsExactesElLloguerContinuaSentGratuit

2.2. Noms que revelen intenció

La diferència entre un nom i un bon nom és si obliga a llegir la implementació.

// ❌ Cal llegir el cos per saber què retorna
public List<Estacio> getData(int x) { ... }
public List<Estacio> proces2(boolean b) { ... }

// ✅ El nom és la documentació
public List<Estacio> cercarAmbDisponibilitat(int bicicletesMinimes) { ... }
public List<Estacio> cercarOperativesEnHoraPunta() { ... }

getData() falla en tres fronts: no diu quines dades, no diu d'on surten i no diu segons quin criteri es filtren. cercarAmbDisponibilitat(int bicicletesMinimes) respon als tres, i a més el nom del paràmetre converteix el número de la crida en informació: cercarAmbDisponibilitat(3) es llegeix «estacions amb almenys tres bicicletes».

Quatre regles pràctiques: res d'abreviatures llevat de les universalment conegudes (id, url, http); res de números als noms, perquè proces1 i proces2 volen dir que no saps què els distingeix; el nom indica el tipus de retorn —el que comença per es o esPot retorna boolean, el que comença per cercar pot no trobar i retorna Optional o llista—; i el mateix concepte, sempre la mateixa paraula: si és cercar, no és obtenir a la classe del costat.

  1. Funcions i classes

3.1. Un sol nivell d'abstracció

La regla més útil sobre la mida d'una funció no és «menys de vint línies», sinó: totes les sentències d'un mètode han d'estar al mateix nivell de detall.

// ❌ Barreja "què es fa" amb "com es fa"
public LloguerResponse iniciar(IniciarLloguerRequest peticio) {
    Bicicleta bicicleta = bicicletaRepositori.bloquejarMillorDisponible(
            peticio.estacioOrigenId(), xarxa.llindarBateria())
            .orElseThrow(() -> new BicicletaNoDisponibleException(peticio.estacioOrigenId()));
    if (lloguerRepositori.countByUsuariIdAndFiIsNull(peticio.usuariId()) >= 1) {
        throw new ReglaNegociException("LLOGUER_EN_CURS", "Ja tens un lloguer obert");
    }
    bicicleta.setEstat(EstatBicicleta.EN_US);
    // ... vint línies més al mateix nivell de detall
}

// ✅ El mètode públic explica la història; els privats la detallen
public LloguerResponse iniciar(IniciarLloguerRequest peticio) {
    validarQueNoTeLloguerObert(peticio.usuariId());
    Bicicleta bicicleta = reservarMillorBicicleta(peticio.estacioOrigenId());
    Lloguer lloguer = registrarLloguer(peticio, bicicleta);
    esdeveniments.publishEvent(new LloguerIniciat(lloguer.getId(), instantActual()));
    return lloguerMapper.aResposta(lloguer);
}

El mètode públic es llegeix com un paràgraf i s'entén en cinc segons. Qui necessiti el detall baixa un nivell; qui només vulgui saber què passa, no hi baixa.

3.2. Arguments, i per què les banderes booleanes són un problema

Zero, un o dos arguments és l'ideal; tres és acceptable si pertanyen al mateix concepte; amb quatre o més gairebé sempre falta un objecte que els agrupi.

Una bandera booleana és un argument que fa que el mètode faci dues coses diferents, i té tres defectes: a la crida no es llegeix res (finalitzar(9L, true) no diu què és true), obliga a un if a dins que separa dos fluxos que ja són dos mètodes, i creix: demà hi ha dues banderes i quatre combinacions, de les quals dues no tenen sentit.

// ❌ Què significa aquest true a la crida?
public LloguerResponse finalitzar(Long id, boolean aplicarPenalitzacio) { ... }
finalitzar(9L, true);

// ✅ Dos mètodes amb nom
public LloguerResponse finalitzar(Long id) { ... }
public LloguerResponse finalitzarPerCaducitat(Long id) { ... }   // aplica la penalització

3.3. Responsabilitat única, aplicada de debò

«Una classe, una responsabilitat» se cita molt i s'aplica malament, perquè «responsabilitat» és vague. La formulació operativa és millor: una classe ha de tenir una sola raó per canviar, és a dir, un sol interlocutor que pugui demanar modificar-la. Si LloguerService canvia quan l'ajuntament revisa les tarifes, quan facturació canvia el format de la factura i quan el proveïdor de correu canvia la seva API, té tres raons per canviar i tres persones diferents demanant-les. L'apartat següent ho arregla.

  1. Una refactorització completa: LloguerService

Partim d'una versió real i versemblant de LloguerService.finalitzar: funciona, passa les proves i fa massa coses.

// ❌ Abans: 5 responsabilitats en un mètode
@Transactional
public LloguerResponse finalitzar(Long id, FinalitzarLloguerRequest peticio) {
    Lloguer lloguer = lloguerRepositori.findById(id).orElseThrow();
    if (lloguer.getFi() != null) throw new ConflicteRecursException("Ja ha finalitzat");
    Estacio desti = estacioRepositori.findById(peticio.estacioDestiId()).orElseThrow();
    if (bicicletaRepositori.countByEstacioId(desti.getId()) >= desti.getCapacitat()) {
        throw new EstacioPlenaException(desti.getId());
    }

    // Càlcul de l'import, reimplementat a mà dins del servei
    long minuts = Duration.between(lloguer.getInici(), Instant.now()).toMinutes();
    BigDecimal importTotal;
    if (lloguer.getUsuari().getTipusTarifa() == TipusTarifa.ESTUDIANT) {
        importTotal = new BigDecimal("0.08").multiply(BigDecimal.valueOf(Math.max(0, minuts - 15)));
    } else if (lloguer.getUsuari().getTipusTarifa() == TipusTarifa.JUBILAT) {
        importTotal = new BigDecimal("0.05").multiply(BigDecimal.valueOf(Math.max(0, minuts - 30)));
    } else {
        importTotal = new BigDecimal("0.50")
                .add(new BigDecimal("0.12").multiply(BigDecimal.valueOf(Math.max(1, minuts))));
    }
    importTotal = importTotal.setScale(2, RoundingMode.HALF_UP);
    if (minuts > 120) importTotal = importTotal.add(new BigDecimal("5.00"));   // recàrrec per excés

    lloguer.setFi(Instant.now());                      // mutació a base de setters
    lloguer.setEstacioDesti(desti);
    lloguer.setImportTotal(importTotal);
    lloguer.setEstat(EstatLloguer.FINALITZAT);
    lloguer.getBicicleta().setEstat(EstatBicicleta.DISPONIBLE);
    lloguer.getBicicleta().setEstacio(desti);

    correu.enviarResum(lloguer.getUsuari().getCorreu(), importTotal);   // dins de la transacció
    metriques.registrarFinalitzacio(lloguer);
    return new LloguerResponse(lloguer.getId(), /* ... 8 camps més ... */);
}

Les cinc responsabilitats són cinc raons diferents per canviar aquesta classe, i cadascuna té un interlocutor diferent: validar les regles de finalització (l'ajuntament), calcular l'import (el departament de tarifes), aplicar el recàrrec per excés (l'ajuntament, en una altra reunió), mutar l'estat del lloguer i la bicicleta (l'equip de domini) i notificar i mesurar (màrqueting i operacions).

Pas 1: extreure el càlcul de tarifa on ja existia

L'if/else if/else reimplementa el que SelectorTarifa i les tres CalculadoraTarifa de 02-02 ja feien. És duplicació pura, i pitjor: una segona font de veritat que pot divergir de la primera. Les quinze línies es converteixen en selectorTarifa.perUsuari(lloguer.getUsuari()).calcular(durada).

Pas 2: el recàrrec és una tarifa, no un if

L'if (minuts > 120) és una regla de negoci amagada en un servei. Com que el sistema de tarifes ja és extensible per disseny, el recàrrec encaixa com a decorador:

package com.ciclourbana.lloguers;

/** Recàrrec per excés de durada sobre qualsevol tarifa base. Decorador: només coneix
 *  el contracte, no la tarifa concreta. */
public record TarifaAmbRecarrecPerExces(CalculadoraTarifa base, Duration duradaMaxima,
                                        BigDecimal recarrec) implements CalculadoraTarifa {
    @Override
    public BigDecimal calcular(Duration durada) {
        BigDecimal importTotal = base.calcular(durada);
        return durada.compareTo(duradaMaxima) > 0 ? importTotal.add(recarrec) : importTotal;
    }

    @Override
    public String nom() { return base.nom() + "-amb-recarrec"; }
}

Ara el recàrrec es prova sol, es combina amb qualsevol tarifa present o futura i es configura per propietats. LloguerService deixa de saber que existeix.

Pas 3: el comportament, al domini

Els sis setters consecutius són el símptoma del model anèmic de l'apartat 10. L'operació «finalitzar un lloguer» pertany a Lloguer, que és qui coneix els seus invariants:

// A l'entitat Lloguer:
/** Tanca el lloguer. Invariant: un lloguer finalitzat no torna a finalitzar-se. */
public void finalitzar(Estacio desti, Instant fi, BigDecimal importTotal) {
    if (this.estat == EstatLloguer.FINALITZAT) {
        throw new ConflicteRecursException("El lloguer " + id + " ja ha finalitzat");
    }
    this.fi = fi;
    this.estacioDesti = desti;
    this.importTotal = importTotal;
    this.estat = EstatLloguer.FINALITZAT;
    this.bicicleta.ancorarA(desti);            // la bicicleta gestiona el seu propi estat
}

Cal notar el que s'hi guanya: la comprovació de «ja ha finalitzat» deixa de dependre que el servei se'n recordi. Qualsevol camí que cridi finalitzar l'aplica.

Pas 4: els efectes secundaris, fora de la transacció

El correu i la mètrica se separen a un esdeveniment en AFTER_COMMIT, com ja vam establir a Transaccions i Tasques Programades i Asincronia.

El resultat

// ✅ Després: una responsabilitat i zero regles de negoci amagades
@Transactional
public LloguerResponse finalitzar(Long id, FinalitzarLloguerRequest peticio) {
    Lloguer lloguer = cercarEnCurs(id);
    Estacio desti = cercarEstacioAmbEspaiLliure(peticio.estacioDestiId());

    Instant fi = Instant.now(rellotge);
    BigDecimal importTotal = selectorTarifa.perUsuari(lloguer.getUsuari())
            .calcular(Duration.between(lloguer.getInici(), fi));

    lloguer.finalitzar(desti, fi, importTotal);   // el domini protegeix les seves regles

    esdeveniments.publishEvent(new LloguerFinalitzat(lloguer.getId(), importTotal, fi));
    return lloguerMapper.aResposta(lloguer);
}
Abans Després
Línies del mètode / raons per canviar 38 / 5 11 / 1
Regles de negoci al servei 3 0
Prova del recàrrec Amb context i dades Unitària d'1 ms
Afegir una tarifa nova Tocar aquest mètode Una classe nova

I la condició sense la qual res d'això no es fa: les proves del mòdul 6 estaven en verd abans de començar i van continuar en verd després de cada pas. Sense elles, aquesta refactorització és una reescriptura a cegues.

  1. SOLID a Spring, amb exemples de Ribalta

Principi Què diu On es veu a CicloUrbana
SRP — responsabilitat única Una sola raó per canviar La refactorització de l'apartat 4; SeguretatLloguers separat de LloguerService
OCP — obert/tancat Obert a extensió, tancat a modificació CalculadoraTarifa: afegir TarifaJubilat no toca SelectorTarifa, que les injecta en una List
LSP — substitució de Liskov Un subtipus ha de poder substituir el tipus base sense sorpreses Qualsevol CalculadoraTarifa ha de retornar un import no negatiu; una que llancés excepció amb durada zero trencaria tots els seus consumidors
ISP — segregació d'interfícies Millor diverses interfícies petites que una de gran Els repositoris: EstacioRepositori exposa el d'estacions i res més; i Pageable/Sort en lloc d'un mètode amb vuit paràmetres
DIP — inversió de dependències Dependre d'abstraccions, no d'implementacions És literalment el que fa el contenidor: LloguerService declara CalculadoraTarifa i Spring decideix quina injectar

Dos malentesos que convé aclarir. DIP no vol dir «una interfície per classe»: vol dir que la dependència apunta cap a l'abstracció quan hi ha una frontera real. LloguerService depèn de CalculadoraTarifa perquè hi ha tres implementacions i n'hi haurà més; una ILloguerService amb una sola implementació és cerimònia sense benefici. I LSP és el més ignorat i el que produeix fallades més rares: el seu incompliment típic no és d'herència, sinó de contracte —una implementació que retorna null on les altres retornen llista buida, o que llança excepció on les altres retornen zero—, i el consumidor, escrit contra el comportament de la primera, es trenca amb la segona.

  1. Comentaris

Un comentari és un deute de manteniment: no el comprova el compilador, no l'executa cap prova i envelleix en silenci. Per això el criteri és exigent.

Comentari Val la pena?
Repeteix el que el codi diu No. // incrementa i sobre i++
Explica què fa un mètode llarg No. Extreu un mètode amb nom
Explica per què es va prendre una decisió no òbvia Sí. El més valuós
Documenta una restricció externa Sí. «El proveïdor limita a 100 peticions/min»
Adverteix d'un parany Sí. «No convertir en private: es perd el proxy»
Codi comentat, o // TODO sense data ni responsable No. Per a això hi ha el control de versions i el gestor de tasques
// ❌ Soroll: diu el que ja es llegeix
// Comprovem si l'estació és plena
if (bicicletes.size() >= estacio.getCapacitat()) { ... }

// ✅ Explica el que el codi no pot dir
// L'ajuntament exigeix deixar sempre un ancoratge lliure per a la furgoneta de
// manteniment (conveni del 2026), d'aquí el -1 i no la capacitat completa.
if (bicicletes.size() >= estacio.getCapacitat() - 1) { ... }

El javadoc que val la pena és el de les fronteres: interfícies públiques, contractes de domini i qualsevol mètode el comportament del qual no sigui evident en els casos límit —CalculadoraTarifa.calcular documenta què passa amb durada zero, perquè això no és a la signatura—. En canvi, un /** Retorna el nom. @return el nom */ sobre getNom() només afegeix línies.

  1. Gestió d'errors

Excepcions específiques del domini, no genèriques. throw new RuntimeException("Error") obliga qui la captura a llegir el missatge per saber què va passar. La jerarquia de 03-06 —CicloUrbanaException amb RecursNoTrobatException, ConflicteRecursException, EstacioPlenaException i BicicletaNoDisponibleException— permet que el @RestControllerAdvice tradueixi cadascuna al seu codi HTTP sense ni un if sobre cadenes.

No facis servir excepcions per al flux normal. «No hi ha bicicletes disponibles» en una estació concorreguda no és excepcional: passa cent vegades al dia. Una excepció costa construir la traça de pila i, sobretot, comunica una cosa equivocada al lector; si el cas és esperable, retorna Optional o un tipus resultat. I no capturis Exception: atrapa també el que no saps gestionar i ho converteix en un log. Captura el que pots tractar, i deixa pujar la resta.

// ❌ S'empassa tot, inclosos els errors que haurien d'arribar al gestor global
try { ... } catch (Exception e) { log.error("Error", e); }

// ✅ Tracta el previst, rellança com a excepció del domini
try {
    passarelaPagament.cobrar(importTotal);
} catch (PagamentRebutjatException e) {
    throw new ReglaNegociException("PAGAMENT_REBUTJAT", "El pagament ha estat rebutjat", e);
}

Missatges útils. "Error" no serveix de res; "L'estació 3 és plena: 18 bicicletes per a 18 ancoratges" diu què va passar, amb quines dades, i permet reproduir-ho. Amb una condició que no es pot oblidar: el missatge que va al client i el que va al log no són el mateix. Al log, el detall; al client, la versió sense informació interna.

  1. Immutabilitat

Un objecte immutable no pot estar en un estat invàlid després de construir-se, és segur entre fils sense raonar res i no pot canviar entre el moment en què es valida i el moment en què es fa servir —una font real de vulnerabilitats—.

record per a DTOs i objectes de valor. Tots els DTOs de CicloUrbana ho són, amb el constructor compacte com a lloc natural per normalitzar; i també els objectes de valor del domini, com UbicacioResponse. Col·leccions immutables als retorns, perquè retornar la llista interna permet que qui la rep la modifiqui per l'esquena:

// ❌ Qui la rep pot fer estacio.getBicicletes().clear()
public List<Bicicleta> getBicicletes() { return bicicletes; }

// ✅ Vista de només lectura; les modificacions passen per mètodes amb nom
public List<Bicicleta> getBicicletes() { return List.copyOf(bicicletes); }
public void ancorar(Bicicleta bicicleta) { /* valida capacitat i afegeix */ }

I el límit honest: les entitats JPA no poden ser immutables. Hibernate necessita un constructor sense arguments i modifica l'estat en carregar i en aplicar canvis. La resposta no és forçar la immutabilitat, sinó controlar la mutació: camps privats, sense setters públics indiscriminats, i mètodes amb nom de negoci —finalitzar, ancorarA, marcarAvariada— que són els únics que canvien l'estat i que poden protegir els invariants.

  1. Optional ben fet servir

Optional es va introduir per a un cas concret —el retorn d'un mètode que pot no trobar res— i es fa servir sovint per a tres més, on fa nosa.

Ús Correcte? Per què
Tipus de retorn Sí És el seu propòsit: obliga qui el crida a decidir què fa si no hi ha valor
Paràmetre de mètode No Qui el crida ha d'embolcallar; i hi ha tres estats possibles: null, buit i present
Camp d'una classe No No és serialitzable, ocupa memòria extra i complica l'enllaç de Jackson i JPA
Col·lecció buida No Una llista buida ja expressa «no hi ha res»; Optional<List<T>> és redundant

I l'antipatró més freqüent de tots: .get() sense comprovar, que converteix un cas previst en una NoSuchElementException amb 500 i traça al log.

// ❌ Un 500 en lloc d'un 404
Estacio estacio = estacioRepositori.findById(id).get();

// ✅ L'absència es tradueix a una excepció del domini
Estacio estacio = estacioRepositori.findById(id)
        .orElseThrow(() -> new RecursNoTrobatException("Estació", id));

orElseThrow, orElseGet, map, filter i ifPresent cobreixen pràcticament tot. Un detall que es passa per alt: orElse avalua sempre el seu argument, fins i tot quan hi ha valor, així que si construir l'alternativa és car ha de ser orElseGet amb un proveïdor mandrós.

  1. El model de domini anèmic

Un model anèmic és aquell en què les entitats només tenen dades —camps, getters i setters— i tota la lògica viu als serveis. És l'estil per defecte de la majoria dels projectes Spring, i mereix una discussió honesta perquè no sempre està malament.

El cas a favor de posar comportament al domini. Compara les dues maneres de finalitzar un lloguer:

// ❌ El servei manipula l'estat amb setters: els invariants viuen al servei
lloguer.setFi(fi);
lloguer.setEstacioDesti(desti);
lloguer.setImportTotal(importTotal);
lloguer.setEstat(EstatLloguer.FINALITZAT);

// ✅ L'entitat protegeix les seves pròpies regles
lloguer.finalitzar(desti, fi, importTotal);

Quatre diferències que importen. L'invariant viatja amb la dada: la comprovació de «ja ha finalitzat» s'aplica per qualsevol camí, no només pel que se'n va recordar d'escriure-la. L'entitat no pot quedar a mitges: amb setters públics, un mètode pot fixar l'import i oblidar l'estat, i res no ho impedeix. El nom és en el llenguatge de l'ajuntament, no en el de la base de dades. I es pot provar sense res: Lloguer.finalitzar és una prova unitària d'un mil·lisegon.

El cas a favor del model anèmic existeix i no convé menysprear-lo:

Situació Per què l'anèmic és acceptable
CRUD sense regles Si «actualitzar l'adreça d'una estació» és assignar un camp, embolcallar-ho no afegeix res
La lògica necessita col·laboradors Calcular l'import requereix SelectorTarifa; una entitat JPA no hauria d'injectar serveis
La regla creua diversos agregats «L'usuari no pot tenir dos lloguers oberts» no cap dins de Lloguer
Equip sense experiència en DDD Un domini ric mal fet —entitats amb repositoris a dins— és pitjor que un d'anèmic

El criteri pràctic de CicloUrbana, que és un punt intermedi defensable: les regles que depenen només de l'estat del mateix agregat van a l'entitat —finalitzar, esPotLlogar, esPlena, ancorarA—; les que necessiten col·laboradors o creuen agregats van al servei —seleccionar la tarifa, verificar que no hi ha un altre lloguer obert, comprovar permisos—. Amb aquesta divisió, Lloguer no coneix cap repositori i LloguerService no manipula estat a base de setters.

  1. Arquitectura verificable amb ArchUnit

Les regles de dependència de 10-01 —el controlador no toca el repositori, el domini no importa el framework— són acords que s'incompleixen tard o d'hora si depenen que algú se n'adoni en una revisió. ArchUnit les converteix en proves.

Amb la dependència com.tngtech.archunit:archunit-junit5:1.3.0 en àmbit test, les regles s'escriuen com a camps estàtics anotats amb @ArchTest:

package com.ciclourbana;

import com.tngtech.archunit.junit.*;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.ciclourbana",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ReglesArquitecturaTest {

    /** Regla 1: els controladors parlen amb serveis, mai amb repositoris. */
    @ArchTest
    static final ArchRule elsControladorsNoAccedeixenARepositoris =
            noClasses().that().haveSimpleNameEndingWith("Controller")
                    .should().dependOnClassesThat().haveSimpleNameEndingWith("Repositori")
                    .because("el controlador tradueix HTTP; les regles viuen al servei");

    /** Regla 2: les entitats no surten del paquet del seu agregat. */
    @ArchTest
    static final ArchRule lesEntitatsNoSurtenDelSeuPaquet =
            classes().that().areAnnotatedWith(jakarta.persistence.Entity.class)
                    .should().onlyBeAccessed().byClassesThat()
                    .resideInAnyPackage("com.ciclourbana.(**)", "com.ciclourbana.comu..")
                    .because("el contracte públic s'expressa amb DTOs, no amb entitats");

    /** Regla 3: res del domini no coneix la capa web. */
    @ArchTest
    static final ArchRule elDominiNoConeixElServlet =
            noClasses().that().resideInAPackage("..lloguers..")
                    .and().haveSimpleNameNotEndingWith("Controller")
                    .should().dependOnClassesThat().resideInAPackage("jakarta.servlet..")
                    .because("la lògica de lloguers no depèn que l'entrada sigui HTTP");

    /** Regla 4: sense cicles entre els mòduls de negoci. */
    @ArchTest
    static final ArchRule senseCiclesEntreModuls =
            slices().matching("com.ciclourbana.(*)..").should().beFreeOfCycles();

    /** Regla 5: el parany del proxy de 10-02, convertit en prova. */
    @ArchTest
    static final ArchRule lesAnotacionsDeProxyVanEnMetodesPublics =
            methods().that().areAnnotatedWith(Transactional.class).should().bePublic()
                    .because("CGLIB no intercepta mètodes no públics");
}

Cinc regles, un fitxer, i a partir d'aquell moment ./mvnw test es posa en vermell el dia que algú les incompleixi, amb un missatge que diu quina classe, quina dependència i —gràcies al because— per què està prohibida. És la diferència entre una convenció documentada i una convenció que es compleix. Tres consells perquè la inversió no es giri en contra: comença per tres o quatre regles, les que de debò fan mal si es trenquen; escriu sempre el because, perquè el missatge de fallada és l'única cosa que veurà qui la incompleixi d'aquí a un any; i si una regla necessita excepcions, declara-les explícitament en lloc d'esborrar la regla sencera.

  1. Estil i automatització

L'estil —on van les claus, quants espais, l'ordre dels import, la longitud de línia— és la discussió amb pitjor relació entre temps invertit i valor obtingut de tota l'enginyeria de programari. La solució no és acordar un estil: és delegar-lo en una eina.

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.44.0</version>
    <configuration>
        <java>
            <palantirJavaFormat/>            <!-- formatatge determinista -->
            <removeUnusedImports/>
            <importOrder><order>java,javax,jakarta,org,com,</order></importOrder>
            <trimTrailingWhitespace/>
            <endWithNewline/>
        </java>
    </configuration>
    <!-- Enganxat a la fase validate: falla si alguna cosa no està formatada -->
    <executions><execution><phase>validate</phase>
        <goals><goal>check</goal></goals></execution></executions>
</plugin>

./mvnw spotless:apply formata tot el projecte i ./mvnw spotless:check falla si alguna cosa no ho està, cosa que executa la canonada de 08-05 a cada canvi.

Eina Què comprova Quan s'executa
Spotless Format: espais, import, salts A validate, i amb apply en local
Checkstyle Convencions: noms, mida de mètode, javadoc A CI
SpotBugs / Error Prone Errors probables: equals incoherent, comparacions sospitoses A CI
SonarQube Anàlisi global, deute, duplicació, cobertura Nocturn o per pull request
ArchUnit Regles d'arquitectura (apartat 11) Amb les proves

Per què l'estil no es discuteix a les revisions. Un comentari que diu «falten dos espais» consumeix l'atenció que hauria d'anar a la lògica, genera fricció i no aporta res que una eina no faci de franc. Amb Spotless a validate, el codi arriba ja formatat i la revisió s'ocupa del que cap eina no detecta: si el disseny és correcte, si el nom revela la intenció i si falta un cas límit. I l'ordre importa en adoptar-ho: primer s'aplica el formatador a tot el projecte en un commit dedicat, sense cap canvi funcional, i només després s'activa la comprovació; barrejar reformatatge i lògica produeix diferències irrevisables.

  1. Refactorització segura

Refactoritzar és canviar l'estructura interna sense canviar el comportament observable, i la definició conté el seu propi requisit: per saber que el comportament no ha canviat, cal poder comprovar-ho.

flowchart LR
    A["Proves en verd<br/>ABANS de tocar res"] --> B["Un canvi petit<br/>i reversible"]
    B --> C["Proves en verd"]
    C -->|"Sí"| D["Commit"]
    C -->|"No"| E["Desfer i<br/>fer-ho més petit"]
    D --> B

Les quatre regles, en ordre d'importància:

  1. Si no hi ha proves, la primera tasca és escriure-les. No les de tot el sistema: les del comportament que mouràs. S'anomenen proves de caracterització i afirmen el que el codi fa avui, incloses les seves raresses. Sense elles no estàs refactoritzant, estàs reescrivint.
  2. Un pas cada vegada, amb les proves entremig. La refactorització de l'apartat 4 van ser quatre passos, cadascun amb la suite en verd. Si el pas 3 trenqués alguna cosa, sabries exactament quin va ser.
  3. No barregis refactorització i funcionalitat al mateix commit. Un canvi que mou dues-centes línies i a més arregla una fallada és irrevisable: ningú no pot distingir el moviment del canvi.
  4. Confia en l'IDE per al que és mecànic. Reanomenar, extreure mètode o classe, canviar signatura i introduir paràmetre són transformacions que l'IDE fa sense equivocar-se; fer-les amb cerca-i-substitució és on apareixen els errors.

Quan refactoritzar. La resposta sostenible no és «reservem un sprint»: és la regla del campament, deixar el codi una mica millor de com el vas trobar cada vegada que el toques per un altre motiu. Un sprint de refactorització competeix amb funcionalitat i sempre perd; una millora de deu minuts dins d'una tasca que ja estaves fent, no competeix amb res.

  1. Deute tècnic

La metàfora és exacta i per això funciona: prens prestat temps avui i pagues interessos cada vegada que toques aquell codi. Com la financera, hi ha deute raonable i deute ruïnós.

Tipus Exemple a CicloUrbana Acceptable?
Deliberat i prudent «Sortim amb mapatge manual; migrarem a MapStruct en arribar a 20 DTOs» Sí, si està registrat
Deliberat i imprudent «No hi ha temps per a proves» No: els interessos són immediats
Accidental i prudent «Ara entenem que el mapejador no hauria de consultar repositoris» Inevitable i sa: és aprenentatge
Accidental i imprudent Ningú no sabia que @Transactional no funciona en autoinvocació Formació, revisió i les regles de l'apartat 11

Com es reconeix. Quatre senyals: una estimació que creix sense que el requisit creixi; un fitxer que apareix al 80 % dels commits; una part del codi que «només toca en tal»; i la frase «no ho toquis, que funciona», reconeixement explícit que ningú no ho entén.

Com es registra. Un // TODO sense data ni responsable és decoració: als sis mesos n'hi ha cent quaranta i ningú no els llegeix. El que sí que funciona:

// DEUTE-2026-03: el mapejador consulta repositoris i provoca N+1 en llistats.
// Impacte: p95 de GET /api/v1/lloguers. Sortida: que el servei retorni
// un agregat amb els noms ja resolts. Estimat: 1 dia. Fitxa: CU-412.

La diferència amb un TODO és que té destinatari, cost i impacte, que és la informació necessària per prioritzar-lo davant d'una funcionalitat; i va acompanyat d'una fitxa al mateix sistema on viu la resta de la feina, perquè un deute que només existeix al codi no es planifica mai.

Quan es paga. Tres moments, i cap no és «quan hi hagi temps»: quan tocaràs aquella zona per un altre motiu —l'interès es paga sol—; quan bloqueja alguna cosa que l'ajuntament sí que vol; i quan el cost dels interessos supera el de l'amortització, cosa que es detecta mesurant, no discutint. El deute que no molesta ningú pot quedar-se: no tot el millorable mereix ser millorat.

Errors Comuns i Consells

Confondre codi net amb codi «elegant». Una cadena de cinc stream() imbricats pot ser molt enginyosa i il·legible. Si cal llegir-la tres vegades, un bucle amb noms clars és millor codi.

Refactoritzar sense proves. És reescriure amb un altre nom. Si no hi ha proves, escriu-les abans: primer les de caracterització, després el canvi. I aplicar SOLID com a ritual —una interfície per classe, un mapejador per DTO, una fàbrica per servei— no és disseny: és cerimònia. Cada abstracció ha d'estar pagant alguna cosa concreta.

Comentar el que es pot anomenar. Si necessites un comentari per explicar què fa un bloc, gairebé sempre el que necessites és extreure aquell bloc a un mètode amb aquell nom. I deixar codi comentat «per si de cas» només genera dubtes sobre si hauria d'estar actiu: és al control de versions.

Confondre el model ric amb ficar repositoris a les entitats. Una entitat que injecta un repositori és pitjor que el model anèmic: barreja persistència i domini i fa impossible provar-la aïlladament.

Consell: la millor prova de llegibilitat és llegir-ho en veu alta. Si lloguer.finalitzar(desti, fi, importTotal) es llegeix com una frase de l'ajuntament i processar(a, true, 2) no, ja tens la resposta sense discutir d'estil.

Consell: escriu el codi pensant en qui l'esborrarà. El codi fàcil d'esborrar —fronteres clares, poques dependències entrants— és el mateix que és fàcil de canviar: si eliminar TarifaJubilat és esborrar una classe, el disseny és bo.

Consell: la revisió de codi s'ocupa del que cap eina no veu. Spotless mira el format, ArchUnit les dependències, SpotBugs els errors probables i JaCoCo la cobertura. El que queda per a les persones és si el nom revela la intenció, si l'abstracció és la correcta i si falta un cas límit. Això és exactament on una revisió aporta valor.

Exercicis

Exercici 1: netejar un servei d'incidències

Refactoritza aquesta classe aplicant el que hem vist: noms, nivell d'abstracció, banderes booleanes, Optional, gestió d'errors i model de domini. Justifica cada canvi.

@Service
public class IncService {

    @Transactional
    public Object proc(Long id, boolean tancar, boolean notificar) throws Exception {
        Incidencia i = repo.findById(id).get();
        if (tancar) {
            i.setEstat("TANCADA");
            i.setDataTancament(new Date());
            if (i.getTipus().equals("BATERIA")) {
                i.getBicicleta().setEstat("DISPONIBLE");
                i.getBicicleta().setNivellBateria(100);
            }
        } else {
            i.setEstat("OBERTA");
        }
        repo.save(i);
        if (notificar) {
            try { correu.enviar(i.getAutor().getCorreu(), "Incidència " + id); }
            catch (Exception e) { }
        }
        return i;
    }
}

Exercici 2: regles d'ArchUnit per a Ribalta

Escriu quatre regles d'ArchUnit que protegeixin decisions preses al llarg del curs, diferents de les cinc de l'apartat 11. Per a cadascuna, indica quina decisió protegeix, en quina lliçó es va prendre i quin missatge donaria en fallar.

Exercici 3: anèmic o ric?

Per a cadascuna d'aquestes sis regles de negoci de CicloUrbana, decideix si ha de viure a l'entitat o al servei, i justifica-ho amb el criteri de l'apartat 10: (1) una bicicleta amb menys del 20 % de bateria no es pot llogar; (2) un usuari no pot tenir dos lloguers oberts alhora; (3) una estació és plena quan les seves bicicletes ancorades igualen la seva capacitat; (4) l'import depèn del tipus de tarifa de l'usuari; (5) un lloguer finalitzat no pot tornar a finalitzar-se; (6) només un operari pot marcar una bicicleta com a avariada.

Solucions

Solució 1

@Service
@Transactional(readOnly = true)
public class IncidenciaService {   // constructor omès: repositori, esdeveniments, mapper, rellotge

    /** Tanca la incidència i torna la bicicleta al servei si escau. */
    @Transactional
    public IncidenciaResponse tancar(Long idIncidencia, String resolucio) {
        Incidencia incidencia = cercar(idIncidencia);
        incidencia.tancar(resolucio, Instant.now(rellotge));   // el domini decideix
        esdeveniments.publishEvent(new IncidenciaTancada(idIncidencia,
                incidencia.getAutor().getCorreu()));
        return incidenciaMapper.aResposta(incidencia);
    }

    /** Reobre una incidència tancada per error. Mètode a part, no una bandera. */
    @Transactional
    public IncidenciaResponse reobrir(Long idIncidencia, String motiu) { ... }

    private Incidencia cercar(Long id) {
        return incidenciaRepositori.findById(id)
                .orElseThrow(() -> new RecursNoTrobatException("Incidència", id));
    }
}

// A l'entitat Incidencia:
/** Tanca la incidència. Si era de bateria, la bicicleta torna al servei. */
public void tancar(String resolucio, Instant moment) {
    if (this.estat == EstatIncidencia.TANCADA) {
        throw new ConflicteRecursException("La incidència " + id + " ja estava tancada");
    }
    this.estat = EstatIncidencia.TANCADA;
    this.dataTancament = moment;
    this.resolucio = resolucio;
    if (this instanceof IncidenciaBateria) {
        bicicleta.tornarAlServei();              // la bicicleta gestiona el seu estat
    }
}

Els deu canvis, justificats.

# Canvi Motiu
1 IncService → IncidenciaService, proc → tancar/reobrir Un nom abreujat no estalvia res i costa una consulta al lector
2 Les dues banderes booleanes passen a ser dos mètodes proc(9L, true, false) no es llegeix; i de les quatre combinacions, dues no tenien sentit
3 Object → IncidenciaResponse Object renuncia al tipatge; retornar l'entitat seria la fuita de 03-05
4 throws Exception desapareix; .get() → orElseThrow La jerarquia del domini hereta de RuntimeException i provoca rollback; i l'absència dona un 404, no un 500
5 String → enum a estat i tipus equals("BATERIA") falla en silenci amb un error tipogràfic; l'enum no compila
6 new Date() → Instant.now(rellotge) Determinisme: la prova controla el temps amb Clock.fixed
7 repo.save(i) desapareix L'entitat està gestionada; el dirty checking genera l'UPDATE
8 El correu passa a un esdeveniment en AFTER_COMMIT No reté la connexió durant la crida de xarxa i no anuncia el que es pot desfer
9 El catch (Exception e) { } buit desapareix És el pitjor fragment de la classe: descarta la fallada sense deixar rastre

I el canvi de fons: la lògica de tancament s'ha mogut a Incidencia, on l'invariant «no es tanca dues vegades» s'aplica per qualsevol camí.

Solució 2

/** Decisió (03-05, 04-07): el servei retorna DTOs, mai entitats. */
@ArchTest
static final ArchRule elsServeisNoRetornenEntitats =
        noMethods().that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Service")
                .and().arePublic()
                .should().haveRawReturnType(describe("una entitat JPA",
                        c -> c.isAnnotatedWith(jakarta.persistence.Entity.class)))
                .because("amb open-in-view: false provoca LazyInitializationException");

/** Decisió (03-05): els DTOs són record immutables. */
@ArchTest
static final ArchRule elsDtosSonRecord =
        classes().that().resideInAPackage("..dto..").should().beRecords()
                .because("un DTO mutable pot canviar entre validar-se i fer-se servir");

/** Decisió (09-05): el log el governa Logback, mai System.out. */
@ArchTest
static final ArchRule senseSortidaEstandardDirecta =
        noClasses().should().accessField(System.class, "out")
                .because("un println no porta rastreId, ni nivell, ni format JSON");

/** Decisió (02-02): injecció per constructor amb camps final. */
@ArchTest
static final ArchRule senseInjeccioPerCamp =
        noFields().should().beAnnotatedWith(Autowired.class)
                .because("impedeix final i obliga a reflexió per construir en una prova");

Per què aquestes quatre i no unes altres: les quatre protegeixen decisions que s'incompleixen per descuit i no produeixen cap error immediat. Una regla d'ArchUnit sobre alguna cosa que ja falla en compilar no aporta res; el seu valor és exactament en els acords silenciosos. I el because no és decoratiu: és l'única cosa que veurà d'aquí a dos anys qui la incompleixi, així que convé incloure-hi la raó concreta o la lliçó, no un «està prohibit».

Solució 3

# Regla On viu Justificació
1 Bateria mínima per llogar Entitat Bicicleta.esPotLlogar(llindar) Depèn només de l'estat propi. El llindar entra com a paràmetre des de XarxaProperties, així que l'entitat no necessita conèixer la configuració
2 Un sol lloguer obert per usuari Servei Creua agregats i necessita consultar el repositori: Lloguer no pot saber quants altres lloguers existeixen
3 Estació plena Entitat Estacio.esPlena() Comparació entre dues dades del mateix agregat. És el cas més clar dels sis
4 Import segons tarifa Servei Requereix el col·laborador SelectorTarifa. Ficar-ho a Lloguer obligaria a injectar un servei en una entitat, cosa pitjor que el model anèmic
5 No finalitzar dues vegades Entitat Lloguer.finalitzar(...) És un invariant de l'agregat, i posar-lo a l'entitat garanteix que cap camí no se'l pugui saltar
6 Només un operari marca avariada Servei, amb @PreAuthorize És autorització, no domini. Depèn de l'usuari autenticat, un concepte aliè a Bicicleta

El patró que emergeix, i que és la resposta de l'exercici: la regla viu a l'entitat quan es pot avaluar amb el que l'entitat ja té al davant. Tan bon punt cal consultar una altra cosa —un altre agregat, un servei, l'usuari autenticat— puja al servei. El cas 1 sol generar discussió, perquè es podria argumentar que el llindar és configuració i per tant la regla és del servei; passar-lo com a argument manté l'entitat neta i la regla al costat de la dada. Quan dubtis, la pregunta útil és: puc provar aquesta regla amb un new i res més? Si la resposta és sí, pot viure a l'entitat.

Conclusió

El codi de CicloUrbana ja no només funciona i evita els errors coneguts: es pot llegir. I el criteri que ho governa tot és un de sol, el que obria la lliçó: el destinatari no és el compilador, és el proper que el llegeixi, amb la propietat més objectiva —la comprovabilitat— com a termòmetre: si provar alguna cosa exigeix acrobàcies, el problema és el disseny.

Saps anomenar amb la convenció del curs —domini en català, framework en anglès, sense híbrids— i distingir getData() de cercarAmbDisponibilitat(int bicicletesMinimes), que respon a què, d'on i segons quin criteri. Escrius funcions amb un sol nivell d'abstracció, on el mètode públic explica la història i els privats la detallen, i saps per què una bandera booleana és en realitat dos mètodes que encara no s'han separat. Has vist la refactorització completa de LloguerService.finalitzar en quatre passos —extreure el càlcul a SelectorTarifa, convertir el recàrrec en el decorador TarifaAmbRecarrecPerExces, moure el comportament a Lloguer.finalitzar(...) i treure els efectes secundaris a l'AFTER_COMMIT—, de trenta-vuit línies i cinc raons per canviar a onze línies i una, amb la condició innegociable de tenir les proves en verd abans i després de cada pas.

Tens SOLID aterrat a Ribalta amb els seus dos malentesos aclarits —DIP no és una interfície per classe, i LSP s'incompleix gairebé sempre per contracte i no per herència—; el criteri per als comentaris, que documenten el perquè i mai el què; la gestió d'errors amb excepcions del domini, sense fer-les servir per al flux normal, sense capturar Exception i amb missatges diferents per al log i per al client; la immutabilitat amb record i col·leccions de només lectura, i el seu límit honest a les entitats JPA, on la resposta no és la immutabilitat sinó la mutació controlada per mètodes amb nom de negoci; i Optional al seu únic lloc correcte, el retorn, amb orElseThrow substituint el .get() que converteix un 404 en un 500. I tens la discussió honesta sobre el model anèmic, amb el criteri de CicloUrbana com a punt intermedi defensable: les regles que depenen només de l'estat del mateix agregat van a l'entitat, les que necessiten col·laboradors o creuen agregats van al servei.

Tanquen la lliçó les tres peces que converteixen tot l'anterior en alguna cosa que se sosté sola: ArchUnit, que transforma les regles de dependència en proves que es posen en vermell amb un missatge que explica el perquè; Spotless, Checkstyle i l'anàlisi estàtica, que treuen l'estil de les revisions perquè aquestes s'ocupin del que cap eina no veu; i la refactorització segura recolzada en el mòdul 6, amb la regla del campament com a forma sostenible de millorar i amb el deute tècnic registrat amb destinatari, cost i impacte en lloc d'amb TODO decoratius —i amb el permís explícit de deixar sense pagar el deute que no molesta ningú—.

Amb això es tanca la reflexió sobre com està feta CicloUrbana. Durant deu mòduls hem construït la xarxa de Ribalta capa per capa i mai no l'hem mirada sencera. La lliçó Projecte Final: Recorregut Complet de CicloUrbana fa exactament això: l'arquitectura final en un diagrama, l'estructura completa del repositori, el recorregut d'un POST /api/v1/lloguers pas a pas des del balancejador fins a la mètrica —citant a cada pas la lliçó on es va estudiar—, el mapa de què va construir cada mòdul, les decisions de disseny amb les seves alternatives i les seves contrapartides, el pom.xml i l'application.yml finals comentats, com posar en marxa el projecte sencer des de zero, i cap a on continuar ampliant-lo.

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