Manejo avanzado de errores con ProblemDetails y correlation IDs

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-error-handling-avanzado

El problema con el manejo básico de errores

El manejo intermedio de errores en Spring Boot consiste en capturar excepciones con un @RestControllerAdvice y devolver un ResponseEntity con un body simple:

// Manejo intermedio (limitado)
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EmpleadoNotFoundException.class)
    public ResponseEntity<Map<String, String>> manejarNoEncontrado(EmpleadoNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(Map.of("error", e.getMessage()));
    }
}

Esto funciona en proyectos pequeños pero rompe en producción enterprise:

  • No hay formato estándar: cada API devuelve sus errores en un JSON diferente, lo que dificulta integraciones con clientes y herramientas de monitoring.
  • No hay trazabilidad: si el cliente reporta "fallo a las 14:32", no hay forma de localizar el log correspondiente entre miles.
  • No hay catálogo: los mensajes están dispersos por el código en español, sin forma de internacionalizar.
  • No hay distinción cliente/servidor: errores 4xx (culpa del cliente) y 5xx (culpa del servidor) reciben el mismo tratamiento, cuando deberían ser radicalmente distintos.

Spring Boot 4 trae ProblemDetails (RFC 7807) como estándar y, combinado con un patrón de correlation IDs y un catálogo de errores, resuelve los cuatro problemas.

ProblemDetails RFC 7807

RFC 7807 es un estándar IETF que define un formato JSON para errores en APIs REST. Spring Framework 6+ lo implementa nativamente con la clase ProblemDetail.

Una respuesta ProblemDetails tiene esta forma:

{
  "type": "https://api.empresa.com/problemas/empleado-no-encontrado",
  "title": "Empleado no encontrado",
  "status": 404,
  "detail": "No existe ningún empleado con identificador 42",
  "instance": "/api/empleados/42",
  "traceId": "abc-123-def-456",
  "timestamp": "2026-05-07T18:30:42Z",
  "errorCode": "EMPLOYEE.NOT_FOUND"
}

Los campos type, title, status, detail e instance son del estándar. Los demás (traceId, timestamp, errorCode) son extensiones específicas de la aplicación, perfectamente válidas según el RFC.

Habilitar ProblemDetail en Spring Boot 4

A partir de Spring Boot 3.2 (y por defecto en 4), basta con activar la propiedad:

spring:
  mvc:
    problemdetails:
      enabled: true

Con esto, Spring genera automáticamente respuestas en formato ProblemDetail para excepciones estándar (MethodArgumentNotValidException, HttpMessageNotReadableException, etc.) sin escribir un solo handler.

Para excepciones personalizadas, se construyen con ProblemDetail.forStatusAndDetail:

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EmpleadoNotFoundException.class)
    ProblemDetail manejarNoEncontrado(EmpleadoNotFoundException e) {
        var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "No existe ningún empleado con identificador " + e.getId()
        );
        problem.setTitle("Empleado no encontrado");
        problem.setType(URI.create("https://api.empresa.com/problemas/empleado-no-encontrado"));
        problem.setProperty("errorCode", "EMPLOYEE.NOT_FOUND");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

Devolver un ProblemDetail directamente desde el handler funciona porque Spring lo serializa con el Content-Type: application/problem+json correcto.

Correlation IDs propagados en MDC

Un correlation ID es un identificador único por petición que se propaga por toda la pila: del cliente al gateway, del gateway al microservicio A, del A al B, y queda anotado en cada log. Permite reconstruir el flujo completo de una petición a partir de un único valor.

Filtro que asigna correlation ID

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {

    public static final String HEADER = "X-Correlation-ID";
    public static final String MDC_KEY = "correlationId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        var correlationId = Optional.ofNullable(request.getHeader(HEADER))
            .filter(s -> !s.isBlank())
            .orElseGet(() -> UUID.randomUUID().toString());

        MDC.put(MDC_KEY, correlationId);
        response.setHeader(HEADER, correlationId);

        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove(MDC_KEY);
        }
    }
}

Patrón de logs con correlation ID

En application.yml:

logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{correlationId:-}] %-5level %logger{36} - %msg%n"

El %X{correlationId:-} extrae el valor del MDC. Si no existe, queda vacío. Cada línea de log queda así:

2026-05-07 18:30:42.123 [http-nio-8080-exec-1] [abc-123-def-456] INFO  c.e.s.EmpleadoService - Buscando empleado id=42
2026-05-07 18:30:42.156 [http-nio-8080-exec-1] [abc-123-def-456] WARN  c.e.s.EmpleadoService - Empleado 42 no encontrado

Buscando por abc-123-def-456 en la herramienta de logs (Loki, Elasticsearch, Splunk) se reconstruye el flujo completo.

Correlation ID en respuesta de error

El handler del error añade el correlation ID al ProblemDetail:

@ExceptionHandler(EmpleadoNotFoundException.class)
ProblemDetail manejarNoEncontrado(EmpleadoNotFoundException e) {
    var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
        "No existe ningún empleado con identificador " + e.getId());
    problem.setTitle("Empleado no encontrado");
    problem.setProperty("errorCode", "EMPLOYEE.NOT_FOUND");
    problem.setProperty("traceId", MDC.get("correlationId"));
    problem.setProperty("timestamp", Instant.now());
    return problem;
}

Cuando el cliente reporta un error con su correlation ID, el operador encuentra el log instantáneamente.

Propagación entre microservicios

Para que el correlation ID viaje entre servicios, el cliente HTTP (RestClient o WebClient) debe propagar el header. Con RestClient y un interceptor:

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .requestInterceptor((request, body, execution) -> {
                var correlationId = MDC.get("correlationId");
                if (correlationId != null) {
                    request.getHeaders().set("X-Correlation-ID", correlationId);
                }
                return execution.execute(request, body);
            })
            .build();
    }
}

El servicio receptor, gracias al CorrelationIdFilter, lee el header y lo pone en su propio MDC. La cadena se preserva.

Catálogo de errores con códigos e i18n

En aplicaciones grandes conviene separar el código del error (técnico, estable) del mensaje (presentable, traducible).

Enum de códigos

public enum ErrorCode {
    EMPLOYEE_NOT_FOUND("EMPLOYEE.NOT_FOUND", HttpStatus.NOT_FOUND),
    EMPLOYEE_DUPLICATE("EMPLOYEE.DUPLICATE", HttpStatus.CONFLICT),
    SALARY_BELOW_MINIMUM("SALARY.BELOW_MINIMUM", HttpStatus.BAD_REQUEST),
    DEPARTMENT_FULL("DEPARTMENT.FULL", HttpStatus.UNPROCESSABLE_ENTITY),
    INTERNAL_ERROR("SYSTEM.INTERNAL", HttpStatus.INTERNAL_SERVER_ERROR);

    private final String code;
    private final HttpStatus httpStatus;

    ErrorCode(String code, HttpStatus httpStatus) {
        this.code = code;
        this.httpStatus = httpStatus;
    }

    public String code() { return code; }
    public HttpStatus httpStatus() { return httpStatus; }
}

Mensajes en messages.properties

# messages_es.properties
error.EMPLOYEE.NOT_FOUND.title=Empleado no encontrado
error.EMPLOYEE.NOT_FOUND.detail=No existe ningún empleado con identificador {0}

error.EMPLOYEE.DUPLICATE.title=Empleado duplicado
error.EMPLOYEE.DUPLICATE.detail=Ya existe un empleado con email {0}

error.SALARY.BELOW_MINIMUM.title=Salario por debajo del mínimo
error.SALARY.BELOW_MINIMUM.detail=El salario {0} es inferior al mínimo permitido {1}
# messages_en.properties
error.EMPLOYEE.NOT_FOUND.title=Employee not found
error.EMPLOYEE.NOT_FOUND.detail=No employee exists with identifier {0}

error.EMPLOYEE.DUPLICATE.title=Duplicate employee
error.EMPLOYEE.DUPLICATE.detail=An employee with email {0} already exists

error.SALARY.BELOW_MINIMUM.title=Salary below minimum
error.SALARY.BELOW_MINIMUM.detail=Salary {0} is below the allowed minimum {1}

Excepción de negocio con código y argumentos

public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;
    private final Object[] args;

    public BusinessException(ErrorCode errorCode, Object... args) {
        super(errorCode.code());
        this.errorCode = errorCode;
        this.args = args;
    }

    public ErrorCode errorCode() { return errorCode; }
    public Object[] args() { return args; }
}

// Subclases tipadas para claridad
public class EmpleadoNotFoundException extends BusinessException {
    public EmpleadoNotFoundException(Long id) {
        super(ErrorCode.EMPLOYEE_NOT_FOUND, id);
    }
}

public class SalarioInferiorMinimoException extends BusinessException {
    public SalarioInferiorMinimoException(BigDecimal salario, BigDecimal minimo) {
        super(ErrorCode.SALARY_BELOW_MINIMUM, salario, minimo);
    }
}

Handler central con i18n

@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class GlobalExceptionHandler {

    private final MessageSource messageSource;

    @ExceptionHandler(BusinessException.class)
    ProblemDetail manejarBusiness(BusinessException e, Locale locale) {
        var code = e.errorCode();
        var title = messageSource.getMessage(
            "error." + code.code() + ".title", null, code.code(), locale);
        var detail = messageSource.getMessage(
            "error." + code.code() + ".detail", e.args(), code.code(), locale);

        var problem = ProblemDetail.forStatusAndDetail(code.httpStatus(), detail);
        problem.setTitle(title);
        problem.setType(URI.create("https://api.empresa.com/problemas/" + code.code().toLowerCase()));
        problem.setProperty("errorCode", code.code());
        problem.setProperty("traceId", MDC.get("correlationId"));
        problem.setProperty("timestamp", Instant.now());

        // Log apropiado al tipo de error
        if (code.httpStatus().is4xxClientError()) {
            log.info("Error de cliente: code={}, args={}", code.code(), e.args());
        } else {
            log.error("Error de servidor: code={}", code.code(), e);
        }

        return problem;
    }
}

El cliente envía Accept-Language: en-US y recibe el mensaje en inglés. Accept-Language: es-ES y recibe el mensaje en español. La aplicación queda lista para internacionalización sin cambiar el código de negocio.

Errores 4xx vs 5xx

Distinguirlos en el log es crítico:

  • 4xx (cliente): el cliente envió algo inválido. Log a INFO. No es bug del servidor.
  • 5xx (servidor): la aplicación falló. Log a ERROR con el stacktrace completo. Es bug.

Confundirlos genera dos problemas:

  • Loggear con ERROR un 404 o un 400 inunda el sistema de monitoring de "errores" que en realidad son comportamiento normal del cliente.
  • Loggear con INFO un NullPointerException oculta bugs reales que deberían disparar alertas.
@ExceptionHandler(BusinessException.class)
ProblemDetail manejarBusiness(BusinessException e, Locale locale) {
    // ... construir ProblemDetail ...

    if (e.errorCode().httpStatus().is4xxClientError()) {
        log.info("Error de cliente: {}", e.errorCode().code());
    } else if (e.errorCode().httpStatus().is5xxServerError()) {
        log.error("Error interno: {}", e.errorCode().code(), e);
    }
    return problem;
}

@ExceptionHandler(Exception.class)  // catch-all
ProblemDetail manejarInesperado(Exception e) {
    log.error("Excepción no controlada", e);  // ERROR + stacktrace
    var problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "Error interno del servidor. Contacta con soporte indicando el traceId.");
    problem.setTitle("Error interno");
    problem.setProperty("errorCode", "SYSTEM.INTERNAL");
    problem.setProperty("traceId", MDC.get("correlationId"));
    return problem;
}

Validación de entrada con detalle de campos

Spring Boot 4 ya formatea bien MethodArgumentNotValidException, pero conviene personalizarlo para añadir el correlation ID y el código de error:

@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail manejarValidacion(MethodArgumentNotValidException e, Locale locale) {
    var camposInvalidos = e.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.<String, Object>of(
            "field", fe.getField(),
            "message", fe.getDefaultMessage(),
            "rejectedValue", String.valueOf(fe.getRejectedValue())
        ))
        .toList();

    var problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Una o varias propiedades del cuerpo de la petición son inválidas");
    problem.setTitle("Validación fallida");
    problem.setProperty("errorCode", "VALIDATION.FAILED");
    problem.setProperty("traceId", MDC.get("correlationId"));
    problem.setProperty("invalidFields", camposInvalidos);

    return problem;
}

Respuesta:

{
  "type": "about:blank",
  "title": "Validación fallida",
  "status": 400,
  "detail": "Una o varias propiedades del cuerpo de la petición son inválidas",
  "instance": "/api/empleados",
  "errorCode": "VALIDATION.FAILED",
  "traceId": "abc-123-def-456",
  "invalidFields": [
    {"field": "email", "message": "must be a well-formed email address", "rejectedValue": "no-es-email"},
    {"field": "salario", "message": "must be greater than or equal to 0", "rejectedValue": "-100"}
  ]
}

Buenas prácticas

  • Un handler por categoría, no uno por excepción. Captura BusinessException (negocio), MethodArgumentNotValidException (validación), Exception (catch-all). Las subclases de BusinessException se manejan polimórficamente.
  • Nunca devolver el stacktrace al cliente. Solo en local con un perfil específico. En producción, el cliente recibe el traceId y los detalles van al log.
  • Códigos estables, mensajes traducibles. Los códigos no cambian (son parte del contrato API). Los mensajes se traducen.
  • Correlation ID en cada respuesta. Sin él, los reportes de errores son irresolubles.
  • Tests del handler: probar que cada ExceptionHandler devuelve el JSON esperado con el código correcto. Es contrato de API.

Un manejo de errores correcto es transparente para el cliente, trazable para el operador y mantenible para el equipo. Los tres beneficios vienen de aplicar ProblemDetails + correlation IDs + catálogo i18n de forma sistemática.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Boot

Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Diseñar respuestas de error con ProblemDetails RFC 7807. Propagar correlation IDs en MDC y entre servicios. Crear un catálogo de errores con códigos e i18n. Centralizar el manejo de excepciones con @RestControllerAdvice. Diferenciar errores de cliente y de servidor con criterio.