Seis patrones de diseño de JavaScript para evitar el código espagueti
Explica seis patrones prácticos de JavaScript: Estrategia, Fábrica, Observador, Adaptador, Composición y Pipeline, que reemplazan el código enredado por una estructura mantenible.
Conviertiendo scripts caóticos en sistemas predecibles y mantenibles
Cualquiera que haya desarrollado software durante suficiente tiempo reconoce ese momento incómodo en el que se vuelve a abrir un proyecto meses después de su lanzamiento y ya no se puede rastrear cómo se conectan las diferentes partes.
El desliz hacia el caos rara vez comienza intencionadamente. Se añade rápidamente un control para la interfaz de usuario, luego una función para obtener datos, después algún manejo de casos extremos, indicadores de carga y una llamada a análisis. Unas semanas más tarde, el script que antes estaba ordenado se ha convertido en un laberinto de más de 800 líneas lleno de callbacks anidados, variables globales sueltas y cadenas frágiles de if/else.
Esa es la esencia del código espagueti: las reglas de negocio y la lógica de presentación se entrelazan tanto que cambiar una sección afecta silenciosamente a otras dos en diferentes partes del código.
Crear JavaScript escalable no significa añadir abstracciones pesadas al estilo empresarial a cada función. Se trata principalmente de separar las responsabilidades y confiar en un puñado de patrones fiables.
A continuación se presentan seis patrones de diseño y arquitectura probados que simplifican el JavaScript complicado y mantienen tu base de código manejable a medida que crece.
1. Patrón de Estrategia: Eliminar condicionales anidados
El problema
Cada vez que la lógica necesita dividirse según la categoría del usuario, el método de pago o el modo de procesamiento, el paso instintivo de muchos desarrolladores es acumular sentencias if/else o bloques switch excesivamente largos.
// The Spaghetti Way
function calculateShipping(order) {
if (order.type === 'standard') {
return order.weight * 1.5;
} else if (order.type === 'express') {
return order.weight * 3.0 + 10;
} else if (order.type === 'overnight') {
return order.weight * 5.0 + 25;
} else if (order.type === 'international') {
return order.weight * 8.0 + 50;
} else {
throw new Error('Unknown shipping method');
}
}
Cada vez que su equipo introduce un nuevo nivel de envío, se ve obligado a modificar esta misma función central. Un solo error o un operador defectuoso aquí afecta los cálculos de envío para todos los tipos de pedidos al mismo tiempo.
La solución
El Patrón de Estrategia separa cada algoritmo en una función autónoma y las almacena en un objeto de consulta compartido.
// The Scalable Way
const shippingStrategies = {
standard: (order) => order.weight * 1.5,
express: (order) => order.weight * 3.0 + 10,
overnight: (order) => order.weight * 5.0 + 25,
international: (order) => order.weight * 8.0 + 50,
};
function calculateShipping(order) {
const strategy = shippingStrategies[order.type];
if (!strategy) {
throw new Error(`Unsupported shipping method: ${order.type}`);
}
return strategy(order);
}
Por qué funciona a gran escala
- Principio de Apertura/Cierre: agregar una docena de nuevas opciones de envío solo implica añadir nuevas entradas a
shippingStrategies, sin necesidad de realizar cambios dentro decalculateShippingen sí. - Pruebas más sencillas: cada función de estrategia puede ser exportada, analizada y probada por completo de forma independiente.
2. Patrones de módulo y fábrica: Mantener el estado contenido
El problema
Las variables globales de alcance amplio y los objetos mutables compartidos generan errores difíciles de detectar. Una vez que varios componentes de interfaz pueden leer y modificar libremente el mismo estado, averiguar cuál de ellos corrompió un valor se convierte en una tarea similar a la de un detective.
// The Spaghetti Way
let cart = [];
let total = 0;
function addItem(item) {
cart.push(item);
total += item.price;
}
function resetCart() {
cart = [];
total = 0;
}
Nada impide que algún script no relacionado en la página establezca cart en null o actualice total sin modificar cart de forma sincronizada.
La solución
Las clausuras dentro de las funciones fábrica permiten mantener el estado privado, exponiendo solo las operaciones específicas que realmente necesita el resto del código mientras se ocultan por completo las variables brutas.
// The Scalable Way
function createCart() {
// Private variables protected inside the closure
let items = [];
return {
addItem(product) {
if (!product || typeof product.price !== 'number') {
throw new Error('Invalid product payload');
}
items.push({ ...product, id: crypto.randomUUID() });
},
removeItem(productId) {
items = items.filter((item) => item.id !== productId);
},
getItems() {
// Return a shallow copy so external mutations don't alter state
return [...items];
},
getTotal() {
return items.reduce((sum, item) => sum + item.price, 0);
},
clear() {
items = [];
}
};
}
const userCart = createCart();
userCart.addItem({ name: 'Mechanical Keyboard', price: 120 });
console.log(userCart.getTotal()); // 120
Por qué funciona a gran escala
- Sin variables filtradas: el código externo no puede sobrescribir directamente
items— debe pasar por métodos públicos validados. - Múltiples instancias seguras: cada llamada a
createCart()devuelve su propio estado independiente, sin riesgo de que una instancia afecte a otra.
3. Patrón Observer (Pub/Sub): Aflojar el código estrechamente acoplado
El problema
Cuando un comprador hace clic en “Realizar pedido”, varias cosas deben ocurrir al mismo tiempo: se vacía el carrito, aparece un mensaje de confirmación, se envía un píxel de seguimiento y se notifica al backend. Agregar toda esa lógica en una sola función la convierte en algo ingobernable y genérico.
// The Spaghetti Way
async function handleCheckout(order) {
await api.submitOrder(order);
// UI logic mixed directly with tracking and data operations
document.querySelector('#cart-count').textContent = '0';
document.querySelector('#modal').classList.add('active');
analytics.trackPurchase(order);
notificationSystem.sendPush('Order confirmed');
}
Si el script de seguimiento lanza un error o un elemento DOM cambia de nombre, todo el flujo de pago corre el riesgo de fallar.
La solución
// The Scalable Way
class EventEmitter {
constructor() {
this.events = new Map();
}
subscribe(eventName, listener) {
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName).add(listener);
// Return an easy unsubscribe function
return () => this.events.get(eventName).delete(listener);
}
publish(eventName, data) {
const listeners = this.events.get(eventName);
if (listeners) {
listeners.forEach((listener) => {
try {
listener(data);
} catch (err) {
console.error(`Error executing listener for ${eventName}:`, err);
}
});
}
}
}
const appBus = new EventEmitter();
// Feature modules register their own behavior
appBus.subscribe('order:placed', (order) => {
analytics.trackPurchase(order);
});
appBus.subscribe('order:placed', () => {
document.querySelector('#cart-count').textContent = '0';
});
// The emitter stays minimal and decoupled
async function handleCheckout(order) {
await api.submitOrder(order);
appBus.publish('order:placed', order);
}
Por qué funciona a gran escala
- Sin dependencias entre partes: la función de pago no sabe quién está suscrito a ella. Puedes integrar nuevas funciones de análisis, notificaciones por correo electrónico o efectos en la interfaz sin tocar nunca
handleCheckout. - Aislamiento de fallos: un error en uno de los listeners no afecta a la función que activó el evento.
4. Patrón Adaptador: Aíslar tu código de dependencias inestables
El problema
Los servicios externos, los paquetes npm y los endpoints internos tienden a cambiar sus contratos sin previo aviso. Si quince componentes distintos obtienen datos de usuario y cada uno lee directamente los campos de la respuesta en bruto, renombrar una sola propiedad —por ejemplo, user_id a id— desencadena una refactorización que afecta a toda la base de código.
// The Spaghetti Way: scattered across multiple UI components
function renderProfile(rawApiResponse) {
// Directly tied to backend-specific naming conventions
const name = `${rawApiResponse.first_name} ${rawApiResponse.last_name}`;
const address = rawApiResponse.shipping_address_line_1;
const avatar = rawApiResponse.meta_info.profile_image_url;
}
La solución
Incorpore una capa de adaptador entre la fuente de datos externa y la lógica interna de su aplicación. Convierta la forma en que llega el payload externo en una estructura estable y predecible antes de que cualquier componente posterior la procese.
// The Scalable Way
function userAdapter(externalUser) {
return {
id: externalUser.user_id || externalUser.id,
fullName: `${externalUser.first_name || ''} ${externalUser.last_name || ''}`.trim(),
address: externalUser.shipping_address_line_1 || externalUser.street || 'N/A',
avatar: externalUser.meta_info?.profile_image_url || '/assets/default-avatar.png',
};
}
// Your components only ever consume normalized models
async function getUserProfile(userId) {
const response = await fetch(`/api/v1/users/${userId}`);
const rawData = await response.json();
return userAdapter(rawData);
}
Por qué funciona a gran escala
- Un lugar para ajustar: si el backend cambia su esquema de respuesta la próxima semana, basta editar
userAdapteruna sola vez en lugar de solucionar los problemas en cuarenta componentes diferentes. - Duplicados de prueba más simples: las pruebas de interfaz solo necesitan verificar la estructura normalizada, no el formato externo que cambia constantemente.
5. Preferir la composición sobre la herencia: ensamblar funcionalidades como bloques de construcción
El problema
Las jerarquías profundas de clases tienden a colapsar debido a su propia complejidad. Supongamos que se empieza con una clase genérica User y se crean ramas como AdminUser, ModeratorUser y GuestUser. Todo se complica en el momento en que se necesita un GuestModerator — alguien que tiene algunos poderes de moderador pero no el conjunto completo.
// The Spaghetti Way: Deep Inheritance
class BaseUser {
login() { /* ... */ }
}
class Admin extends BaseUser {
deleteContent() { /* ... */ }
manageBilling() { /* ... */ }
}
// What happens when you need a "BillingAgent" who cannot delete content?
Las largas cadenas de herencia unen capacidades de maneras que no se corresponden con la realidad, y las subclases terminan heredando métodos que no tienen sentido para ellas.
La solución
Depende en su lugar de la composición de objetos: funciones pequeñas y reutilizables de comportamiento (mixins) que se adjuntan a un objeto según sea necesario. Construye conjuntos de capacidades basados en lo que algo hace en lugar de forzarlo dentro de una jerarquía rígida de tipo es-un.
// The Scalable Way: Composable Behaviors
const canAuthenticate = (state) => ({
login: () => console.log(`${state.email} logged in`),
logout: () => console.log(`${state.email} logged out`),
});
const canModerateContent = () => ({
deletePost: (postId) => console.log(`Post ${postId} deleted`),
banUser: (userId) => console.log(`User ${userId} banned`),
});
const canManageBilling = () => ({
processInvoice: (amount) => console.log(`Invoice processed: ${amount}`),
});
// Build specialized actors on demand
function createSupportStaff(email) {
const state = { email };
return {
email,
...canAuthenticate(state),
...canModerateContent(),
};
}
function createSuperAdmin(email) {
const state = { email };
return {
email,
...canAuthenticate(state),
...canModerateContent(),
...canManageBilling(),
};
}
const moderator = createSupportStaff('support@example.com');
moderator.login();
moderator.deletePost(404);
// moderator.processInvoice is undefined - zero privilege leakage
Por qué funciona a gran escala
- Sin jerarquía que gestionar: los comportamientos se combinan sobre la marcha, sin necesidad de planificar un árbol de clases con antelación.
canAuthenticate funciona igualmente bien en cuentas de clientes, cuentas de personal interno o cuentas de bots automatizados.6. El patrón Pipeline: pasos asíncronos secuenciales
El problema
Enlazar varias transformaciones asíncronas a menudo resulta en código profundamente anidado y difícil de seguir, que mezcla cuestiones no relacionadas entre sí.
// The Spaghetti Way
async function handleImageUpload(file) {
if (file.size > 5000000) {
throw new Error('Too large');
}
const compressed = await compressImage(file);
const metadata = await extractExif(compressed);
const tagged = await tagCategories(compressed, metadata);
const uploadResult = await uploadToS3(tagged);
return uploadResult;
}
Esto se puede manejar con tres pasos, pero una vez que se comienzan a añadir registro de actividades, telemetría, intentos repetidos y validación, todo se vuelve difícil de seguir.
La solución
Adopte el patrón de pipeline: modele cada transformación como una función pequeña y de propósito único, y encadénelas de modo que todo el proceso sea fácil de seguir desde el principio hasta el final, de arriba hacia abajo o de izquierda a derecha.
// The Scalable Way
const pipeAsync = (...functions) => (initialValue) =>
functions.reduce(
(currentPromise, currentFunction) => currentPromise.then(currentFunction),
Promise.resolve(initialValue)
);
// Each step is an isolated, testable transformation
const validateSize = async (file) => {
if (file.size > 5 * 1024 * 1024) throw new Error('File exceeds 5MB limit');
return file;
};
const compress = async (file) => compressImage(file);
const attachWatermark = async (image) => applyWatermark(image);
const upload = async (finalImage) => uploadToCloud(finalImage);
// Create the pipeline
const processUserImage = pipeAsync(
validateSize,
compress,
attachWatermark,
upload
);
// Usage
processUserImage(rawFileInput)
.then((res) => console.log('Upload complete:', res))
.catch((err) => console.error('Pipeline failed:', err.message));
Por qué funciona a gran escala
- Fácil reordenamiento: agregar, eliminar o resecuenciar un paso — como insertar una etapa de generación de miniaturas — requiere casi ningún esfuerzo.
- Depuración paso a paso: puede insertar una función simple de registro en cualquier punto del pipeline para inspeccionar qué entra y qué sale.
Superando el código enredado
El código no se vuelve desordenado porque las personas que lo escriben carezcan de habilidad. Se vuelve desordenado porque los sistemas crecen bajo presión temporal, y los desarrolladores recurren a cualquier línea de código que resuelva el problema inmediato más rápido.
La verdadera clave de una arquitectura limpia no radica en incorporar algún framework empresarial pesado. Consiste en aplicar estructuras pequeñas y bien pensadas donde sean necesarias:
- Adapta el patrón al síntoma real: utiliza Strategy cuando las condiciones se vuelven incontrolables, emplea pub/sub cuando los módulos comienzan a llamarse entre sí en círculos, y aplica un adapter cuando los acuerdos con terceros amenazan con desestabilizar tu interfaz de usuario.
- Preferir lo simple a lo ingenioso: no necesitas todos los patrones desde el primer día. Deja que una parte de la lógica se repita dos veces antes de molestarte en abstraerla en la tercera ocasión.
- Mantén las funciones puras y las interfaces bien definidas: entradas y salidas predecibles hacen que los refactores futuros sean mucho menos dolorosos.
El código limpio no es algo que se escribe perfectamente de una sola vez; es un código que sigue siendo fácil de modificar incluso seis meses después.
Lecturas relacionadas
- Patrones de diseño en React: desde el OOP clásico hasta los hooks modernos — Explica cómo los patrones de software clásicos como Singleton, Factory y Observer se aplican en React, junto con patrones específicos de React como HOCs, Hooks y componentes compuestos.
- Comprendiendo los principios SOLID a través de ejemplos prácticos de código — Esta guía explica los cinco principios SOLID con ejemplos de código concretos, mostrando cómo se aplican en proyectos reales y aplicaciones React.