Inicio / Artículos / Seis patrones de diseño de JavaScript para evitar el código espagueti

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.

2146 palabras

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 de calculateShipping en 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 userAdapter una 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.
  • Portátil en diferentes contextos: un comportamiento como 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:

    1. 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.
    2. 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.
    3. 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

  • Cursor, Claude Code y Codex: Elegir una herramienta de programación con IA para JS — Esta comparación explica cómo Cursor, Claude Code y Codex se adaptan a diferentes flujos de trabajo en JavaScript, desde la programación basada en editores hasta tareas con agentes autónomos.
  • Diez patrones cotidianos de JavaScript y las consecuencias de cada uno — Cláusulas de protección, mapas de búsqueda, objetos de resultado, AbortController, allSettled, fábricas y pequeños cachés: cuándo resulta útil cada patrón y cuándo causa problemas.