1. Las ganas de devolver la excepción y sus consecuencias
Cuando empiezas a escribir una API, la reacción más natural ante un fallo es mostrar al cliente lo que «ya está ahí»: la excepción, su mensaje y, si tienes suerte, el stack trace. Parece que así es más rápido: el desarrollador del frontend verá de inmediato dónde está el error y dejará de hacer preguntas. Por desgracia, es como tratar un dolor de cabeza con una guillotina: el problema desaparece… junto con el paciente.
En la práctica, «devolver la excepción tal cual» rompe tres cosas a la vez. Primero, empiezas a exponer las entrañas del servidor, y eso no solo es feo, sino que a veces es peligroso. Segundo, haces que el contrato público sea inestable: cambiaste una clase, actualizaste una librería, cambió el texto de la excepción, y el cliente se «rompe» porque (Dios no lo quiera) estaba parseando el mensaje como cadena. Tercero, le das a la persona (y al código del cliente) ruido en lugar de significado: al cliente le interesa entender qué pasó con la solicitud, no leer una mini-novela «Cómo JVM se entristeció en tres tomos».
Contrato de error y diagnóstico interno
Para dejar de confundirte, es útil dividir mentalmente el mundo en dos destinatarios del mismo problema. El primer destinatario es el cliente de la API (navegador, aplicación móvil, otro servicio). Necesita información compacta, estable y comprensible: qué estado, qué salió mal, qué se puede solucionar. El segundo destinatario eres tú y tu equipo. Necesitáis diagnóstico: stack trace, tipo de excepción, contexto, para corregir errores.
Estos destinatarios no solo son diferentes, sino que tienen tareas distintas, y por eso no se les deben dar los mismos datos.
Una buena imagen para tener esto en mente:
flowchart TD
%% Dos ramas: una para diagnóstico interno, otra para respuesta pública al cliente
A[Solicitud HTTP] --> B[Controlador / Servicio]
B -->|Error| C[Excepción en el código]
C --> D[Diagnóstico interno: logs, stack trace]
C --> E[Respuesta pública: estado + descripción comprensible]
Observa que no estamos discutiendo cómo hacer técnicamente una captura unificada de errores a nivel de toda la aplicación; eso será el siguiente paso. Ahora lo importante es: incluso si manejas el error «manualmente» en un solo lugar, los datos para la rama D y los datos para la rama E deben ser diferentes.
2. Stack trace y su lugar en la respuesta de la API
El stack trace es una lista detallada de «qué métodos se invocaron hasta que todo falló». Para un desarrollador, es información de oro: se ve exactamente dónde ocurrió el error, qué clases participaron, qué librerías están en la cadena. Pero para el cliente, el stack trace es casi siempre basura: es largo, multilínea, contiene nombres internos de clases y paquetes, y a veces también rutas de archivos, números de línea y fragmentos de mensajes técnicos.
Aquí tienes un típico antipatrón al estilo «bueno, al menos es informativo»:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DemoController {
@ExceptionHandler(Exception.class)
ResponseEntity<String> handle(Exception ex) {
// Mal: devolvemos al cliente detalles internos del servidor (tipo de excepción/mensaje).
// Esto no ayuda al cliente, pero expone la implementación.
return ResponseEntity.status(500).body(ex.toString());
}
}
Incluso si aquí devuelves ex.toString(), ya estás exponiendo el tipo de excepción. Y si vas más allá y añades ex.getStackTrace() (o incluso serializas toda la Exception), obtendrás en la respuesta una «sábana» enorme. En el mejor de los casos, es simplemente feo. En el peor, le regalas al atacante un mapa de las tecnologías internas y pistas sobre dónde «golpear» después.
3. Nombres internos de clases y paquetes
Incluso si no devuelves el stack trace completo, muchos caen en la «inocente» idea de devolver al cliente ex.getClass().getName() o al menos ex.getClass().getSimpleName(). Parece honesto: el cliente debe saber qué se rompió. Pero en realidad, esto vuelve a mezclar dos mundos: la clase de la excepción es un detalle de implementación, y el contrato público debe describir el error en el lenguaje de la API.
Imagina que el cliente recibe esto:
{
"error": "com.example.tasktracker.domain.exception.TaskNotFoundException"
}
Para el cliente de la API, esto no aporta significado. No conoce tu código ni está obligado a conocer tus paquetes. Pero ahora no puedes renombrar tranquilamente un paquete, mover las excepciones a otro módulo, cambiar la jerarquía de excepciones; formalmente has cambiado el contrato externo (aunque no quisieras). Y sí, parece extraño: como si un restaurante, en lugar de decir «se acabó la sopa», dijera Kitchen.StockService.NoMoreSoupException.
Por lo tanto, la regla es simple: los nombres de las clases Java son diagnóstico interno, su lugar está en los logs, no en la respuesta JSON al cliente.
4. Mensajes de excepción en bruto: ex.getMessage()
Ahora el punto más engañoso: ex.getMessage(). Parece «humano» porque es una cadena. Pero precisamente por esa «simplicidad» se empieza a devolver sin pensar. El problema es que el mensaje de la excepción casi nunca es una formulación estable y orientada al cliente.
Primero, los mensajes suelen ser puramente técnicos y pensados para el desarrollador. NullPointerException puede no tener mensaje, o tener un mensaje que depende de la versión de la JDK. DateTimeParseException puede mostrar un texto donde la mitad son detalles de parseo. HttpMessageNotReadableException (que suele aparecer con JSON malformado) puede contener «la cocina interna» de Jackson. Si se lo muestras al cliente tal cual, obtendrás algo como: Cannot deserialize value of type ... from String .... Eso no es el lenguaje de tu API. Es el lenguaje de la librería.
Segundo, los mensajes son inestables. Cambian entre versiones de librerías y frameworks. Hoy actualizas Spring Boot o Jackson, y el texto se vuelve diferente. Si en algún lugar del cliente (o en la documentación) alguien se basó en una redacción concreta, tienes un «breaking change oculto». Lo más triste es que estos errores son muy desagradables de investigar: el servidor funciona, el estado es correcto, pero el frontend «por alguna razón no reconoce el error».
Tercero, a veces el mensaje de la excepción contiene un fragmento de la entrada del usuario. Esto puede ser normal y útil, pero sin control puedes obtener fácilmente una inyección de basura en los logs o un texto «sucio» en la respuesta. El cliente puede accidental o intencionadamente enviar una cadena que parezca HTML, SQL, lo que sea. Devolver eso en la respuesta es una mala idea, especialmente si luego alguien lo muestra en la UI sin escaparlo.
Si quieres una fórmula corta: ex.getMessage() es una pista de diagnóstico, no un contrato público.
5. Errores comprensibles en el lenguaje de la API
Si no devolvemos al cliente stack trace, nombres de clases ni mensajes en bruto, ¿qué queda? Queda lo más útil: una descripción comprensible del problema en los términos de tu API. En el contexto de Task Tracker API, el cliente piensa así: «creo una tarea», «obtengo una tarea por id», «intento cambiar el estado», «adjunto un archivo». Por lo tanto, los errores deben describirse en ese mismo lenguaje.
A nivel mínimo, normalmente necesitas tres cosas. El cliente necesita el estado (ya está en HTTP), un título corto («qué pasó») y un detalle («qué exactamente está mal»). Incluso si aún no utilizas un formato de error estándar (llegaremos a ello pronto), es importante saber formular un texto controlado que:
1) no dependa de clases internas,
2) no «salte» de una versión de librería a otra,
3) no arrastre detalles innecesarios hacia afuera.
Una aclaración importante: este record no es un formato que deba fijarse como principal para toda la API. Aquí solo necesitamos un contenedor temporal para separar el texto público controlado del diagnóstico interno.
Ejemplo de un modelo «público» muy simple:
// Modelo público de error: solo lo que estamos dispuestos a mostrar conscientemente al cliente.
public record PublicError(String title, String detail) {
}
Y un ejemplo de cómo se puede describir un escenario de «no encontrado» en el lenguaje de la API:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
ResponseEntity<PublicError> notFound(String taskId) {
// Bien: formamos el texto como parte del contrato de la API, no lo tomamos de la excepción.
PublicError body = new PublicError(
"Task not found",
// En el detalle podemos incluir cuidadosamente la entrada del usuario, si controlamos el formato.
"Task with id '%s' was not found".formatted(taskId)
);
// Bien: devolvemos un estado HTTP semánticamente correcto.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
Observa: aquí no usamos ex.getMessage(). Construimos el texto como parte del contrato. Sí, es un poco más de trabajo. Pero es un trabajo que hace que la API sea madura.
Este contenedor es suficiente para sentir el principio. Pero cuando aparecen muchas respuestas de este tipo, tu propio record se convierte rápidamente en otro mini-estándar casero. Por lo tanto, aquí no necesitamos otro PublicError, sino un formato común de cuerpo de error para toda la API.
6. Los detalles, en los logs
Si al cliente no se le debe dar diagnóstico interno, surge una pregunta lógica: «¿Y dónde guardarlo para que nosotros mismos podamos corregir errores?» La respuesta es simple y aburrida (y por lo tanto, correcta): en los logs. Los logs son el lugar donde son apropiados el stack trace, las clases internas y cualquier detalle de implementación.
Para empezar, basta con un enfoque básico: cuando ocurre un error técnico, lo registramos con la excepción, y hacia afuera devolvemos una descripción controlada. En la capa de servicio, se ve así:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TaskService {
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
public void doSomethingRisky() {
try {
riskyOperation();
} catch (RuntimeException ex) {
// En los logs se puede (y se debe) dejar el stack trace: es diagnóstico interno.
log.error("Error técnico inesperado en TaskService", ex);
// Aquí no «convertimos el error en null» ni lo sustituimos.
// Que la capa superior (por ejemplo, el manejador de excepciones) decida qué devolver al cliente.
throw ex;
}
}
private void riskyOperation() {
// Aquí podría haber trabajo con BD, servicio externo, archivo, etc.
}
}
Aquí lo importante es la idea en sí. No «escondemos» el error, no hacemos como si todo estuviera bien, no convertimos el fallo en un misterioso null. Simplemente registramos el diagnóstico donde debe estar (en los logs) y no lo arrastramos a la API pública.
Y otra idea importante: también hay que loguear con cabeza. No hay que loguear todo el cuerpo de la solicitud en cada petición (especialmente si puede contener datos personales). No hay que loguear objetos enormes. Los logs no son un vertedero, son una herramienta de diagnóstico. Pero eso ya es un ajuste fino de la cultura de ingeniería; hoy nos importa el fundamento: el diagnóstico se queda dentro del servidor.
7. Mini-ejemplo en Task Tracker API
Para relacionarlo con nuestro proyecto, tomemos el escenario GET /api/v1/tasks/{taskId}. Imaginemos que el servicio lanza TaskNotFoundException si la tarea no existe. La peor forma de manejarlo es capturar la excepción y devolver todo tal cual.
Antipatrón:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
public class TaskController {
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getById(@PathVariable String taskId) {
try {
Object task = findTask(taskId);
return ResponseEntity.ok(task);
} catch (Exception ex) {
// Mal: devolvemos "tipo + mensaje" de la excepción.
// Esto es inestable y puede filtrar detalles internos (BD, tablas, tecnologías).
return ResponseEntity.status(500).body(ex.toString());
}
}
private Object findTask(String taskId) {
// Ejemplo de detalle técnico que no debería aparecer en la respuesta de la API.
throw new RuntimeException("DB is down, table TASKS not found");
}
}
Hay varios problemas aquí, pero para nuestra lección el clave es que devuelves ex.toString(), es decir, «tipo + mensaje». Y el mensaje puede contener cualquier cosa, incluidos detalles internos. Además, devuelves 500 para todo, incluso si el problema no es del servidor sino que la tarea no existe.
Ahora hagámoslo mejor, pero teniendo en cuenta la limitación: el PublicError de abajo sigue siendo un contenedor temporal. El sentido del ejemplo no es fijar tu propio formato de error, sino no arrastrar ex.toString() hacia afuera. Formamos un cuerpo controlado y devolvemos un estado comprensible. El diagnóstico (si es necesario) lo registramos.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public class TaskController {
private static final Logger log = LoggerFactory.getLogger(TaskController.class);
public ResponseEntity<?> getById(String taskId) {
try {
Object task = findTask(taskId);
return ResponseEntity.ok(task);
} catch (TaskNotFoundException ex) {
// Bien: error de dominio/negocio -> estado comprensible y texto claro para el cliente.
// No usamos ex.getMessage() ni exponemos el nombre de la clase de la excepción.
PublicError body = new PublicError(
"Task not found",
"Task with id '%s' was not found".formatted(taskId)
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
} catch (Exception ex) {
// Bien: error técnico -> detalles en los logs.
// {} es un placeholder para taskId, ex lo pasamos aparte para obtener el stack trace en los logs.
log.error("Error técnico al obtener la tarea {}", taskId, ex);
// Hacia afuera devolvemos un texto controlado sin detalles internos.
PublicError body = new PublicError(
"Internal error",
"Unexpected error while processing request"
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
private Object findTask(String taskId) {
// Para el ejemplo: el error de dominio "tarea no encontrada" se representa con una excepción separada.
throw new TaskNotFoundException(taskId);
}
}
Arquitectónicamente es una variante de transición, pero muestra bien lo principal. El cliente recibe un mensaje comprensible en el lenguaje de la API, y el servidor guarda los detalles para sí mismo. Y tan pronto como este PublicError empiece a repetirse en los controladores, queda claro: se necesita un formato estándar único de cuerpo de error, y no un record propio para cada caso.
8. Errores típicos al responder con errores
Cuando empieces a «limpiar» las respuestas de error, los primeros problemas no suelen venir por falta de conocimiento de Spring, sino por hábitos humanos. Es muy tentador «que el cliente entienda más rápido», «que el frontend no moleste», «que sea más fácil escribir tests». Son deseos comprensibles, pero a menudo llevan a que la API se convierta en un vertedero de información interna.
Error nº1: devolver stackTrace «solo en desarrollo» y luego olvidarse de desactivarlo.
Esto ocurre más a menudo de lo que nos gustaría admitir. Primero creas un «modo de depuración útil», luego aparece un segundo perfil, un tercero, luego staging, y luego «uy, ¿por qué el cliente en producción tiene en la respuesta la ruta /home/jenkins/workspace/...?». Si construyes la API como un contrato, es mejor acostumbrarse desde el principio: el stack trace vive en los logs, no en la respuesta.
Error nº2: pensar que el nombre de la excepción es un «código de error».
El nombre de la clase parece un buen identificador, pero es un identificador para código Java, no para un contrato externo. Cambia con la refactorización y conlleva detalles internos. El «código de error» del cliente (si lo necesitas) debe ser una entidad separada, independiente de paquetes y clases.
Error nº3: devolver ex.getMessage() como si fuera un texto para el usuario.
Los mensajes de las excepciones rara vez están formulados «de forma humana» y aún más raramente son estables. Además, pueden cambiar entre versiones de librerías. Incluso si un mensaje concreto hoy parece decente, mañana actualizarás una dependencia y obtendrás una nueva redacción que rompe la UX o los tests del cliente.
Error nº4: mezclar el significado público y el diagnóstico interno en un mismo campo.
A menudo se ven respuestas del tipo: «Task not found. NullPointerException at TaskRepository.findById(TaskRepository.java:42)». Es un intento de complacer a todos a la vez, pero suele salir mal. El cliente no necesita el número de línea en el repositorio, y el desarrollador no necesita un «cuento» al estilo «task not found» sin contexto. Separa: el significado público, en la respuesta; el diagnóstico interno, en los logs.
Error nº5: hacer el texto del error demasiado genérico para que «no se filtre nada».
El extremo opuesto es devolver para todo Error o Something went wrong. Formalmente no has filtrado nada, pero el contrato se ha vuelto inútil. El cliente necesita al menos categorías comprensibles en el lenguaje de la API: Task not found, Invalid input, Operation is not allowed. Sin esto, el cliente no puede reaccionar adecuadamente, y al usuario le resulta difícil corregir la solicitud.
GO TO FULL VERSION