Fa quatre lliçons que anem deixant etiquetes TODO pel camí. Els 404 es construeixen a mà a cada controlador amb ResponseEntity.notFound(). Les regles de negoci de 03-03 llancen IllegalStateException que es converteixen en errors del servidor amb la traça de pila a dins. La ConstraintViolationException de 03-04 surt com a 500 quan la culpa és del client. I els errors de validació responen "Invalid request content." sense dir quin camp ha fallat. Tot això se salda en aquesta lliçó. Construirem la jerarquia d'excepcions de CicloUrbana, la centralitzarem en un únic gestor global, adoptarem el format estàndard RFC 7807 Problem Details que Spring Boot 3 suporta de manera nativa, i ens assegurarem que cap error no filtri informació que no ha de sortir del servidor.

Contingut

  1. El comportament per defecte de Spring Boot
  2. Per què no serveix per a una API pública
  3. La jerarquia d'excepcions de CicloUrbana
  4. @ResponseStatus a l'excepció i els seus límits
  5. @ExceptionHandler local al controlador
  6. @RestControllerAdvice: el gestor global
  7. RFC 7807: Problem Details
  8. Errors de validació amb la llista de camps
  9. Excepcions del framework
  10. No filtrar informació sensible
  11. Què registrar al log segons la gravetat
  12. Identificador de rastreig a la resposta
  13. Errors Comuns i Consells
  14. Exercicis

  1. El comportament per defecte de Spring Boot

Spring Boot no deixa mai una excepció sense resposta. Quan una s'escapa del controlador, el DispatcherServlet —el recorregut del qual vam dibuixar a 03-01— la passa per una cadena de HandlerExceptionResolver. Si cap no la reconeix, la petició es reenvia internament a /error, atès pel BasicErrorController. Si hi afegeixes un endpoint de prova que llança IllegalStateException, la resposta és aquesta:

{ "timestamp": "2026-09-01T09:14:22.481+00:00", "status": 500,
  "error": "Internal Server Error", "path": "/api/v1/estacions/prova-error" }

Aquest format es controla amb quatre propietats:

server:
  error:
    include-message: always          # never | always | on-param (per defecte: never)
    include-binding-errors: always   # errors de validació a la resposta
    include-stacktrace: never        # MAI en producció
    include-exception: false         # nom de la classe de l'excepció
    path: /error                     # ruta interna del BasicErrorController
Propietat Per defecte Risc si s'activa
include-message never Pot exposar detalls interns del missatge
include-binding-errors never Baix: són dades que el client ha enviat
include-stacktrace never Alt: revela classes, rutes i versions
include-exception false Mitjà: revela la implementació interna

Amb include-message i include-stacktrace a always, la resposta passa a incloure el missatge complet i la traça de pila sencera: noms de paquet, versions de llibreries, línies de codi. És informació d'or per a qui busqui vulnerabilitats. include-stacktrace ha de ser never en producció, sense excepcions.

  1. Per què no serveix per a una API pública

Encara que afinem aquestes propietats, la gestió per defecte té quatre problemes que no s'arreglen amb configuració. Tot és 500: IllegalStateException, IllegalArgumentException i NoSuchElementException surten igual, encara que unes siguin culpa del client i altres nostres, i això arruïna el monitoratge —el sistema d'alertes del mòdul 9 despertarà algú de matinada per un 404—. El client no pot programar contra l'error, perquè no hi ha un codi estable que distingir, només un text en anglès que pot canviar en qualsevol versió. No hi ha context útil: "Bad Request" no diu quin camp estava malament. I el format no és un estàndard, així que cada client escriu el seu propi analitzador.

El que necessita una API pública és que cada tipus de fallada tingui un codi HTTP correcte, un identificador estable, un missatge útil i un format uniforme. Això és el que construirem.

  1. La jerarquia d'excepcions de CicloUrbana

Tota excepció del domini hereta d'una base comuna, a com.ciclourbana.comu.excepcions:

/**
 * Base de totes les excepcions de negoci de CicloUrbana.
 *
 * És RuntimeException (no comprovada) deliberadament: obligar a declarar
 * throws a cada signatura contamina el codi sense aportar seguretat, perquè
 * ningú no es pot recuperar d'aquestes fallades llevat del gestor global.
 */
public abstract class CicloUrbanaException extends RuntimeException {

    /** Codi estable que el client pot fer servir a la seva lògica. */
    private final String codi;

    protected CicloUrbanaException(String codi, String missatge) {
        super(missatge);
        this.codi = codi;
    }

    public String getCodi() {
        return codi;
    }
}

El camp codi és el que fa l'API programable: el client reacciona a ESTACIO_PLENA sense dependre del text del missatge, que es pot traduir o reescriure sense trencar res. Les excepcions concretes:

/** El recurs sol·licitat no existeix. Mapeja a 404. */
public class RecursNoTrobatException extends CicloUrbanaException {
    public RecursNoTrobatException(String tipusRecurs, Object id) {
        super("RECURS_NO_TROBAT",
              "No s'ha trobat %s amb identificador %s".formatted(tipusRecurs, id));
    }
}

/** Regla de negoci violada (422) i conflicte amb un altre recurs (409). */
public class ReglaNegociException extends CicloUrbanaException {
    public ReglaNegociException(String codi, String missatge) { super(codi, missatge); }
}
public class ConflicteRecursException extends CicloUrbanaException {
    public ConflicteRecursException(String codi, String missatge) { super(codi, missatge); }
}

/** Especialitzacions amb el missatge construït. */
public class EstacioPlenaException extends ConflicteRecursException {
    public EstacioPlenaException(String nom, int capacitat) {
        super("ESTACIO_PLENA", "L'estació %s no té ancoratges lliures (capacitat %d)"
                .formatted(nom, capacitat));
    }
}
public class BicicletaNoDisponibleException extends ReglaNegociException {
    public BicicletaNoDisponibleException(String matricula, EstatBicicleta estat) {
        super("BICICLETA_NO_DISPONIBLE",
              "La bicicleta %s no està disponible (estat: %s)".formatted(matricula, estat));
    }
}

El mapatge complet a codis HTTP:

Excepció HTTP codi Quan es llança
RecursNoTrobatException 404 RECURS_NO_TROBAT Estació o lloguer inexistent
ConflicteRecursException 409 variable Nom d'estació duplicat
EstacioPlenaException 409 ESTACIO_PLENA Retornar a una estació plena
ReglaNegociException 422 variable Lloguer ja finalitzat
BicicletaNoDisponibleException 422 BICICLETA_NO_DISPONIBLE Bicicleta en manteniment
MethodArgumentNotValidException 400 VALIDACIO @Valid falla al cos
ConstraintViolationException 400 VALIDACIO @Validated falla en un paràmetre
HttpMessageNotReadableException 400 COS_ILLEGIBLE JSON mal format
Qualsevol altra 500 ERROR_INTERN Fallada no prevista

Amb això, els serveis deixen de llançar IllegalStateException:

// A LloguerService, substituint el codi provisional de 03-03
Lloguer lloguer = lloguerRepositori.cercarPerId(lloguerId)
        .orElseThrow(() -> new RecursNoTrobatException("lloguer", lloguerId));
if (lloguer.estat() == EstatLloguer.FINALITZAT) {
    throw new ReglaNegociException("LLOGUER_JA_FINALITZAT",
            "El lloguer %d ja estava finalitzat".formatted(lloguerId));
}
Estacio desti = estacioRepositori.cercarPerId(estacioDestiId)
        .orElseThrow(() -> new RecursNoTrobatException("estació", estacioDestiId));
if (bicicletaRepositori.comptarPerEstacio(desti.id()) >= desti.capacitat()) {
    throw new EstacioPlenaException(desti.nom(), desti.capacitat());
}

Fixa't en el patró orElseThrow: converteix un Optional buit en l'excepció adequada en una sola expressió, i és la raó per la qual els repositoris retornen Optional i no null.

  1. @ResponseStatus a l'excepció i els seus límits

La manera més ràpida d'associar un codi HTTP a una excepció és anotar-la amb @ResponseStatus(HttpStatus.NOT_FOUND). Spring la detecta amb ResponseStatusExceptionResolver i respon 404. Funciona, és una línia, i té tres límits seriosos:

Límit Conseqüència
L'estat és fix La mateixa excepció no pot donar 409 en un cas i 422 en un altre
No controla el cos La resposta la continua generant BasicErrorController
Només serveix per a excepcions pròpies No pots anotar ConstraintViolationException, d'una llibreria

Per aquests motius, CicloUrbana no fa servir @ResponseStatus a les seves excepcions: el mapatge complet viu al gestor global, en un sol lloc que es llegeix d'un cop d'ull.

Existeix una variant interessant, ResponseStatusException, que duu l'estat a dins: throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Estació no trobada"). És còmoda per a prototips, però acobla la capa de servei a l'API web —HttpStatus és una classe de Spring Web— i no permet el camp codi. El curs l'esmenta i no l'adopta.

  1. @ExceptionHandler local al controlador

Un mètode anotat amb @ExceptionHandler dins d'un controlador captura les excepcions d'aquell controlador:

// Dins d'EstacioController, al costat dels endpoints
@ExceptionHandler(EstacioPlenaException.class)
public ProblemDetail gestionarEstacioPlena(EstacioPlenaException e) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}

El seu abast limitat és alhora la seva virtut i el seu defecte: serveix per a una excepció específica d'un controlador concret, però repetir-ho a cada classe multiplica el codi, així que CicloUrbana fa servir el gestor global i reserva els locals per a casos genuïnament particulars. Una dada de precedència que convé retenir: el gestor local sempre guanya al global quan tots dos capturen la mateixa excepció.

  1. @RestControllerAdvice: el gestor global

@RestControllerAdvice és @ControllerAdvice + @ResponseBody: una classe els @ExceptionHandler de la qual s'apliquen a tots els controladors.

package com.ciclourbana.comu;

@RestControllerAdvice
public class GestorGlobalExcepcions {

    private static final Logger log = LoggerFactory.getLogger(GestorGlobalExcepcions.class);
    private static final URI BASE_TIPUS = URI.create("https://api.ciclourbana.example/errors/");

    @ExceptionHandler(RecursNoTrobatException.class)
    public ProblemDetail gestionarNoTrobat(RecursNoTrobatException e,
                                           HttpServletRequest peticio) {
        log.info("Recurs no trobat a {}: {}", peticio.getRequestURI(), e.getMessage());
        return construir(HttpStatus.NOT_FOUND, "Recurs no trobat", e);
    }

    @ExceptionHandler(ConflicteRecursException.class)
    public ProblemDetail gestionarConflicte(ConflicteRecursException e,
                                            HttpServletRequest peticio) {
        log.warn("Conflicte a {}: {}", peticio.getRequestURI(), e.getMessage());
        return construir(HttpStatus.CONFLICT, "Conflicte amb l'estat actual", e);
    }

    @ExceptionHandler(ReglaNegociException.class)
    public ProblemDetail gestionarReglaNegoci(ReglaNegociException e,
                                              HttpServletRequest peticio) {
        log.warn("Regla violada a {}: {}", peticio.getRequestURI(), e.getMessage());
        return construir(HttpStatus.UNPROCESSABLE_ENTITY, "Operació no permesa", e);
    }

    /** Xarxa de seguretat. Si es dispara, és un bug: per això ERROR amb la traça. */
    @ExceptionHandler(Exception.class)
    public ProblemDetail gestionarInesperat(Exception e, HttpServletRequest peticio) {
        String idRastre = UUID.randomUUID().toString();
        log.error("Error inesperat [rastre={}] a {}", idRastre, peticio.getRequestURI(), e);

        ProblemDetail problema = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                // Missatge GENÈRIC: no es filtra e.getMessage()
                "S'ha produït un error intern. Contacta amb suport indicant el rastre.");
        problema.setTitle("Error intern");
        problema.setType(BASE_TIPUS.resolve("error-intern"));
        problema.setProperty("codi", "ERROR_INTERN");
        problema.setProperty("rastre", idRastre);
        return problema;
    }

    /** Construcció comuna de la resposta Problem Details. */
    private ProblemDetail construir(HttpStatus estat, String titol, CicloUrbanaException e) {
        ProblemDetail problema = ProblemDetail.forStatusAndDetail(estat, e.getMessage());
        problema.setTitle(titol);
        problema.setType(BASE_TIPUS.resolve(e.getCodi().toLowerCase().replace('_', '-')));
        problema.setProperty("codi", e.getCodi());
        problema.setProperty("marcaTemps", Instant.now().toString());
        return problema;
    }
}

Tres detalls importants. Retornar ProblemDetail directament n'hi ha prou: Spring el reconeix, fixa l'estat a partir del camp status i posa Content-Type: application/problem+json, sense necessitat de ResponseEntity. El gestor d'Exception no filtra e.getMessage(): el missatge real va al log i al client només hi arriba un text genèric amb un identificador de rastreig (apartat 10). I l'ordre de resolució és per especificitat del tipus, no per ordre de declaració: si es llança EstacioPlenaException, Spring tria el gestor de ConflicteRecursException —la seva superclasse més propera amb gestor— i no el d'Exception, que per això es pot declarar sense por que es mengi els altres.

Quan hi ha diversos @RestControllerAdvice —per exemple, un per al domini i un altre que aporti Spring Security al mòdul 5—, el desempat sí que depèn de l'ordre, i es controla amb @Order: @Order(Ordered.HIGHEST_PRECEDENCE) al gestor de seguretat i @Order(Ordered.LOWEST_PRECEDENCE) al general, que actua de xarxa de seguretat. @RestControllerAdvice accepta a més basePackages, assignableTypes o annotations per limitar-ne l'abast, útil si conviuen una API pública i un tauler intern amb formats d'error diferents.

Amb el gestor en marxa, els controladors se simplifiquen. L'obtenirPerId de 03-02 perd el seu ResponseEntity i el seu .map(...).orElseGet(...):

@GetMapping("/{id:\\d+}")
public EstacioDetallResponse obtenirPerId(@PathVariable("id") @Positive Long id) {
    Estacio estacio = estacioService.cercarPerId(id)
            .orElseThrow(() -> new RecursNoTrobatException("estació", id));
    return estacioMapper.aDetall(estacio, bicicletaService.cercarPerEstacio(id));
}

El TODO que hi vam deixar queda saldat: el camí d'error ja no ocupa espai al camí feliç.

  1. RFC 7807: Problem Details

L'RFC 7807 —actualitzat per l'RFC 9457— defineix un format estàndard per als errors HTTP, i Spring Boot 3 el suporta de manera nativa amb la classe ProblemDetail. Els camps de l'estàndard:

Camp Significat
type URI que identifica el tipus de problema; serveix de documentació
title Resum llegible i estable per a aquest tipus
status El codi HTTP, repetit al cos
detail Explicació específica d'aquesta ocurrència
instance URI de l'ocurrència concreta
(extensions) Camps propis: codi, rastre, errors...

La distinció entre title i detail és la que més es confon: title és fix per al tipus de problema ("Recurs no trobat") i detail canvia a cada ocurrència ("No s'ha trobat estació amb identificador 999"). Un client pot agrupar errors per type i mostrar detail a l'usuari.

La resposta que produeix el nostre gestor davant de GET /api/v1/estacions/999 és un 404 amb Content-Type: application/problem+json i aquest cos:

{ "type": "https://api.ciclourbana.example/errors/recurs-no-trobat",
  "title": "Recurs no trobat", "status": 404,
  "detail": "No s'ha trobat estació amb identificador 999",
  "instance": "/api/v1/estacions/999",
  "codi": "RECURS_NO_TROBAT", "marcaTemps": "2026-09-01T09:22:41.117Z" }

Content-Type: application/problem+json el posa Spring sol en detectar un ProblemDetail. És el tipus MIME que vam anunciar a la taula de 03-01.

Spring Boot ofereix a més una activació global sense escriure gestors:

spring:
  mvc:
    problemdetails:
      enabled: true    # les excepcions de Spring MVC surten com a Problem Details

Fa que les excepcions del framework mateix —MethodArgumentNotValidException, HttpRequestMethodNotSupportedException, NoResourceFoundException— produeixin Problem Details en lloc del format de BasicErrorController. No cobreix les pròpies, per a les quals continua calent el gestor, però dona coherència als errors que no gestiones explícitament.

Existeixen a més ErrorResponseException, una excepció de Spring que ja duu un ProblemDetail a dins i permet llançar un error completament format des de qualsevol punt, i ResponseEntityExceptionHandler, una classe base amb gestors preescrits per a totes les excepcions de Spring MVC que pots estendre i sobreescriure mètode a mètode: dona més cobertura de sortida a canvi de menys control.

  1. Errors de validació amb la llista de camps

Recuperem el deute de 03-04: una MethodArgumentNotValidException conté un BindingResult amb tots els camps que han fallat, i cal extreure'ls i posar-los a la resposta.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail gestionarValidacioCos(MethodArgumentNotValidException e) {

    // Un camp pot tenir diversos errors: s'agrupen per nom de camp
    Map<String, List<String>> errors = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.groupingBy(FieldError::getField, LinkedHashMap::new,
                    Collectors.mapping(DefaultMessageSourceResolvable::getDefaultMessage,
                                       Collectors.toList())));

    // Errors de restriccions de classe (@CoordenadesValides sense camp associat)
    List<String> globals = e.getBindingResult().getGlobalErrors().stream()
            .map(DefaultMessageSourceResolvable::getDefaultMessage).toList();

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "La petició conté %d camp(s) amb errors".formatted(errors.size()));
    problema.setTitle("Error de validació");
    problema.setType(BASE_TIPUS.resolve("validacio"));
    problema.setProperty("codi", "VALIDACIO");
    problema.setProperty("errors", errors);
    if (!globals.isEmpty()) {
        problema.setProperty("errorsGlobals", globals);
    }
    return problema;
}

/** @Validated sobre paràmetres: excepció diferent, mateixa forma de resposta. */
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail gestionarValidacioParametres(ConstraintViolationException e) {

    Map<String, List<String>> errors = e.getConstraintViolations().stream()
            .collect(Collectors.groupingBy(v -> ultimNode(v.getPropertyPath()),
                    LinkedHashMap::new,
                    Collectors.mapping(ConstraintViolation::getMessage, Collectors.toList())));

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Paràmetres de la petició no vàlids");
    problema.setTitle("Error de validació");
    problema.setType(BASE_TIPUS.resolve("validacio"));
    problema.setProperty("codi", "VALIDACIO");
    problema.setProperty("errors", errors);
    return problema;
}

/** El path arriba com a "llistar.mida"; al client només li interessa "mida". */
private String ultimNode(Path ruta) {
    String complet = ruta.toString();
    return complet.substring(complet.lastIndexOf('.') + 1);
}

El resultat, amb la mateixa petició que a 03-04 retornava "Invalid request content.":

{ "type": "https://api.ciclourbana.example/errors/validacio",
  "title": "Error de validació", "status": 400,
  "detail": "La petició conté 2 camp(s) amb errors",
  "instance": "/api/v1/estacions", "codi": "VALIDACIO",
  "errors": {
    "nom": ["El nom de l'estació és obligatori",
            "El nom ha de tenir entre 3 i 80 caràcters"],
    "capacitat": ["La capacitat ha de ser més gran que zero"] } }

Ara el tauler de Ribalta pot ressaltar els dos camps i mostrar els seus missatges al costat de cadascun. Observa que nom acumula dos errors: l'agrupació en List<String> no en perd cap, mentre que un Map<String, String> n'hauria descartat un en silenci. I amb això se salda també el parany del 500 de 03-04: ConstraintViolationException ja respon 400.

  1. Excepcions del framework

A més de les pròpies, cal gestionar les que llança Spring quan la petició està mal formada:

Excepció Causa Estat
HttpMessageNotReadableException JSON mal format o cos absent 400
MethodArgumentTypeMismatchException /estacions/abc amb id de tipus Long 400
MissingServletRequestParameterException Falta un @RequestParam obligatori 400
HttpRequestMethodNotSupportedException DELETE sobre la col·lecció 405
HttpMediaTypeNotSupportedException S'envia XML on s'espera JSON 415
HttpMediaTypeNotAcceptableException Accept que no podem satisfer 406
NoResourceFoundException Ruta inexistent (Spring Boot 3.2+) 404
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail gestionarCosIllegible(HttpMessageNotReadableException e) {
    // COMPTE: e.getMessage() inclou un fragment del JSON rebut i la classe
    // Java destí. És informació interna: no es retorna al client.
    log.warn("Cos il·legible: {}", e.getMessage());

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "El cos de la petició no és un JSON vàlid");
    problema.setTitle("Cos il·legible");
    problema.setType(BASE_TIPUS.resolve("cos-illegible"));
    problema.setProperty("codi", "COS_ILLEGIBLE");
    return problema;
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail gestionarTipusIncorrecte(MethodArgumentTypeMismatchException e) {
    String tipusEsperat = e.getRequiredType() != null
            ? e.getRequiredType().getSimpleName() : "desconegut";

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "El paràmetre '%s' ha de ser de tipus %s".formatted(e.getName(), tipusEsperat));
    problema.setTitle("Tipus de paràmetre incorrecte");
    problema.setProperty("codi", "TIPUS_INCORRECTE");
    problema.setProperty("parametre", e.getName());
    return problema;
}

NoResourceFoundException (amb spring.mvc.throw-exception-if-no-handler-found: true) i HttpRequestMethodNotSupportedException segueixen el mateix patró; l'exercici 3 completa aquesta última amb la capçalera Allow. Ara aquella petició del .http de 03-02 que responia un 400 incomprensible dona alguna cosa útil davant de ?capacitatMinima=molt:

{ "type": "about:blank", "title": "Tipus de paràmetre incorrecte", "status": 400,
  "detail": "El paràmetre 'capacitatMinima' ha de ser de tipus Integer",
  "codi": "TIPUS_INCORRECTE", "parametre": "capacitatMinima" }

  1. No filtrar informació sensible

Un gestor d'errors és una superfície d'atac: cada dada que retorna és una dada que algú pot fer servir contra tu. Què no ha de sortir mai:

Dada Per què és perillosa
Traça de pila Revela classes, versions de llibreries i estructura del codi
Missatges de la base de dades Filtren taules i columnes; ajuden a injeccions SQL
Rutes del sistema de fitxers Revelen el sistema operatiu i el desplegament
Noms de classe Java com.ciclourbana.passarela.ClientRedsys diu què fas servir
Adreces internes i ports Faciliten el moviment lateral a la xarxa
El valor rebutjat per una validació Pot ser la contrasenya acabada d'escriure

Els tres primers arriben gairebé sempre per la mateixa via: retornar e.getMessage() d'una excepció que no controles. Amb ProblemDetail.forStatusAndDetail(INTERNAL_SERVER_ERROR, e.getMessage()) i una fallada de base de dades, l'API respon alguna cosa com "could not execute statement; SQL [insert into estacions ...]; constraint [uk_estacions_nom]", que revela l'esquema complet. El gestor d'Exception de l'apartat 6 fa el correcte: missatge genèric a fora, detall complet al log, i un identificador de rastreig que uneix tots dos.

La regla: el missatge de les excepcions pròpies pot sortir, perquè l'hem escrit nosaltres pensant en el client; el de les alienes, mai.

Un cas subtil: l'enumeració de recursos. Si GET /api/v1/usuaris/1 respon 404 i GET /api/v1/usuaris/2 respon 403, un atacant dedueix que l'usuari 2 existeix; per a recursos sensibles el correcte és respondre 404 en tots dos casos. No s'aplica a les estacions de Ribalta, que són informació pública, però sí als usuaris, i ho reprendrem al mòdul 5.

  1. Què registrar al log segons la gravetat

Registrar-ho tot amb log.error inutilitza el log: quan tot és un error, res no ho és.

Situació Nivell Traça Raó
404 de recurs INFO No Funcionament normal
Validació fallida INFO No El client s'ha equivocat; és esperable
409 / 422 de negoci WARN No Esperat, però pot assenyalar un client amb errors
401 / 403 WARN No Pot ser un intent d'accés indegut
Dependència externa caiguda ERROR Sí Requereix intervenció
Excepció no prevista ERROR Sí És un bug: cal arreglar-lo

Dues regles pràctiques. La traça de pila només a ERROR: un 404 amb vint línies de traça multiplica la mida del log sense aportar res. I registrar l'excepció com a últim argument, no concatenada:

log.error("Error inesperat [rastre={}] a {}", idRastre, uri, e);   // BÉ: traça completa
log.error("Error inesperat: " + e.getMessage());                   // MALAMENT: perd la causa

SLF4J tracta l'últim argument Throwable de manera especial i imprimeix la traça completa, incloses les causes encadenades; concatenar perd exactament la informació que es necessita per diagnosticar. Els logs en profunditat arriben a 09-05.

  1. Identificador de rastreig a la resposta

Quan un operari de Ribalta truca dient "m'ha donat un error en finalitzar el lloguer", la pregunta és quin dels centenars d'errors del log és el seu. L'identificador de rastreig respon a això: un valor únic que apareix alhora a la resposta del client i a la línia del log. La versió artesanal és la que ja tenim: un UUID.randomUUID() al gestor d'Exception. Només cobreix els errors i no relaciona entre si les línies de log d'una mateixa petició. La versió bona fa servir l'MDC (Mapped Diagnostic Context) d'SLF4J, que associa dades al fil actual i fa que totes les línies de log d'una petició duguin el mateix identificador:

package com.ciclourbana.comu;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)     // abans que qualsevol altre filtre
public class FiltreRastreig extends OncePerRequestFilter {

    public static final String CLAU_MDC = "rastreId";
    private static final String CAPCALERA = "X-Rastre-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest peticio,
                                    HttpServletResponse resposta,
                                    FilterChain cadena) throws ServletException, IOException {

        // Si un servei anterior ja ha enviat un identificador, es reutilitza:
        // així el rastre sobreviu entre serveis (es completa a 09-06)
        String rastre = Optional.ofNullable(peticio.getHeader(CAPCALERA))
                .filter(s -> !s.isBlank())
                .orElseGet(() -> UUID.randomUUID().toString().substring(0, 8));

        MDC.put(CLAU_MDC, rastre);
        resposta.setHeader(CAPCALERA, rastre);    // el client sempre el rep
        try {
            cadena.doFilter(peticio, resposta);
        } finally {
            // IMPRESCINDIBLE: els fils de Tomcat es reutilitzen. Sense aquest
            // remove, la petició següent heretaria el rastre de l'anterior.
            MDC.remove(CLAU_MDC);
        }
    }
}

Aquest MDC.remove al finally no és opcional: sense ell, una petició que falli deixa el seu identificador enganxat al fil i la petició següent atesa per aquest fil escriu al log un rastre que no li correspon. És un error difícil de diagnosticar precisament perquè corromp l'eina de diagnòstic.

S'inclou al patró de log amb %X{rastreId:-sense-rastre} —%X llegeix l'MDC i :-sense-rastre és el valor per defecte—, i al gestor n'hi ha prou amb problema.setProperty("rastre", MDC.get(FiltreRastreig.CLAU_MDC)):

logging:
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%X{rastreId:-sense-rastre}] %logger{36} - %msg%n"

El resultat, al log del servidor i a la resposta que rep l'operari:

09:22:41.117 WARN [a3f5c9e1] c.c.c.GestorGlobalExcepcions - Conflicte a
/api/v1/lloguers/7/finalitzar: L'estació Universitat no té ancoratges lliures
{ "type": "https://api.ciclourbana.example/errors/estacio-plena",
  "title": "Conflicte amb l'estat actual", "status": 409,
  "detail": "L'estació Universitat no té ancoratges lliures (capacitat 36)",
  "codi": "ESTACIO_PLENA", "rastre": "a3f5c9e1" }

Amb a3f5c9e1 es localitza al log la petició exacta i totes les seves línies. Aquest identificador és la llavor de la traçabilitat distribuïda de 09-06, on el rastre continuarà viu a través de diversos serveis.

Errors Comuns i Consells

Deixar include-stacktrace: always en producció. És la fuita d'informació més comuna en aplicacions Spring Boot. Ha de ser never.

Capturar Exception al controlador amb un try/catch. Anul·la el gestor global i escampa la lògica d'errors. Deixa que l'excepció pugi.

Retornar e.getMessage() d'una excepció aliena. És la via per la qual surten esquemes de base de dades i rutes del sistema.

Registrar-ho tot amb log.error. Un 404 no és un error del servidor. Quan tot és ERROR, les alertes deixen de significar res.

Oblidar MDC.remove(). El rastre es filtra a la petició següent del mateix fil i corromp el log just quan més el necessites.

Fer servir @ResponseStatus i el gestor global alhora. Tenir el mapatge en dos llocs acaba en incoherències; el curs tria el gestor. I fer servir Map<String, String> per als errors de validació perd en silenci els errors addicionals d'un mateix camp: fes servir Map<String, List<String>>.

Consell: escriu primer la taula d'excepció a codi HTTP, que és l'especificació del gestor, i prova cada branca d'error afegint al fitxer .http una petició per tipus; al mòdul 6 aquestes peticions es converteixen en proves d'integració que impedeixen que un refactor trenqui el contracte d'errors.

Exercicis

Exercici 1: Excepció de duplicat amb context

EstacioService.crear continua llançant IllegalStateException quan el nom està repetit. Crea EstacioDuplicadaException, fes-la respondre 409 i afegeix a la resposta l'identificador de l'estació que ja existeix, perquè el tauler de Ribalta pugui enllaçar-la directament.

Exercici 2: Errors de validació internacionalitzats i amb el valor rebutjat

Amplia el gestor de MethodArgumentNotValidException perquè cada error inclogui el nom del camp, el missatge traduït segons Accept-Language i el valor rebutjat, amb una llista de camps sensibles el valor dels quals no es retorna mai.

Exercici 3: Gestor de 405 amb la capçalera Allow

El gestor de HttpRequestMethodNotSupportedException de l'apartat 9 retorna el 405 correcte però no inclou la capçalera Allow, que l'RFC 9110 exigeix. Corregeix-ho i explica per què aquest cas necessita ResponseEntity mentre que els altres no.

Solucions

Solució 1.

public class EstacioDuplicadaException extends ConflicteRecursException {

    private final Long idExistent;

    public EstacioDuplicadaException(String nom, Long idExistent) {
        super("ESTACIO_DUPLICADA", "Ja existeix una estació anomenada '%s'".formatted(nom));
        this.idExistent = idExistent;
    }

    public Long getIdExistent() { return idExistent; }
}

// A EstacioService
public Estacio crear(Estacio nova) {
    estacioRepositori.cercarPerNom(nova.nom()).ifPresent(existent -> {
        throw new EstacioDuplicadaException(nova.nom(), existent.id());
    });
    return estacioRepositori.desar(nova);
}

Observa el canvi al repositori: existeixPerNom retornava un boolean i ara necessitem cercarPerNom, que retorna Optional<Estacio>. És un patró recurrent: si en llançar l'error necessites dades del recurs en conflicte, el repositori t'ha de retornar l'objecte, no un booleà. El gestor específic:

@ExceptionHandler(EstacioDuplicadaException.class)
public ProblemDetail gestionarEstacioDuplicada(EstacioDuplicadaException e) {
    ProblemDetail problema = construir(HttpStatus.CONFLICT, "Estació duplicada", e);
    problema.setProperty("idExistent", e.getIdExistent());
    problema.setProperty("enllacExistent", "/api/v1/estacions/" + e.getIdExistent());
    return problema;
}
{ "type": "https://api.ciclourbana.example/errors/estacio-duplicada",
  "title": "Estació duplicada", "status": 409,
  "detail": "Ja existeix una estació anomenada 'Plaça Major'",
  "codi": "ESTACIO_DUPLICADA", "idExistent": 1,
  "enllacExistent": "/api/v1/estacions/1" }

El camp enllacExistent és un toc d'HATEOAS on de veritat aporta: el tauler pot oferir "veure l'estació existent" sense construir l'URL. I encara que no existís un gestor per a EstacioDuplicadaException, l'excepció continuaria responent 409 gràcies al de la seva superclasse ConflicteRecursException; el gestor específic només hi afegeix els dos camps extra. Aquest és l'avantatge d'una jerarquia ben dissenyada.

Solució 2.

/** Camps el valor dels quals NO es retorna MAI, encara que hagi fallat la validació. */
private static final Set<String> CAMPS_SENSIBLES =
        Set.of("contrasenya", "password", "token", "targeta", "cvv", "dni", "iban");

public record ErrorCamp(String camp, String missatge, Object valorRebutjat) {}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail gestionarValidacio(MethodArgumentNotValidException e, Locale idioma) {

    List<ErrorCamp> errors = e.getBindingResult().getFieldErrors().stream()
            // getMessage(error, idioma) resol la clau contra el
            // MessageSource de 03-04 amb l'idioma d'Accept-Language
            .map(error -> new ErrorCamp(error.getField(),
                    messageSource.getMessage(error, idioma), valorSegur(error)))
            .toList();

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "La petició conté %d error(s) de validació".formatted(errors.size()));
    problema.setTitle("Error de validació");
    problema.setType(BASE_TIPUS.resolve("validacio"));
    problema.setProperty("codi", "VALIDACIO");
    problema.setProperty("errors", errors);
    return problema;
}

/** Retorna el valor rebutjat llevat que el camp sigui sensible. */
private Object valorSegur(FieldError error) {
    String camp = error.getField().toLowerCase();
    if (CAMPS_SENSIBLES.stream().anyMatch(camp::contains)) {
        return "***";
    }
    Object valor = error.getRejectedValue();
    // Truncar: un camp de text pot portar megabytes i ompliria la resposta
    return valor instanceof String text && text.length() > 100
            ? text.substring(0, 100) + "..." : valor;
}

Amb Accept-Language: en:

{ "title": "Error de validació", "status": 400,
  "detail": "La petició conté 2 error(s) de validació",
  "codi": "VALIDACIO",
  "errors": [
    { "camp": "nom", "missatge": "The station name is required", "valorRebutjat": "" },
    { "camp": "capacitat", "missatge": "Capacity must be greater than zero",
      "valorRebutjat": -5 } ] }

Tres decisions que val la pena assenyalar. Primera: Locale com a paràmetre del gestor, que Spring injecta a partir del LocaleResolver de 03-04, de manera que messageSource.getMessage(error, idioma) resol {estacio.nom.obligatori} en l'idioma correcte. Segona: la llista de camps sensibles és una llista negra, amb el problema que ja coneixem —un camp nou anomenat numeroSeguretatSocial no hi seria—, així que en un sistema amb dades veritablement sensibles el correcte és a l'inrevés: no retornar cap valor llevat dels explícitament marcats com a segurs. Tercera: el truncament a 100 caràcters evita que un client maliciós provoqui respostes enormes.

Solució 3.

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ProblemDetail> gestionarMetodeNoSuportat(
        HttpRequestMethodNotSupportedException e) {

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(
            HttpStatus.METHOD_NOT_ALLOWED,
            "El mètode %s no està permès en aquesta ruta".formatted(e.getMethod()));
    problema.setTitle("Mètode no permès");
    problema.setType(BASE_TIPUS.resolve("metode-no-permes"));
    problema.setProperty("codi", "METODE_NO_PERMES");
    problema.setProperty("metodesPermesos", e.getSupportedMethods());

    HttpHeaders capcaleres = new HttpHeaders();
    Set<HttpMethod> permesos = e.getSupportedHttpMethods();
    if (permesos != null && !permesos.isEmpty()) {
        capcaleres.setAllow(permesos);         // capçalera Allow: GET, POST, ...
    }
    return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .headers(capcaleres).body(problema);
}

Davant de DELETE /api/v1/estacions, la resposta és 405 amb Allow: GET, POST i un cos application/problem+json que repeteix els mètodes a metodesPermesos.

Per què aquest cas necessita ResponseEntity. ProblemDetail descriu únicament el cos de la resposta: té status perquè Spring fixi el codi, però no té manera d'expressar capçaleres. L'RFC 9110 diu que un 405 ha d'incloure Allow, així que cal embolcallar-lo. És exactament el criteri que vam fixar a la taula de 03-02: ResponseEntity quan calen capçaleres, l'objecte directe quan no.

Val la pena notar la duplicitat deliberada: els mètodes permesos apareixen a la capçalera Allow i al camp metodesPermesos. La capçalera és el que exigeix l'estàndard i el que llegeixen els intermediaris; el camp del cos és el que la majoria dels clients JavaScript llegiran a la pràctica, perquè accedir a capçaleres des del navegador exigeix la configuració exposedHeaders de CORS que vam veure a 03-02. Duplicar aquí costa una línia i estalvia un problema.

Conclusió

El deute de quatre lliçons queda saldat. Saps què fa Spring Boot per defecte —el reenviament a /error i el BasicErrorController— i per què les propietats server.error.include-* no basten per a una API pública, començant pel fet que tot surt com a 500. Has construït la jerarquia d'excepcions de CicloUrbana amb CicloUrbanaException com a base, el seu camp codi que fa l'API programable sense dependre del text, i les excepcions concretes del domini de Ribalta, cadascuna amb el seu codi HTTP en una taula que és l'especificació del gestor. Coneixes @ResponseStatus i els seus tres límits, @ExceptionHandler local i la seva precedència sobre el global, i sobretot el @RestControllerAdvice que centralitza la gestió en una sola classe, amb la resolució per especificitat de tipus que permet declarar un gestor d'Exception sense por que es mengi els altres.

Els errors de CicloUrbana parlen ja l'estàndard RFC 7807 Problem Details, amb type, title, status, detail, instance i les extensions pròpies, servits amb application/problem+json i complementats per spring.mvc.problemdetails.enabled. Els errors de validació de 03-04 retornen per fi la llista exacta de camps erronis, agrupats en Map<String, List<String>> per no perdre'n cap, i la ConstraintViolationException ja no surt com a 500. Gestiones les excepcions del framework —JSON il·legible, tipus incorrecte, mètode no permès, ruta inexistent— i saps que cap no ha de filtrar e.getMessage(). Tens la taula de què no ha de sortir mai en un error, la política de nivells de log per gravetat, i un identificador de rastreig propagat amb l'MDC que apareix alhora a la resposta i a totes les línies del log d'aquesta petició, amb el MDC.remove() al finally que evita corrompre el diagnòstic.

CicloUrbana té ara una API completa: tretze endpoints, validació a la vora, DTO que separen el domini del contracte i errors uniformes i ben formats. I tanmateix, si demà l'equip de l'app mòbil de Ribalta o el del portal de dades obertes de l'ajuntament es volguessin integrar, ens haurien de preguntar endpoint per endpoint quins camps accepta cadascun, què retorna i quins errors pot donar. Tot aquest coneixement és al codi i al nostre cap, i enlloc més.

La lliçó 03-07, Documentar l'API amb OpenAPI, ho posa per escrit de manera automàtica i llegible per màquines. Veurem què és OpenAPI 3.1 i per què un contracte formal canvia la forma de treballar entre equips; compararem code-first i design-first; integrarem springdoc-openapi amb els seus endpoints /v3/api-docs i /swagger-ui.html; documentarem CicloUrbana amb @Tag, @Operation, @Parameter, @ApiResponse i @Schema; veurem com les restriccions de Bean Validation de 03-04 i els Problem Details d'aquesta lliçó apareixen sols a l'esquema; agruparem endpoints amb GroupedOpenApi; exportarem l'openapi.json al build per generar clients; i tancarem el mòdul amb el balanç de l'API abans de saltar a la persistència real.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats