El Kobayashi Maru de Java: Manejo Global de Excepciones

Esta publicación fue originalmente escrita en inglés. La traducción puede no reflejar el 100% de las ideas originales del autor.

Para quienes se saltaron las clases de la Academia de la Flota Estelar, el Kobayashi Maru es un ejercicio de entrenamiento diseñado como un “escenario sin victoria”. El objetivo no es ganar, es ver cómo manejas un fracaso inevitable. En el mundo de la Ingeniería de Backend, nuestro escenario sin victoria es la Excepción No Controlada.

Pasas semanas diseñando un servicio hermoso y limpio. Usas Records, optimizas tus consultas SQL, aplicas principios SOLID. Y luego, el día de producción, un usuario envía un JSON malformado, y tu API vomita un Stack Trace de 50 líneas directamente en la consola de su navegador. Es feo, es poco profesional y expone tu lógica interna al mundo.

Excepción!

La forma amateur de manejar esto es llenar tu base de código con bloques try-catch. Envuelves cada método del Controller en una manta de seguridad, duplicando código y haciendo tu lógica ilegible.

Pero como estamos usando Spring Boot, podemos hacer lo que hizo el Capitán Kirk: podemos reprogramar la simulación. No tenemos que luchar contra la excepción en cada método, podemos capturarla globalmente antes de que salga de la esclusa de aire.

La Estrategia: @ControllerAdvice

Spring proporciona una anotación llamada @ControllerAdvice. Piensa en ella como un interceptor global. Se sitúa por encima de todos tus controladores, observando cualquier error que suba a la superficie. Si se lanza una excepción en cualquier parte de tu aplicación, este componente la captura, la formatea y devuelve una respuesta JSON educada en lugar de un error crudo.

Implementemos un mecanismo de manejo de errores centralizado y limpio.

Paso 1: Definir el Error Estándar

Primero, dejamos de devolver Strings o Maps aleatorios. Necesitamos un contrato. Como estamos en Java 21, usemos un record para crear una estructura de datos inmutable y concisa para nuestros errores.

package me.doismiu.api.exception;

import java.time.LocalDateTime;

public record ApiError(
    LocalDateTime timestamp,
    int status,
    String error,
    String path
) {
    public ApiError(int status, String error, String path) {
        this(LocalDateTime.now(), status, error, path);
    }
}

Paso 2: El Manejador Global

Ahora, ocurre la magia. Creamos una clase anotada con @ControllerAdvice. Dentro, definimos métodos anotados con @ExceptionHandler para cada problema específico que queramos capturar.

Yo suelo separarlos en dos categorías: Errores Lógicos Esperados (como “Usuario No Encontrado”) y Lo Inesperado (NullPointers, caídas de Base de Datos).


package me.doismiu.api.exception;

import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    // Escenario 1: El usuario pidió algo que no existe.
    // Devolvemos un 404 (No Encontrado), no un 500 (Error del Servidor).
    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiError> handleEntityNotFound(
            EntityNotFoundException ex,
            HttpServletRequest request
    ) {
        ApiError error = new ApiError(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Escenario 2: Validación de Lógica de Negocio (ej., "Fondos Insuficientes")
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ApiError> handleBadRequest(
            IllegalArgumentException ex,
            HttpServletRequest request
    ) {
        ApiError error = new ApiError(
                HttpStatus.BAD_REQUEST.value(),
                ex.getMessage(),
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    // Escenario 3: El "Kobayashi Maru" (Todo lo demás)
    // Esto captura NullPointers, errores SQL y cualquier cosa que no hayamos previsto.
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleGenericException(
            Exception ex,
            HttpServletRequest request
    ) {
        // Registramos el stack trace real internamente para poder arreglarlo después
        // Pero NUNCA lo mostramos al cliente.
        ex.printStackTrace();

        ApiError error = new ApiError(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Ocurrió un error inesperado. Por favor, contacte con soporte.",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Por Qué Esto Importa

Al hacer esto, desacoplas el manejo de errores de la lógica de negocio. Tus clases de Servicio pueden simplemente throw new EntityNotFoundException("Usuario id 5 no encontrado"), y no tienen que preocuparse por los estados HTTP o el formato JSON. El GlobalExceptionHandler se encarga de la traducción.

Convierte un caótico fallo en un aterrizaje controlado. El cliente recibe un JSON limpio que explica lo que pasó, y tu código permanece limpio, legible y centrado en el camino feliz.

No has “ganado” necesariamente (el error aún ocurrió), pero has cambiado las reglas del juego para asegurarte de que no pierdes.