Llevamos cuatro lecciones dejando etiquetas TODO por el camino. Los 404 se construyen a mano en cada controlador con ResponseEntity.notFound(). Las reglas de negocio de 03-03 lanzan IllegalStateException que se convierten en errores del servidor con la traza de pila dentro. La ConstraintViolationException de 03-04 sale como 500 cuando la culpa es del cliente. Y los errores de validación responden "Invalid request content." sin decir qué campo falló. Todo eso se salda en esta lección. Construiremos la jerarquía de excepciones de CicloUrbana, la centralizaremos en un único manejador global, adoptaremos el formato estándar RFC 7807 Problem Details que Spring Boot 3 soporta de forma nativa, y nos aseguraremos de que ningún error filtre información que no debe salir del servidor.

Contenido

  1. El comportamiento por defecto de Spring Boot
  2. Por qué no sirve para una API pública
  3. La jerarquía de excepciones de CicloUrbana
  4. @ResponseStatus en la excepción y sus límites
  5. @ExceptionHandler local al controlador
  6. @RestControllerAdvice: el manejador global
  7. RFC 7807: Problem Details
  8. Errores de validación con la lista de campos
  9. Excepciones del framework
  10. No filtrar información sensible
  11. Qué registrar en el log según la gravedad
  12. Identificador de traza en la respuesta
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. El comportamiento por defecto de Spring Boot

Spring Boot nunca deja una excepción sin respuesta. Cuando una escapa del controlador, el DispatcherServlet —cuyo recorrido dibujamos en 03-01— la pasa por una cadena de HandlerExceptionResolver. Si ninguno la reconoce, la petición se reenvía internamente a /error, atendido por el BasicErrorController. Si añades un endpoint de prueba que lanza IllegalStateException, la respuesta es esta:

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

Ese formato se controla con cuatro propiedades:

server:
  error:
    include-message: always          # nunca | always | on-param (por defecto: nunca)
    include-binding-errors: always   # errores de validación en la respuesta
    include-stacktrace: never        # NUNCA en producción
    include-exception: false         # nombre de la clase de la excepción
    path: /error                     # ruta interna del BasicErrorController
Propiedad Por defecto Riesgo si se activa
include-message never Puede exponer detalles internos del mensaje
include-binding-errors never Bajo: son datos que el cliente envió
include-stacktrace never Alto: revela clases, rutas y versiones
include-exception false Medio: revela la implementación interna

Con include-message e include-stacktrace en always, la respuesta pasa a incluir el mensaje completo y la traza de pila entera: nombres de paquete, versiones de librerías, líneas de código. Es información de oro para quien busque vulnerabilidades. include-stacktrace debe ser never en producción, sin excepciones.

  1. Por qué no sirve para una API pública

Aunque afinemos esas propiedades, el manejo por defecto tiene cuatro problemas que no se arreglan con configuración. Todo es 500: IllegalStateException, IllegalArgumentException y NoSuchElementException salen igual, aunque unas sean culpa del cliente y otras nuestras, y eso arruina la monitorización —el sistema de alertas del módulo 9 despertará a alguien de madrugada por un 404—. El cliente no puede programar contra el error, porque no hay un código estable que distinguir, solo un texto en inglés que puede cambiar en cualquier versión. No hay contexto útil: "Bad Request" no dice qué campo estaba mal. Y el formato no es un estándar, así que cada cliente escribe su propio parseador.

Lo que necesita una API pública es que cada tipo de fallo tenga un código HTTP correcto, un identificador estable, un mensaje útil y un formato uniforme. Eso es lo que vamos a construir.

  1. La jerarquía de excepciones de CicloUrbana

Toda excepción del dominio hereda de una base común, en com.ciclourbana.comun.excepciones:

/**
 * Base de todas las excepciones de negocio de CicloUrbana.
 *
 * Es RuntimeException (no comprobada) deliberadamente: obligar a declarar
 * throws en cada firma contamina el código sin aportar seguridad, porque
 * nadie puede recuperarse de estos fallos salvo el manejador global.
 */
public abstract class CicloUrbanaException extends RuntimeException {

    /** Código estable que el cliente puede usar en su lógica. */
    private final String codigo;

    protected CicloUrbanaException(String codigo, String mensaje) {
        super(mensaje);
        this.codigo = codigo;
    }

    public String getCodigo() {
        return codigo;
    }
}

El campo codigo es lo que hace la API programable: el cliente reacciona a ESTACION_LLENA sin depender del texto del mensaje, que puede traducirse o reescribirse sin romper nada. Las excepciones concretas:

/** El recurso solicitado no existe. Mapea a 404. */
public class RecursoNoEncontradoException extends CicloUrbanaException {
    public RecursoNoEncontradoException(String tipoRecurso, Object id) {
        super("RECURSO_NO_ENCONTRADO",
              "No se ha encontrado %s con identificador %s".formatted(tipoRecurso, id));
    }
}

/** Regla de negocio violada (422) y conflicto con otro recurso (409). */
public class ReglaNegocioException extends CicloUrbanaException {
    public ReglaNegocioException(String codigo, String mensaje) { super(codigo, mensaje); }
}
public class ConflictoRecursoException extends CicloUrbanaException {
    public ConflictoRecursoException(String codigo, String mensaje) { super(codigo, mensaje); }
}

/** Especializaciones con su mensaje construido. */
public class EstacionLlenaException extends ConflictoRecursoException {
    public EstacionLlenaException(String nombre, int capacidad) {
        super("ESTACION_LLENA", "La estación %s no tiene anclajes libres (capacidad %d)"
                .formatted(nombre, capacidad));
    }
}
public class BicicletaNoDisponibleException extends ReglaNegocioException {
    public BicicletaNoDisponibleException(String matricula, EstadoBicicleta estado) {
        super("BICICLETA_NO_DISPONIBLE",
              "La bicicleta %s no está disponible (estado: %s)".formatted(matricula, estado));
    }
}

El mapeo completo a códigos HTTP:

Excepción HTTP codigo Cuándo se lanza
RecursoNoEncontradoException 404 RECURSO_NO_ENCONTRADO Estación o alquiler inexistente
ConflictoRecursoException 409 variable Nombre de estación duplicado
EstacionLlenaException 409 ESTACION_LLENA Devolver a una estación llena
ReglaNegocioException 422 variable Alquiler ya finalizado
BicicletaNoDisponibleException 422 BICICLETA_NO_DISPONIBLE Bicicleta en mantenimiento
MethodArgumentNotValidException 400 VALIDACION @Valid falla en el cuerpo
ConstraintViolationException 400 VALIDACION @Validated falla en un parámetro
HttpMessageNotReadableException 400 CUERPO_ILEGIBLE JSON mal formado
Cualquier otra 500 ERROR_INTERNO Fallo no previsto

Con esto, los servicios dejan de lanzar IllegalStateException:

// En AlquilerService, sustituyendo el código provisional de 03-03
Alquiler alquiler = alquilerRepositorio.buscarPorId(alquilerId)
        .orElseThrow(() -> new RecursoNoEncontradoException("alquiler", alquilerId));
if (alquiler.estado() == EstadoAlquiler.FINALIZADO) {
    throw new ReglaNegocioException("ALQUILER_YA_FINALIZADO",
            "El alquiler %d ya estaba finalizado".formatted(alquilerId));
}
Estacion destino = estacionRepositorio.buscarPorId(estacionDestinoId)
        .orElseThrow(() -> new RecursoNoEncontradoException("estación", estacionDestinoId));
if (bicicletaRepositorio.contarPorEstacion(destino.id()) >= destino.capacidad()) {
    throw new EstacionLlenaException(destino.nombre(), destino.capacidad());
}

Fíjate en el patrón orElseThrow: convierte un Optional vacío en la excepción adecuada en una sola expresión, y es la razón por la que los repositorios devuelven Optional y no null.

  1. @ResponseStatus en la excepción y sus límites

La forma más rápida de asociar un código HTTP a una excepción es anotarla con @ResponseStatus(HttpStatus.NOT_FOUND). Spring la detecta con ResponseStatusExceptionResolver y responde 404. Funciona, es una línea, y tiene tres límites serios:

Límite Consecuencia
El estado es fijo La misma excepción no puede dar 409 en un caso y 422 en otro
No controla el cuerpo La respuesta la sigue generando BasicErrorController
Solo sirve para excepciones propias No puedes anotar ConstraintViolationException, de una librería

Por esos motivos, CicloUrbana no usa @ResponseStatus en sus excepciones: el mapeo completo vive en el manejador global, en un solo sitio que se lee de un vistazo.

Existe una variante interesante, ResponseStatusException, que lleva el estado dentro: throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Estación no encontrada"). Es cómoda para prototipos, pero acopla la capa de servicio a la API web —HttpStatus es una clase de Spring Web— y no permite el campo codigo. El curso la menciona y no la adopta.

  1. @ExceptionHandler local al controlador

Un método anotado con @ExceptionHandler dentro de un controlador captura las excepciones de ese controlador:

// Dentro de EstacionController, junto a los endpoints
@ExceptionHandler(EstacionLlenaException.class)
public ProblemDetail manejarEstacionLlena(EstacionLlenaException e) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}

Su alcance limitado es a la vez su virtud y su defecto: sirve para una excepción específica de un controlador concreto, pero repetirlo en cada clase multiplica el código, así que CicloUrbana usa el manejador global y reserva los locales para casos genuinamente particulares. Un dato de precedencia que conviene retener: el manejador local siempre gana al global cuando ambos capturan la misma excepción.

  1. @RestControllerAdvice: el manejador global

@RestControllerAdvice es @ControllerAdvice + @ResponseBody: una clase cuyos @ExceptionHandler aplican a todos los controladores.

package com.ciclourbana.comun;

@RestControllerAdvice
public class ManejadorGlobalExcepciones {

    private static final Logger log = LoggerFactory.getLogger(ManejadorGlobalExcepciones.class);
    private static final URI BASE_TIPOS = URI.create("https://api.ciclourbana.es/errores/");

    @ExceptionHandler(RecursoNoEncontradoException.class)
    public ProblemDetail manejarNoEncontrado(RecursoNoEncontradoException e,
                                             HttpServletRequest peticion) {
        log.info("Recurso no encontrado en {}: {}", peticion.getRequestURI(), e.getMessage());
        return construir(HttpStatus.NOT_FOUND, "Recurso no encontrado", e);
    }

    @ExceptionHandler(ConflictoRecursoException.class)
    public ProblemDetail manejarConflicto(ConflictoRecursoException e,
                                          HttpServletRequest peticion) {
        log.warn("Conflicto en {}: {}", peticion.getRequestURI(), e.getMessage());
        return construir(HttpStatus.CONFLICT, "Conflicto con el estado actual", e);
    }

    @ExceptionHandler(ReglaNegocioException.class)
    public ProblemDetail manejarReglaNegocio(ReglaNegocioException e,
                                             HttpServletRequest peticion) {
        log.warn("Regla violada en {}: {}", peticion.getRequestURI(), e.getMessage());
        return construir(HttpStatus.UNPROCESSABLE_ENTITY, "Operación no permitida", e);
    }

    /** Red de seguridad. Si se dispara, es un bug: por eso ERROR con la traza. */
    @ExceptionHandler(Exception.class)
    public ProblemDetail manejarInesperado(Exception e, HttpServletRequest peticion) {
        String idTraza = UUID.randomUUID().toString();
        log.error("Error inesperado [traza={}] en {}", idTraza, peticion.getRequestURI(), e);

        ProblemDetail problema = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                // Mensaje GENÉRICO: no se filtra e.getMessage()
                "Se ha producido un error interno. Contacta con soporte indicando la traza.");
        problema.setTitle("Error interno");
        problema.setType(BASE_TIPOS.resolve("error-interno"));
        problema.setProperty("codigo", "ERROR_INTERNO");
        problema.setProperty("traza", idTraza);
        return problema;
    }

    /** Construcción común de la respuesta Problem Details. */
    private ProblemDetail construir(HttpStatus estado, String titulo, CicloUrbanaException e) {
        ProblemDetail problema = ProblemDetail.forStatusAndDetail(estado, e.getMessage());
        problema.setTitle(titulo);
        problema.setType(BASE_TIPOS.resolve(e.getCodigo().toLowerCase().replace('_', '-')));
        problema.setProperty("codigo", e.getCodigo());
        problema.setProperty("marcaTiempo", Instant.now().toString());
        return problema;
    }
}

Tres detalles importantes. Devolver ProblemDetail directamente basta: Spring lo reconoce, fija el estado a partir del campo status y pone Content-Type: application/problem+json, sin necesidad de ResponseEntity. El manejador de Exception no filtra e.getMessage(): el mensaje real va al log y al cliente solo llega un texto genérico con un identificador de traza (apartado 10). Y el orden de resolución es por especificidad del tipo, no por orden de declaración: si se lanza EstacionLlenaException, Spring elige el manejador de ConflictoRecursoException —su superclase más cercana con manejador— y no el de Exception, que por eso puede declararse sin miedo a que se coma los demás.

Cuando hay varios @RestControllerAdvice —por ejemplo, uno para el dominio y otro que aporte Spring Security en el módulo 5—, el desempate sí depende del orden, y se controla con @Order: @Order(Ordered.HIGHEST_PRECEDENCE) en el manejador de seguridad y @Order(Ordered.LOWEST_PRECEDENCE) en el general, que actúa de red de seguridad. @RestControllerAdvice acepta además basePackages, assignableTypes o annotations para limitar su alcance, útil si conviven una API pública y un panel interno con formatos de error distintos.

Con el manejador en marcha, los controladores se simplifican. El obtenerPorId de 03-02 pierde su ResponseEntity y su .map(...).orElseGet(...):

@GetMapping("/{id:\\d+}")
public EstacionDetalleResponse obtenerPorId(@PathVariable("id") @Positive Long id) {
    Estacion estacion = estacionService.buscarPorId(id)
            .orElseThrow(() -> new RecursoNoEncontradoException("estación", id));
    return estacionMapper.aDetalle(estacion, bicicletaService.buscarPorEstacion(id));
}

El TODO que dejamos allí queda saldado: el camino de error ya no ocupa espacio en el camino feliz.

  1. RFC 7807: Problem Details

El RFC 7807 —actualizado por el RFC 9457— define un formato estándar para los errores HTTP, y Spring Boot 3 lo soporta de forma nativa con la clase ProblemDetail. Los campos del estándar:

Campo Significado
type URI que identifica el tipo de problema; sirve de documentación
title Resumen legible y estable para ese tipo
status El código HTTP, repetido en el cuerpo
detail Explicación específica de esta ocurrencia
instance URI de la ocurrencia concreta
(extensiones) Campos propios: codigo, traza, errores...

La distinción entre title y detail es la que más se confunde: title es fijo para el tipo de problema ("Recurso no encontrado") y detail cambia en cada ocurrencia ("No se ha encontrado estación con identificador 999"). Un cliente puede agrupar errores por type y mostrar detail al usuario.

La respuesta que produce nuestro manejador ante GET /api/v1/estaciones/999 es un 404 con Content-Type: application/problem+json y este cuerpo:

{ "type": "https://api.ciclourbana.es/errores/recurso-no-encontrado",
  "title": "Recurso no encontrado", "status": 404,
  "detail": "No se ha encontrado estación con identificador 999",
  "instance": "/api/v1/estaciones/999",
  "codigo": "RECURSO_NO_ENCONTRADO", "marcaTiempo": "2026-09-01T09:22:41.117Z" }

Content-Type: application/problem+json lo pone Spring solo al detectar un ProblemDetail. Es el tipo MIME que anunciamos en la tabla de 03-01.

Spring Boot ofrece además una activación global sin escribir manejadores:

spring:
  mvc:
    problemdetails:
      enabled: true    # las excepciones de Spring MVC salen como Problem Details

Hace que las excepciones del propio framework —MethodArgumentNotValidException, HttpRequestMethodNotSupportedException, NoResourceFoundException— produzcan Problem Details en lugar del formato de BasicErrorController. No cubre las propias, para las que sigue haciendo falta el manejador, pero da coherencia a los errores que no manejas explícitamente.

Existen además ErrorResponseException, una excepción de Spring que ya lleva un ProblemDetail dentro y permite lanzar un error completamente formado desde cualquier punto, y ResponseEntityExceptionHandler, una clase base con manejadores preescritos para todas las excepciones de Spring MVC que puedes extender y sobrescribir método a método: da más cobertura de partida a cambio de menos control.

  1. Errores de validación con la lista de campos

Recuperamos la deuda de 03-04: una MethodArgumentNotValidException contiene un BindingResult con todos los campos que fallaron, y hay que extraerlos y ponerlos en la respuesta.

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

    // Un campo puede tener varios errores: se agrupan por nombre de campo
    Map<String, List<String>> errores = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.groupingBy(FieldError::getField, LinkedHashMap::new,
                    Collectors.mapping(DefaultMessageSourceResolvable::getDefaultMessage,
                                       Collectors.toList())));

    // Errores de restricciones de clase (@CoordenadasValidas sin campo asociado)
    List<String> globales = e.getBindingResult().getGlobalErrors().stream()
            .map(DefaultMessageSourceResolvable::getDefaultMessage).toList();

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "La petición contiene %d campo(s) con errores".formatted(errores.size()));
    problema.setTitle("Error de validación");
    problema.setType(BASE_TIPOS.resolve("validacion"));
    problema.setProperty("codigo", "VALIDACION");
    problema.setProperty("errores", errores);
    if (!globales.isEmpty()) {
        problema.setProperty("erroresGlobales", globales);
    }
    return problema;
}

/** @Validated sobre parámetros: distinta excepción, misma forma de respuesta. */
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail manejarValidacionParametros(ConstraintViolationException e) {

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

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Parámetros de la petición no válidos");
    problema.setTitle("Error de validación");
    problema.setType(BASE_TIPOS.resolve("validacion"));
    problema.setProperty("codigo", "VALIDACION");
    problema.setProperty("errores", errores);
    return problema;
}

/** El path llega como "listar.tamanio"; al cliente le interesa solo "tamanio". */
private String ultimoNodo(Path ruta) {
    String completo = ruta.toString();
    return completo.substring(completo.lastIndexOf('.') + 1);
}

El resultado, con la misma petición que en 03-04 devolvía "Invalid request content.":

{ "type": "https://api.ciclourbana.es/errores/validacion",
  "title": "Error de validación", "status": 400,
  "detail": "La petición contiene 2 campo(s) con errores",
  "instance": "/api/v1/estaciones", "codigo": "VALIDACION",
  "errores": {
    "nombre": ["El nombre de la estación es obligatorio",
               "El nombre debe tener entre 3 y 80 caracteres"],
    "capacidad": ["La capacidad debe ser mayor que cero"] } }

Ahora el panel de Ribalta puede resaltar los dos campos y mostrar sus mensajes junto a cada uno. Nótese que nombre acumula dos errores: la agrupación en List<String> no pierde ninguno, mientras que un Map<String, String> habría descartado uno en silencio. Y con esto se salda también la trampa del 500 de 03-04: ConstraintViolationException ya responde 400.

  1. Excepciones del framework

Además de las propias, hay que manejar las que lanza Spring cuando la petición está mal formada:

Excepción Causa Estado
HttpMessageNotReadableException JSON mal formado o cuerpo ausente 400
MethodArgumentTypeMismatchException /estaciones/abc con id de tipo Long 400
MissingServletRequestParameterException Falta un @RequestParam obligatorio 400
HttpRequestMethodNotSupportedException DELETE sobre la colección 405
HttpMediaTypeNotSupportedException Se envía XML donde se espera JSON 415
HttpMediaTypeNotAcceptableException Accept que no podemos satisfacer 406
NoResourceFoundException Ruta inexistente (Spring Boot 3.2+) 404
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail manejarCuerpoIlegible(HttpMessageNotReadableException e) {
    // OJO: e.getMessage() incluye un fragmento del JSON recibido y la clase
    // Java destino. Es información interna: no se devuelve al cliente.
    log.warn("Cuerpo ilegible: {}", e.getMessage());

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "El cuerpo de la petición no es un JSON válido");
    problema.setTitle("Cuerpo ilegible");
    problema.setType(BASE_TIPOS.resolve("cuerpo-ilegible"));
    problema.setProperty("codigo", "CUERPO_ILEGIBLE");
    return problema;
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail manejarTipoIncorrecto(MethodArgumentTypeMismatchException e) {
    String tipoEsperado = e.getRequiredType() != null
            ? e.getRequiredType().getSimpleName() : "desconocido";

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "El parámetro '%s' debe ser de tipo %s".formatted(e.getName(), tipoEsperado));
    problema.setTitle("Tipo de parámetro incorrecto");
    problema.setProperty("codigo", "TIPO_INCORRECTO");
    problema.setProperty("parametro", e.getName());
    return problema;
}

NoResourceFoundException (con spring.mvc.throw-exception-if-no-handler-found: true) y HttpRequestMethodNotSupportedException siguen el mismo patrón; el ejercicio 3 completa esta última con la cabecera Allow. Ahora aquella petición del .http de 03-02 que respondía un 400 incomprensible da algo útil ante ?capacidadMinima=mucho:

{ "type": "about:blank", "title": "Tipo de parámetro incorrecto", "status": 400,
  "detail": "El parámetro 'capacidadMinima' debe ser de tipo Integer",
  "codigo": "TIPO_INCORRECTO", "parametro": "capacidadMinima" }

  1. No filtrar información sensible

Un manejador de errores es una superficie de ataque: cada dato que devuelve es un dato que alguien puede usar contra ti. Qué no debe salir nunca:

Dato Por qué es peligroso
Traza de pila Revela clases, versiones de librerías y estructura del código
Mensajes de la base de datos Filtran tablas y columnas; ayudan a inyecciones SQL
Rutas del sistema de ficheros Revelan el sistema operativo y el despliegue
Nombres de clase Java com.ciclourbana.pasarela.ClienteRedsys dice qué usas
Direcciones internas y puertos Facilitan el movimiento lateral en la red
El valor rechazado por una validación Puede ser la contraseña recién escrita

Los tres primeros llegan casi siempre por la misma vía: devolver e.getMessage() de una excepción que no controlas. Con ProblemDetail.forStatusAndDetail(INTERNAL_SERVER_ERROR, e.getMessage()) y un fallo de base de datos, la API responde algo como "could not execute statement; SQL [insert into estaciones ...]; constraint [uk_estaciones_nombre]", que revela el esquema completo. El manejador de Exception del apartado 6 hace lo correcto: mensaje genérico fuera, detalle completo en el log, y un identificador de traza que une ambos.

La regla: el mensaje de las excepciones propias puede salir, porque lo hemos escrito nosotros pensando en el cliente; el de las ajenas, nunca.

Un caso sutil: la enumeración de recursos. Si GET /api/v1/usuarios/1 responde 404 y GET /api/v1/usuarios/2 responde 403, un atacante deduce que el usuario 2 existe; para recursos sensibles lo correcto es responder 404 en ambos casos. No aplica a las estaciones de Ribalta, que son información pública, pero sí a los usuarios, y lo retomaremos en el módulo 5.

  1. Qué registrar en el log según la gravedad

Registrar todo con log.error inutiliza el log: cuando todo es un error, nada lo es.

Situación Nivel Traza Razón
404 de recurso INFO No Funcionamiento normal
Validación fallida INFO No El cliente se equivocó; es esperable
409 / 422 de negocio WARN No Esperado, pero puede señalar un cliente con fallos
401 / 403 WARN No Puede ser un intento de acceso indebido
Dependencia externa caída ERROR Sí Requiere intervención
Excepción no prevista ERROR Sí Es un bug: hay que arreglarlo

Dos reglas prácticas. La traza de pila solo en ERROR: un 404 con veinte líneas de traza multiplica el tamaño del log sin aportar nada. Y registrar la excepción como último argumento, no concatenada:

log.error("Error inesperado [traza={}] en {}", idTraza, uri, e);   // BIEN: traza completa
log.error("Error inesperado: " + e.getMessage());                  // MAL: pierde la causa

SLF4J trata el último argumento Throwable de forma especial e imprime la traza completa, incluidas las causas encadenadas; concatenar pierde exactamente la información que se necesita para diagnosticar. Los logs en profundidad llegan en 09-05.

  1. Identificador de traza en la respuesta

Cuando un operario de Ribalta llama diciendo "me ha dado un error al finalizar el alquiler", la pregunta es cuál de los cientos de errores del log es el suyo. El identificador de traza responde a eso: un valor único que aparece a la vez en la respuesta del cliente y en la línea del log. La versión artesanal es la que ya tenemos: un UUID.randomUUID() en el manejador de Exception. Solo cubre los errores y no relaciona entre sí las líneas de log de una misma petición. La versión buena usa el MDC (Mapped Diagnostic Context) de SLF4J, que asocia datos al hilo actual y hace que todas las líneas de log de una petición lleven el mismo identificador:

package com.ciclourbana.comun;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)     // antes que cualquier otro filtro
public class FiltroTraza extends OncePerRequestFilter {

    public static final String CLAVE_MDC = "trazaId";
    private static final String CABECERA = "X-Traza-Id";

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

        // Si un servicio anterior ya envió un identificador, se reutiliza:
        // así la traza sobrevive entre servicios (se completa en 09-06)
        String traza = Optional.ofNullable(peticion.getHeader(CABECERA))
                .filter(s -> !s.isBlank())
                .orElseGet(() -> UUID.randomUUID().toString().substring(0, 8));

        MDC.put(CLAVE_MDC, traza);
        respuesta.setHeader(CABECERA, traza);     // el cliente siempre lo recibe
        try {
            cadena.doFilter(peticion, respuesta);
        } finally {
            // IMPRESCINDIBLE: los hilos de Tomcat se reutilizan. Sin este
            // remove, la siguiente petición heredaría la traza de la anterior.
            MDC.remove(CLAVE_MDC);
        }
    }
}

Ese MDC.remove en el finally no es opcional: sin él, una petición que falle deja su identificador pegado al hilo y la siguiente petición atendida por ese hilo escribe en el log una traza que no le corresponde. Es un error difícil de diagnosticar precisamente porque corrompe la herramienta de diagnóstico.

Se incluye en el patrón de log con %X{trazaId:-sin-traza} —%X lee el MDC y :-sin-traza es el valor por defecto—, y en el manejador basta con problema.setProperty("traza", MDC.get(FiltroTraza.CLAVE_MDC)):

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

El resultado, en el log del servidor y en la respuesta que recibe el operario:

09:22:41.117 WARN [a3f5c9e1] c.c.c.ManejadorGlobalExcepciones - Conflicto en
/api/v1/alquileres/7/finalizar: La estación Universidad no tiene anclajes libres
{ "type": "https://api.ciclourbana.es/errores/estacion-llena",
  "title": "Conflicto con el estado actual", "status": 409,
  "detail": "La estación Universidad no tiene anclajes libres (capacidad 36)",
  "codigo": "ESTACION_LLENA", "traza": "a3f5c9e1" }

Con a3f5c9e1 se localiza en el log la petición exacta y todas sus líneas. Este identificador es la semilla de la trazabilidad distribuida de 09-06, donde la traza seguirá viva a través de varios servicios.

Errores Comunes y Consejos

Dejar include-stacktrace: always en producción. Es la fuga de información más común en aplicaciones Spring Boot. Debe ser never.

Capturar Exception en el controlador con un try/catch. Anula el manejador global y esparce la lógica de errores. Deja que la excepción suba.

Devolver e.getMessage() de una excepción ajena. Es la vía por la que salen esquemas de base de datos y rutas del sistema.

Registrar todo con log.error. Un 404 no es un error del servidor. Cuando todo es ERROR, las alertas dejan de significar nada.

Olvidar MDC.remove(). La traza se filtra a la siguiente petición del mismo hilo y corrompe el log justo cuando más lo necesitas.

Usar @ResponseStatus y el manejador global a la vez. Tener el mapeo en dos sitios acaba en incoherencias; el curso elige el manejador. Y usar Map<String, String> para los errores de validación pierde en silencio los errores adicionales de un mismo campo: usa Map<String, List<String>>.

Consejo: escribe primero la tabla de excepción a código HTTP, que es la especificación del manejador, y prueba cada rama de error añadiendo al fichero .http una petición por tipo; en el módulo 6 esas peticiones se convierten en pruebas de integración que impiden que un refactor rompa el contrato de errores.

Ejercicios

Ejercicio 1: Excepción de duplicado con contexto

EstacionService.crear sigue lanzando IllegalStateException cuando el nombre está repetido. Crea EstacionDuplicadaException, hazla responder 409 y añade a la respuesta el identificador de la estación que ya existe, para que el panel de Ribalta pueda enlazarla directamente.

Ejercicio 2: Errores de validación internacionalizados y con el valor rechazado

Amplía el manejador de MethodArgumentNotValidException para que cada error incluya el nombre del campo, el mensaje traducido según Accept-Language y el valor rechazado, con una lista de campos sensibles cuyo valor nunca se devuelve.

Ejercicio 3: Manejador de 405 con la cabecera Allow

El manejador de HttpRequestMethodNotSupportedException del apartado 9 devuelve el 405 correcto pero no incluye la cabecera Allow, que el RFC 9110 exige. Corrígelo y explica por qué ese caso necesita ResponseEntity mientras los demás no.

Soluciones

Solución 1.

public class EstacionDuplicadaException extends ConflictoRecursoException {

    private final Long idExistente;

    public EstacionDuplicadaException(String nombre, Long idExistente) {
        super("ESTACION_DUPLICADA", "Ya existe una estación llamada '%s'".formatted(nombre));
        this.idExistente = idExistente;
    }

    public Long getIdExistente() { return idExistente; }
}

// En EstacionService
public Estacion crear(Estacion nueva) {
    estacionRepositorio.buscarPorNombre(nueva.nombre()).ifPresent(existente -> {
        throw new EstacionDuplicadaException(nueva.nombre(), existente.id());
    });
    return estacionRepositorio.guardar(nueva);
}

Nótese el cambio en el repositorio: existePorNombre devolvía un boolean y ahora necesitamos buscarPorNombre, que devuelve Optional<Estacion>. Es un patrón recurrente: si al lanzar el error necesitas datos del recurso en conflicto, el repositorio tiene que devolverte el objeto, no un booleano. El manejador específico:

@ExceptionHandler(EstacionDuplicadaException.class)
public ProblemDetail manejarEstacionDuplicada(EstacionDuplicadaException e) {
    ProblemDetail problema = construir(HttpStatus.CONFLICT, "Estación duplicada", e);
    problema.setProperty("idExistente", e.getIdExistente());
    problema.setProperty("enlaceExistente", "/api/v1/estaciones/" + e.getIdExistente());
    return problema;
}
{ "type": "https://api.ciclourbana.es/errores/estacion-duplicada",
  "title": "Estación duplicada", "status": 409,
  "detail": "Ya existe una estación llamada 'Plaza Mayor'",
  "codigo": "ESTACION_DUPLICADA", "idExistente": 1,
  "enlaceExistente": "/api/v1/estaciones/1" }

El campo enlaceExistente es un toque de HATEOAS donde de verdad aporta: el panel puede ofrecer "ver la estación existente" sin construir la URL. Y aunque no existiera un manejador para EstacionDuplicadaException, la excepción seguiría respondiendo 409 gracias al de su superclase ConflictoRecursoException; el manejador específico solo añade los dos campos extra. Esa es la ventaja de una jerarquía bien diseñada.

Solución 2.

/** Campos cuyo valor NUNCA se devuelve, aunque haya fallado la validación. */
private static final Set<String> CAMPOS_SENSIBLES =
        Set.of("contrasena", "password", "token", "tarjeta", "cvv", "dni", "iban");

public record ErrorCampo(String campo, String mensaje, Object valorRechazado) {}

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

    List<ErrorCampo> errores = e.getBindingResult().getFieldErrors().stream()
            // getMessage(error, idioma) resuelve la clave contra el
            // MessageSource de 03-04 con el idioma de Accept-Language
            .map(error -> new ErrorCampo(error.getField(),
                    messageSource.getMessage(error, idioma), valorSeguro(error)))
            .toList();

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "La petición contiene %d error(es) de validación".formatted(errores.size()));
    problema.setTitle("Error de validación");
    problema.setType(BASE_TIPOS.resolve("validacion"));
    problema.setProperty("codigo", "VALIDACION");
    problema.setProperty("errores", errores);
    return problema;
}

/** Devuelve el valor rechazado salvo que el campo sea sensible. */
private Object valorSeguro(FieldError error) {
    String campo = error.getField().toLowerCase();
    if (CAMPOS_SENSIBLES.stream().anyMatch(campo::contains)) {
        return "***";
    }
    Object valor = error.getRejectedValue();
    // Truncar: un campo de texto puede traer megabytes y llenaría la respuesta
    return valor instanceof String texto && texto.length() > 100
            ? texto.substring(0, 100) + "..." : valor;
}

Con Accept-Language: en:

{ "title": "Error de validación", "status": 400,
  "detail": "La petición contiene 2 error(es) de validación",
  "codigo": "VALIDACION",
  "errores": [
    { "campo": "nombre", "mensaje": "The station name is required", "valorRechazado": "" },
    { "campo": "capacidad", "mensaje": "Capacity must be greater than zero",
      "valorRechazado": -5 } ] }

Tres decisiones que merece la pena señalar. Primera: Locale como parámetro del manejador, que Spring inyecta a partir del LocaleResolver de 03-04, de modo que messageSource.getMessage(error, idioma) resuelve {estacion.nombre.obligatorio} en el idioma correcto. Segunda: la lista de campos sensibles es una lista negra, con el problema que ya conocemos —un campo nuevo llamado numeroSeguridadSocial no estaría en ella—, así que en un sistema con datos verdaderamente sensibles lo correcto es al revés: no devolver ningún valor salvo los explícitamente marcados como seguros. Tercera: el truncado a 100 caracteres evita que un cliente malicioso provoque respuestas enormes.

Solución 3.

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

    ProblemDetail problema = ProblemDetail.forStatusAndDetail(
            HttpStatus.METHOD_NOT_ALLOWED,
            "El método %s no está permitido en esta ruta".formatted(e.getMethod()));
    problema.setTitle("Método no permitido");
    problema.setType(BASE_TIPOS.resolve("metodo-no-permitido"));
    problema.setProperty("codigo", "METODO_NO_PERMITIDO");
    problema.setProperty("metodosPermitidos", e.getSupportedMethods());

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

Ante DELETE /api/v1/estaciones, la respuesta es 405 con Allow: GET, POST y un cuerpo application/problem+json que repite los métodos en metodosPermitidos.

Por qué este caso necesita ResponseEntity. ProblemDetail describe únicamente el cuerpo de la respuesta: tiene status para que Spring fije el código, pero no tiene forma de expresar cabeceras. El RFC 9110 dice que un 405 debe incluir Allow, así que hay que envolverlo. Es exactamente el criterio que fijamos en la tabla de 03-02: ResponseEntity cuando hacen falta cabeceras, el objeto directo cuando no.

Merece la pena notar la duplicidad deliberada: los métodos permitidos aparecen en la cabecera Allow y en el campo metodosPermitidos. La cabecera es lo que exige el estándar y lo que leen los intermediarios; el campo del cuerpo es lo que la mayoría de los clientes JavaScript leerán en la práctica, porque acceder a cabeceras desde el navegador exige la configuración exposedHeaders de CORS que vimos en 03-02. Duplicar aquí cuesta una línea y ahorra un problema.

Conclusión

La deuda de cuatro lecciones queda saldada. Sabes qué hace Spring Boot por defecto —el reenvío a /error y el BasicErrorController— y por qué las propiedades server.error.include-* no bastan para una API pública, empezando por el hecho de que todo sale como 500. Has construido la jerarquía de excepciones de CicloUrbana con CicloUrbanaException como base, su campo codigo que hace la API programable sin depender del texto, y las excepciones concretas del dominio de Ribalta, cada una con su código HTTP en una tabla que es la especificación del manejador. Conoces @ResponseStatus y sus tres límites, @ExceptionHandler local y su precedencia sobre el global, y sobre todo el @RestControllerAdvice que centraliza el manejo en una sola clase, con la resolución por especificidad de tipo que permite declarar un manejador de Exception sin miedo a que se coma los demás.

Los errores de CicloUrbana hablan ya el estándar RFC 7807 Problem Details, con type, title, status, detail, instance y las extensiones propias, servidos con application/problem+json y complementados por spring.mvc.problemdetails.enabled. Los errores de validación de 03-04 devuelven por fin la lista exacta de campos erróneos, agrupados en Map<String, List<String>> para no perder ninguno, y la ConstraintViolationException ya no sale como 500. Manejas las excepciones del framework —JSON ilegible, tipo incorrecto, método no permitido, ruta inexistente— y sabes que ninguna debe filtrar e.getMessage(). Tienes la tabla de qué no debe salir nunca en un error, la política de niveles de log por gravedad, y un identificador de traza propagado con el MDC que aparece a la vez en la respuesta y en todas las líneas del log de esa petición, con el MDC.remove() en el finally que evita corromper el diagnóstico.

CicloUrbana tiene ahora una API completa: trece endpoints, validación en el borde, DTOs que separan el dominio del contrato y errores uniformes y bien formados. Y sin embargo, si mañana el equipo de la app móvil de Ribalta o el del portal de datos abiertos del ayuntamiento quisieran integrarse, tendrían que preguntarnos endpoint por endpoint qué campos acepta cada uno, qué devuelve y qué errores puede dar. Todo ese conocimiento está en el código y en nuestra cabeza, y en ninguna otra parte.

La lección 03-07, Documentar la API con OpenAPI, lo pone por escrito de forma automática y legible por máquinas. Veremos qué es OpenAPI 3.1 y por qué un contrato formal cambia la forma de trabajar entre equipos; compararemos code-first y design-first; integraremos springdoc-openapi con sus endpoints /v3/api-docs y /swagger-ui.html; documentaremos CicloUrbana con @Tag, @Operation, @Parameter, @ApiResponse y @Schema; veremos cómo las restricciones de Bean Validation de 03-04 y los Problem Details de esta lección aparecen solos en el esquema; agruparemos endpoints con GroupedOpenApi; exportaremos el openapi.json en el build para generar clientes; y cerraremos el módulo con el balance de la API antes de saltar a la persistencia real.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados