One API to Rule Them All: Unificando Logs y Más en Proyectos de Gran Escala
Introducción: El Caos de las APIs Dispersas
En proyectos de software con múltiples equipos y un ciclo de vida largo, la fragmentación de las APIs es un problema recurrente. Cada equipo, o incluso cada desarrollador, puede optar por diferentes librerías o enfoques para tareas comunes: logs, manejo de errores, peticiones HTTP, etc. Esto no solo introduce inconsistencias, sino que también aumenta la complejidad de mantenimiento, dificulta el onboarding de nuevos miembros y, en última instancia, ralentiza el desarrollo.
Imaginemos un escenario donde el logging en el frontend usa console.log
o una librería específica, mientras que el backend usa otra distinta, quizás con diferentes niveles y formatos. Si necesitas centralizar los logs, o cambiar la implementación subyacente, te enfrentas a una tarea titánica.
La solución reside en la unificación. Crear una "One API to Rule Them All" para ciertas funcionalidades clave puede transformar la forma en que los equipos interactúan con el sistema.
Caso de Estudio: Unificando el Sistema de Logs
El sistema de logs es un candidato perfecto para la unificación. Es transversal a cualquier parte de nuestra aplicación, ya sea en el cliente (navegador, React Native) o en el servidor (Node.js).
El Problema sin Unificación
-
Inconsistencia:
console.log
,console.warn
,debug
,pino
,winston
,log4js
. Cada uno con su sintaxis y capacidades. - Mantenimiento: Si cambiamos el sistema de logs (ej. de un file system a un cloud service), debemos modificar código en múltiples lugares y plataformas.
- Capacitación: Los nuevos desarrolladores deben aprender múltiples herramientas de logging.
- Centralización: Es difícil consolidar y analizar logs de diferentes fuentes con formatos dispares.
La Solución: Una Interfaz Unificada
Podemos crear una API de logging sencilla y agnóstica de la implementación. El objetivo es que los desarrolladores solo necesiten aprender una interfaz.
// src/infrastructure/log/index.ts
export type LogLevel = "info" | "warn" | "error";
export interface LoggerInterface
extends Record<LogLevel, (message: string) => void> {
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(
message: string,
error?: Error,
context?: Record<string, unknown>
): void;
}
export const log: LoggerInterface =
typeof document !== "undefined"
? (await import("./client")).BrowserLogger
: (await import("./server")).ServerLogger;
Consejo del abuelo: Entornos como Bun, Cloudflare, Deno definen un objeto window similar al del navegador, aunque no estén ejecutándose en un navegador web. Esto lo hacen para mantener la compatibilidad con el código JavaScript que espera la presencia de este objeto global. Por lo tanto el único que tiene
document
(es decir elementos html) es el navegador.
Con esta interfaz, cualquier parte de nuestro código, ya sea en el cliente o en el servidor, solo necesita importar log
y usar sus métodos.
La Magia de los Bundlers: Implementaciones Específicas para Cada Entorno
La belleza de esta arquitectura se maximiza con bundlers modernos como Turbopack y Vite. Estos nos permiten tener múltiples implementaciones para la misma interfaz, y el bundler se encarga de empaquetar solo el código relevante para el entorno final (cliente o servidor).
Esto se logra gracias a técnicas como el Tree Shaking o la eliminación de código muerto. Cuando el bundler procesa el código para el cliente, se da cuenta de que la implementación del logger para el servidor nunca es utilizada y, por lo tanto, la elimina por completo del bundle final, resultando en un paquete más pequeño y optimizado. Lo mismo ocurre en el lado del servidor, donde la implementación del cliente es descartada.
Implementación para el Navegador (Cliente):
// src/intrastructure/log/browserLogger.ts
"client only";
import type { LoggerInterface, LogLevel } from "./";
const formatMessage = (
level: LogLevel,
message: string,
context?: Record<string, unknown>
) =>
`[${level.toUpperCase()}] ${message} ${context ? JSON.stringify(context) : ""}`;
export const BrowserLogger: LoggerInterface = {
info(message: string, context?: Record<string, unknown>): void {
console.info(formatMessage("info", message, context));
// Sentry/Datadog/Bugsnag implementation
},
warn(message: string, context?: Record<string, unknown>): void {
console.warn(formatMessage("warn", message, context));
// Sentry/Datadog/Bugsnag implementation
},
error(
message: string,
error?: Error,
context?: Record<string, unknown>
): void {
console.error(formatMessage("error", message, context), error);
// Sentry/Datadog/Bugsnag implementation
},
};
Implementación para el Servidor (Node.js):
Aquí podemos usar librerías robustas como pino
o winston
.
// src/infrastructure/log/server.ts
"server only";
import type { LoggerInterface } from "./log";
import pino from "pino";
const pinoLogger = pino({
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});
export const ServerLogger: LoggerInterface = {
info(message: string, context?: Record<string, unknown>): void {
pinoLogger.info(context, message);
},
warn(message: string, context?: Record<string, unknown>): void {
pinoLogger.warn(context, message);
},
error(
message: string,
error?: Error,
context?: Record<string, unknown>
): void {
pinoLogger.error({ err: error, ...context }, message);
},
};
El patrón clave aquí es diferenciar el entorno desde donde vamos a ejecutar nuestro logger, lo que permite a los bundlers como Next.js y Vite optimizar el código para cada entorno.
Beneficios Tangibles de la Unificación
- Consistencia: El código se ve y se comporta de manera similar en todo el proyecto.
- Mantenimiento Simplificado: Los cambios en la implementación de una API se hacen en un solo lugar.
- Facilidad de Testing: Las interfaces claras facilitan la creación de mocks y pruebas unitarias.
- Menor Curva de Aprendizaje: Los nuevos desarrolladores solo necesitan aprender la API unificada, no múltiples herramientas.
- Reusabilidad del Código: La lógica de negocio puede ser más agnóstica a la plataforma.
- Desacoplamiento: El código que usa la API está desacoplado de su implementación específica.
Conclusión
Adoptar el patrón "One API to Rule Them All" para funcionalidades transversales es una inversión que rinde frutos a largo plazo en proyectos complejos. Reduce la fricción, mejora la calidad del código y, gracias a las capacidades de optimización de bundlers como Vite y Webpack/Turbopack, nos permite mantener un código limpio y unificado sin sacrificar el rendimiento. Empezar con el sistema de logs es un excelente primer paso para demostrar el valor de esta estrategia.
Top comments (1)
P.S. Esta técnica resulta especialmente útil en los tiempos actuales, en los que nos encontramos tres entornos: Server components, client components y SSR. Y usando las directivas nos aseguramos en tiempo de compilación que no exponemos código de un entorno a otro 😁.
Esto funciona especialmente bien en proyectos con monorepos, como base nos permite empezar a unificar y asegurarnos que cualquier otro consumidor de nuestra API sea la correcta sin preocuparse ya que el trabajo lo realiza nuestro compilador y en caso de tener requerimientos específicos debido a nuestra infraestructura siempre se puede adaptar y emplear otras técnicas para el bundling.