Cómo Implementar un Catálogo de Errores
Guía práctica para diseñar un catálogo de errores centralizado en un backend grande y consumirlo desde el frontend con manejadores personalizados por código, mensajes claros y trazabilidad.
En un backend pequeño, lanzar un throw new Error('algo falló') puede bastar. En un sistema grande, con decenas de servicios, varios equipos y un frontend que debe reaccionar a cada fallo, esa improvisación se paga cara: mensajes inconsistentes, logs imposibles de rastrear y usuarios que ven Algo salió mal sin saber qué hacer. Un catálogo de errores resuelve esto convirtiendo los errores en un contrato explícito y compartido.
1. Qué es un catálogo de errores
Es una lista centralizada y versionada de todos los errores que el sistema puede producir. Cada entrada define un código único y todo lo necesario para tratarlo de forma consistente en backend, frontend, soporte y monitorización.
2. Anatomía de un buen error
| Campo | Propósito | Ejemplo |
|---|---|---|
code | Identificador único y estable | ORD-1042 |
httpStatus | Código HTTP asociado | 409 |
category | Tipo de error | BUSINESS |
message | Mensaje técnico (para logs y desarrolladores) | Insufficient stock for item |
retryable | Indica si tiene sentido reintentar | false |
docsUrl | Enlace a la documentación del error | /errors/ORD-1042 |
Regla clave: el backend devuelve códigos y datos, no textos finales para el usuario. El texto visible y la reacción de la interfaz los decide el frontend.
3. Convención de nomenclatura
- Prefijo por dominio:
AUTH,ORD(pedidos),PAY(pagos),USR(usuarios). - Número por rango: por ejemplo 1000-1999 validación, 2000-2999 negocio, 5000-5999 infraestructura.
- Nunca reutilizar un código. Si un error se elimina, se marca como deprecado; su código no se vuelve a asignar.
- Cada dominio es dueño de su prefijo, así se evitan colisiones entre equipos.
4. Categorías de errores
- VALIDATION: datos de entrada incorrectos (400/422).
- AUTHENTICATION / AUTHORIZATION: sesión inválida o falta de permisos (401/403).
- BUSINESS: reglas de negocio incumplidas (409/422).
- NOT_FOUND: el recurso no existe (404).
- EXTERNAL: falla de un servicio de terceros (502/503/504).
- INTERNAL: errores inesperados (500).
5. El catálogo como fuente única de verdad
Define el catálogo en un archivo declarativo (JSON o YAML) dentro de un repositorio compartido o paquete interno. A partir de él se generan las clases del backend, los tipos del frontend y la documentación.
{
"ORD-1042": {
"httpStatus": 409,
"category": "BUSINESS",
"message": "Insufficient stock for item",
"retryable": false
},
"PAY-3001": {
"httpStatus": 503,
"category": "EXTERNAL",
"message": "Payment provider unavailable",
"retryable": true
}
}
6. Implementación en el backend
6.1 Una excepción base
Toda la lógica de negocio lanza una única clase de error que referencia el catálogo. Los ejemplos están en TypeScript, pero el patrón es igual en Java, C#, Python o Go.
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly details?: Record<string, unknown>
) {
super(catalog[code].message);
}
}
// Uso en el dominio
throw new AppError('ORD-1042', { itemId: 'abc', requested: 5, available: 2 });
6.2 Un único punto de traducción
Un manejador global captura los errores y los convierte en una respuesta uniforme. Ningún controlador debería construir respuestas de error a mano.
app.use((err, req, res, next) => {
const code = err instanceof AppError ? err.code : 'SYS-5000';
const def = catalog[code];
logger.error({ code, traceId: req.traceId, stack: err.stack });
res.status(def.httpStatus).json({
error: {
code,
message: def.message,
category: def.category,
details: err.details,
retryable: def.retryable,
traceId: req.traceId
}
});
});
6.3 Contrato de respuesta
{
"error": {
"code": "ORD-1042",
"message": "Insufficient stock for item",
"category": "BUSINESS",
"details": { "itemId": "abc", "requested": 5, "available": 2 },
"retryable": false,
"traceId": "9f2c1e7a-41b3-4c8e-a7d1"
}
}
El traceId es oro puro: el usuario lo ve en pantalla, soporte lo pide, y con él se encuentra toda la traza en los logs de todos los servicios.
7. Errores en servicios y sistemas asíncronos
- Entre microservicios: propaga el código original. Si el servicio de pagos devuelve
PAY-3001, el gateway no debe convertirlo en un 500 genérico. - Anti-corrupción: los errores de terceros se traducen a códigos propios; los proveedores externos no deben filtrarse a tu contrato.
- Colas y mensajería: usa el campo
retryablepara decidir. Los errores reintentables vuelven a la cola con backoff; los no reintentables van directamente a una dead-letter queue, con el código de error en las cabeceras del mensaje.
8. Cómo se trabaja en el frontend
Aquí es donde el catálogo demuestra su valor. El frontend ya no depende de interpretar textos ni de adivinar por el status HTTP: reacciona al código.
8.1 Diccionario de mensajes por código
El texto para el usuario vive en el frontend, en sus archivos de internacionalización. Así se puede traducir, adaptar el tono y cambiar sin tocar el backend.
// es.json
{
"ORD-1042": "No hay stock suficiente. Reduce la cantidad e inténtalo de nuevo.",
"PAY-3001": "No pudimos procesar el pago ahora mismo. Inténtalo en unos minutos.",
"SYS-5000": "Ocurrió un error inesperado. Si persiste, contacta a soporte."
}
8.2 Un interceptor que normaliza el error
Un único punto convierte cualquier fallo de red o respuesta del servidor en un objeto ApiError con la forma del contrato. Si la respuesta no trae el formato esperado (caída de red, proxy, timeout), se normaliza a un código de sistema.
export interface ApiError {
code: string;
category: string;
details?: Record<string, unknown>;
retryable: boolean;
traceId?: string;
}
api.interceptors.response.use(
(res) => res,
(error) => {
const body = error.response?.data?.error;
const apiError: ApiError = body ?? {
code: error.response ? 'SYS-5000' : 'NET-0001',
category: error.response ? 'INTERNAL' : 'EXTERNAL',
retryable: !error.response
};
return Promise.reject(apiError);
}
);
8.3 Una interfaz de manejadores por error
Para no llenar los componentes de if (error.code === ...), se define un contrato: un manejador es una función que recibe el error y un contexto de acciones inyectadas (navegar, notificar, cerrar sesión, marcar campos de un formulario, reintentar). El manejador no importa nada de la UI directamente; solo usa lo que se le inyecta. Eso lo hace testeable y reutilizable.
// Acciones que la aplicación inyecta a cada manejador
export interface ErrorContext {
notify: (message: string, type?: 'error' | 'warning' | 'info') => void;
navigate: (path: string) => void;
logout: () => void;
setFieldErrors: (errors: Record<string, string>) => void;
retry?: () => Promise<unknown>;
t: (key: string) => string;
}
// Un manejador recibe el error y el contexto
export type ErrorHandler = (
error: ApiError,
ctx: ErrorContext
) => void | Promise<void>;
export type ErrorHandlerMap = Record<string, ErrorHandler>;
8.4 Registro de manejadores y resolución
Cada código puede tener su lógica propia. Si no existe un manejador exacto, se busca uno por prefijo de dominio, luego por categoría y, por último, uno por defecto. Así los errores nuevos nunca quedan sin tratar.
// Manejadores específicos por código
export const handlersByCode: ErrorHandlerMap = {
'ORD-1042': (error, ctx) => {
const { available } = error.details as { available: number };
ctx.notify(ctx.t('ORD-1042'), 'warning');
ctx.setFieldErrors({ quantity: 'Máximo disponible: ' + available });
},
'PAY-3001': async (error, ctx) => {
ctx.notify(ctx.t('PAY-3001'), 'error');
if (error.retryable && ctx.retry) await ctx.retry();
},
'AUTH-1001': (_error, ctx) => {
ctx.logout();
ctx.navigate('/login');
}
};
// Manejadores por categoría (fallback)
export const handlersByCategory: ErrorHandlerMap = {
VALIDATION: (error, ctx) => {
const fields = (error.details?.fields ?? {}) as Record<string, string>;
ctx.setFieldErrors(fields);
},
AUTHORIZATION: (_e, ctx) => ctx.navigate('/forbidden'),
NOT_FOUND: (_e, ctx) => ctx.navigate('/404'),
INTERNAL: (error, ctx) =>
ctx.notify(ctx.t('SYS-5000') + ' (ref: ' + error.traceId + ')', 'error')
};
// Manejador por defecto
const defaultHandler: ErrorHandler = (error, ctx) =>
ctx.notify(ctx.t(error.code) || ctx.t('SYS-5000'), 'error');
// Resolución: código exacto -> categoría -> defecto
export function resolveHandler(error: ApiError): ErrorHandler {
return (
handlersByCode[error.code] ??
handlersByCategory[error.category] ??
defaultHandler
);
}
// Punto de entrada único
export async function handleError(
error: ApiError,
ctx: ErrorContext,
overrides: ErrorHandlerMap = {}
) {
const handler = overrides[error.code] ?? resolveHandler(error);
await handler(error, ctx);
}
8.5 Inyectar el contexto (ejemplo con React)
Un hook construye el ErrorContext con las herramientas reales de la aplicación (router, sistema de notificaciones, autenticación) y expone una única función handle. Los componentes no saben nada de cada error concreto.
export function useErrorHandler(extra: Partial<ErrorContext> = {}) {
const navigate = useNavigate();
const { notify } = useToast();
const { logout } = useAuth();
const { t } = useTranslation();
const ctx: ErrorContext = {
notify,
navigate,
logout,
t,
setFieldErrors: () => {},
...extra
};
return (error: ApiError, overrides?: ErrorHandlerMap) =>
handleError(error, ctx, overrides);
}
Uso en un componente: se inyecta lo específico de esa pantalla (por ejemplo, cómo marcar los campos del formulario o cómo reintentar) y se llama al manejador con el error recibido.
function CheckoutForm() {
const form = useForm();
const handle = useErrorHandler({
setFieldErrors: (errors) => form.setErrors(errors),
retry: () => submitOrder(form.values)
});
async function onSubmit() {
try {
await submitOrder(form.values);
} catch (error) {
await handle(error as ApiError);
}
}
return <form onSubmit={onSubmit}>...</form>;
}
8.6 Sobrescribir la lógica en un caso concreto
A veces una pantalla necesita tratar un error de forma distinta a la global. Para eso existe el tercer parámetro overrides: un mapa código a función que tiene prioridad sobre el registro general, sin modificarlo.
await handle(error, {
'ORD-1042': (err, ctx) => {
// Aquí, en el carrito, ofrecemos ajustar la cantidad automáticamente
openAdjustQuantityModal(err.details);
}
});
También puedes enganchar el manejador de forma global en tu capa de datos (por ejemplo, el onError de React Query o un interceptor de rutas), de modo que los errores no capturados localmente pasen siempre por handleError.
8.7 Qué suele hacer cada tipo de error
- VALIDATION: marcar los campos concretos del formulario usando
details. - AUTHENTICATION: redirigir al login o refrescar el token.
- retryable = true: botón de reintentar o reintento automático con backoff.
- BUSINESS: mensaje contextual junto a la acción que falló, o un modal que ofrezca una alternativa.
- INTERNAL: mensaje genérico con el
traceIdvisible para que el usuario lo comparta con soporte.
8.8 Ventajas de este enfoque
- Los componentes quedan limpios: no contienen lógica de errores dispersa.
- Testeable: cada manejador es una función pura a la que se le pasa un contexto simulado.
- Escalable: añadir un error nuevo es añadir una entrada al catálogo, un texto y, si hace falta, un manejador.
- Degradación segura: un código desconocido cae en la categoría o en el manejador por defecto, nunca en una pantalla en blanco.
9. Tipos compartidos y documentación automática
Para evitar desajustes entre backend y frontend, genera desde el catálogo un paquete con los códigos como tipos (type ErrorCode = 'ORD-1042' | 'PAY-3001' | ...). El compilador avisará si un manejador o un texto referencia un código que no existe, o si falta la traducción de uno nuevo. Del mismo archivo puedes generar la página de documentación /errors/<code> para soporte y clientes de la API.
10. Mantenimiento y buenas prácticas
- Versiona el catálogo y revisa los cambios mediante pull requests, como cualquier contrato público.
- Deprecia, no borres: los clientes antiguos pueden seguir recibiendo códigos viejos.
- Nunca expongas trazas, consultas SQL ni datos sensibles en
messageodetails. - Mide: cuenta errores por código en tu monitorización; los códigos más frecuentes te dicen dónde mejorar el producto.
- Empieza pequeño: unos pocos códigos por dominio y una categoría de respaldo bastan; el catálogo crece con el sistema.
Conclusión
Un catálogo de errores no es una lista de mensajes: es un contrato entre el backend, el frontend, soporte y operaciones. El backend lanza errores con código y datos, el frontend los traduce a experiencia mediante manejadores inyectables, y el traceId conecta todo. El resultado es un sistema que falla de forma predecible, se depura rápido y se explica bien al usuario.