Mantenerlo Simple: Ejemplos Reales de un SaaS en Producción

El principio que se paga solo
Después de más de diez años escribiendo software, el principio de ingeniería que más defiendo cabe en una frase: si una solución es difícil de explicar, probablemente está mal. Los sistemas simples son más fáciles de debuggear, más baratos de mantener, más rápidos para incorporar gente nueva, y más resistentes bajo presión.
No quiero hablar de esto en abstracto. Voy a mostrar código real de una plataforma SaaS que construí -- un backend en Node.js con Express y un frontend en Next.js que sirve miles de requests diarios.
Ejemplo 1: Manejo de errores sin frameworks
El manejo de errores es una de esas áreas donde a los desarrolladores nos encanta complicarnos: jerarquías de excepciones, cadenas de middleware, mappers de errores. Esta es la clase de error completa que usa todo el backend:
export class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}Siete líneas. Todas las rutas de la API usan esto para señalar problemas, y un único middleware los captura:
export const errorHandler = (
error: Error | ApiError | ZodApiError,
req: Request,
res: Response,
next: NextFunction
) => {
if (error instanceof ApiError) {
res.status(error.statusCode).json({
statusCode: error.statusCode,
message: error.message,
});
return;
}
// Fallback generico
res.status(500).json({
statusCode: 500,
message: error.message,
});
};Sin enum de códigos de error, sin registro de errores, sin capa de mapeo. Cuando un desarrollador nuevo se suma al equipo, lee este archivo una vez y entiende todos los errores del sistema. Cuando algo se rompe a las dos de la mañana, el mensaje de error te dice exactamente qué pasó y dónde.
Ejemplo 2: Configuración como funciones planas
He visto sistemas de configuración con parsers de YAML, capas de validación, lógica de cascada de entornos y patrones adapter. Esta es nuestra configuración:
export function isDev(): boolean {
return process.env.NODE_ENV === "development";
}
export function isProd(): boolean {
return process.env.NODE_ENV === "production";
}Dos funciones. Cada módulo que necesita comportarse distinto según el entorno llama a isDev() o isProd(). No hay nada que mockear en tests, nada que debuggear, nada que configurar. El servicio de cache lo usa para decidir entre memoria e Redis:
private constructor() {
this.cache = isDev() ? new NodeCache() : null;
this.redisClient = !isDev() ? Redis.fromEnv() : null;
}Memoria local en desarrollo para no necesitar Redis corriendo. Redis en producción por performance. La decisión se ve en una línea. Sin archivos de configuración, sin strategy pattern, sin abstracción de providers.
Ejemplo 3: Seguridad como una lista
Los scanners de vulnerabilidades bombardean constantemente las APIs en producción con requests a /wp-admin, archivos .php y paths .env. El instinto puede ser implementar un motor de reglas WAF o un pipeline de análisis de requests. Esto es lo que realmente funciona:
const BLOCKED_EXTENSIONS = [
".php", ".env", ".sql", ".bak", ".ini",
".log", ".save", ".old", ".backup",
];
const BLOCKED_PATH_PREFIXES = [
"/wp-admin", "/wp-login", "/wp-content",
"/phpmyadmin", "/xmlrpc", "/drupal",
];
export const blockSuspiciousRequests = (
req: Request, res: Response, next: NextFunction
): void => {
const path = req.path.toLowerCase();
const blocked =
BLOCKED_EXTENSIONS.some((ext) => path.endsWith(ext)) ||
BLOCKED_PATH_PREFIXES.some((p) => path.startsWith(p));
if (blocked) {
res.status(403).send("Forbidden");
return;
}
next();
};Dos arrays y un middleware. Agregar un nuevo patrón bloqueado es agregar un string a una lista. Cualquier desarrollador lo entiende en segundos. Complementa el WAF de Cloudflare como capa de defensa en profundidad y ha bloqueado miles de intentos de escaneo con cero falsos positivos.
Ejemplo 4: Una utilidad que previene bugs en una línea
Una de mis funciones favoritas de todo el codebase:
export function assureExists<T>(
value: T | undefined,
message: string
): T {
if (value === undefined || value === null || value === "") {
throw new Error(message);
}
return value;
}Reemplaza chequeos de null dispersos por todo el código con una única llamada descriptiva. En lugar de if (!user) throw ... repetido en docenas de lugares con mensajes ligeramente distintos y chequeos ligeramente diferentes (algunos olvidando strings vacíos, otros olvidando null), cada búsqueda crítica se convierte en:
const business = assureExists(
await db.findById(businessId),
"Business not found"
);
// business esta garantizado como non-null desde acaTypeScript reduce el tipo automáticamente. El mensaje de error siempre es descriptivo. El chequeo siempre es completo. Cinco líneas reemplazando cientos de null checks inconsistentes.
Ejemplo 5: Un logger de frontend que sabe cuándo crecer
El logger del frontend es intencionalmente la implementación más simple posible:
export const logger: Logger = {
debug: (message: string, ...args: unknown[]) => {
if (!isLoggingEnabled()) return;
console.log(...formatLog("debug", message, ...args));
},
info: (message: string, ...args: unknown[]) => {
if (!isLoggingEnabled()) return;
console.info(...formatLog("info", message, ...args));
},
error: (message: string, ...args: unknown[]) => {
// Siempre loggear errores, incluso en produccion
console.error(...formatLog("error", message, ...args));
},
};Es un wrapper fino sobre console. Pero cada llamada de logging en la app pasa por esta interfaz. El día que necesitemos logging estructurado, niveles de log o transporte remoto, cambiamos este único archivo y todos los call sites funcionan igual. La idea clave: no construimos la abstracción antes de necesitarla. Construimos la interfaz para poder agregar la abstracción después sin refactorizar nada.
Por qué esto importa para el negocio
La simplicidad no es una preferencia de desarrollador -- es una ventaja de negocio:
- Velocidad de onboarding. Un desarrollador nuevo puede leer
ApiError.ts,config/index.tsyblockSuspiciousRequests.tsen menos de diez minutos y entender los patrones centrales de todo el backend. - Tiempo de debugging. Cuando producción se rompe, código simple significa menos capas que revisar. El error handler es un archivo. La config son dos funciones. El middleware de seguridad son dos arrays.
- Costo de mantenimiento. Cada abstracción que agregas es una abstracción que mantenés. Un strategy pattern para detección de entorno significa tests para el strategy, documentación para el strategy, y onboarding para el strategy. Dos funciones no necesitan mantenimiento.
- Menos bugs.
assureExistseliminó una clase entera de inconsistencias en null checks. El middleware de seguridad no tiene lógica condicional más allá de búsquedas en arrays, lo que significa cero edge cases.
La conclusión práctica
Antes de agregar una abstracción, preguntate: ¿puedo explicarle esto a un compañero en una frase? Si no podés, probablemente estás resolviendo un problema que todavía no tenés. Construí lo más simple que funcione, diseñalo para que pueda crecer, y esperá a que la realidad te diga que es momento de hacerlo crecer. En mi experiencia, ese momento llega mucho menos seguido de lo que pensamos.