Home / Articles / Six JavaScript Design Patterns for Escaping Spaghetti Code

This article is published in English.

Six JavaScript Design Patterns for Escaping Spaghetti Code

Explains six practical JavaScript patterns—Strategy, Factory, Observer, Adapter, Composition, and Pipeline—that replace tangled code with maintainable structure.

2146 words

Turning chaotic scripts into predictable, maintainable systems

Anyone who has built software long enough recognizes that uneasy moment when you reopen a project months after shipping it and can no longer trace how anything connects.

The slide into chaos rarely starts on purpose. You bolt on a quick toggle for the UI, then a data fetch, then some edge-case handling, then loading indicators, then an analytics call. A few weeks later, your once-tidy script has ballooned into an 800-plus-line tangle of nested callbacks, stray global variables, and brittle if/else chains.

That's the essence of spaghetti code: business rules and presentation logic get so intertwined that changing one section quietly breaks two others elsewhere.

Building JavaScript that scales doesn't mean bolting on heavyweight enterprise-style abstractions for every function. It's mostly about separating concerns and relying on a handful of dependable patterns.

Below are six proven design and architectural patterns that clean up tangled JavaScript and keep your codebase manageable as it grows.

1. Strategy Pattern: Getting Rid of Nested Conditionals

The problem

Whenever logic needs to branch depending on user category, payment method, or processing mode, the instinctive move for many developers is stacking if/else statements or bloated switch blocks.

// 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');
  }
}

Each time your team introduces a new shipping tier, you're forced to modify this same central function. A single mistake or broken operator here takes down shipping calculations for every order type at once.

The fix

The Strategy Pattern pulls each algorithm out into its own self-contained function and stores them in a shared lookup object.

// 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);
}

Why this holds up at scale

  • Open/Closed Principle: adding a dozen new shipping options just means adding new entries to shippingStrategies, with no changes needed inside calculateShipping itself.
  • Easier testing: each strategy function can be exported, profiled, and tested completely on its own.

2. Module and Factory Patterns: Keeping State Contained

The problem

Loosely scoped global variables and shared mutable objects breed elusive bugs. Once several UI components can freely read and modify the same piece of state, figuring out which one corrupted a value turns into detective work.

// The Spaghetti Way
let cart = [];
let total = 0;

function addItem(item) {
  cart.push(item);
  total += item.price;
}

function resetCart() {
  cart = [];
  total = 0;
}

Nothing stops some unrelated script on the page from setting cart to null or updating total without touching cart in sync.

The fix

Closures inside factory functions let you keep state private, exposing only the specific operations other code actually needs while hiding the raw variables entirely.

// 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

Why this holds up at scale

  • No leaked variables: outside code can't directly overwrite items — it has to go through validated public methods.
  • Safe multiple instances: every call to createCart() returns its own independent state, with no risk of one instance clobbering another.

3. Observer Pattern (Pub/Sub): Loosening Tightly Coupled Code

The problem

When a shopper clicks "Place Order," several things need to happen at once: the cart clears, a confirmation message shows up, a tracking pixel fires, and the backend gets notified. Cramming all of that logic into a single function makes it grow into an unmanageable catch-all.

// 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');
}

If the tracking script throws an error or a DOM element gets renamed, the whole checkout flow is at risk of failing.

The fix

// 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);
}

Why this holds up at scale

  • No dependencies between parts: the checkout function has no idea who's subscribed to it. You can wire up new analytics, email notifications, or UI effects without ever touching handleCheckout.
  • Fault isolation: a failure inside one listener doesn't take down the function that triggered the event.

4. Adapter Pattern: Insulating Your Code from Unstable Dependencies

The problem

External services, npm packages, and internal endpoints have a habit of changing their contracts without warning. If fifteen separate components fetch user data and each one reads raw response fields directly, renaming a single property—say user_id to id—triggers a refactor that touches your entire codebase.

// 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;
}

The fix

Insert an adapter layer between the outside data source and your application's internal logic. Convert whatever shape the external payload arrives in into a stable, predictable structure before anything downstream touches it.

// 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);
}

Why this holds up at scale

  • One place to adjust: if the backend swaps its response schema next week, you edit userAdapter once instead of chasing down forty components that broke.
  • Simpler test doubles: UI tests only need to assert against the normalized shape, not the ever-shifting external format.

5. Favoring Composition Over Inheritance: Assembling Features Like Building Blocks

The problem

Deep class hierarchies tend to buckle under their own complexity. Suppose you start with a generic User class and branch into AdminUser, ModeratorUser, and GuestUser. Things fall apart the moment you need a GuestModerator — someone who has a few moderator powers but not the full set.

// 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?

Long inheritance chains glue capabilities together in ways that don't map to reality, and subclasses end up inheriting methods that make no sense for them.

The fix

Rely on object composition instead: small, reusable behavior functions (mixins) that you attach to an object as needed. Build capability sets around what something does rather than forcing it into a rigid is-a hierarchy.

// 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

Why this holds up at scale

  • No hierarchy to manage: behaviors get combined on the fly, with no need to plan out a class tree in advance.
  • Portable across contexts: a behavior like canAuthenticate works equally well on customer accounts, internal staff accounts, or automated bot accounts.

6. The Pipeline Pattern: Sequential Async Steps

The problem

Chaining several asynchronous transformations often ends up as deeply nested, hard-to-follow code that mixes unrelated concerns together.

// 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;
}

This is manageable with three steps, but once you start layering in logging, telemetry, retries, and validation, the whole thing becomes difficult to follow.

The fix

Adopt the pipeline pattern: model each transformation as a small, single-purpose function, and chain them so the whole process reads cleanly from start to finish, top to bottom or left to right.

// 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));

Why this holds up at scale

  • Easy reordering: adding, removing, or resequencing a step — like inserting a thumbnail-generation stage — takes almost no effort.
  • Step-by-step debugging: you can drop a simple logging function into any point of the pipeline to inspect what goes in and what comes out.

Getting Past Tangled Code

Code doesn't turn messy because the people writing it lack ability. It gets messy because systems grow under time pressure, and developers reach for whatever line of code solves the immediate problem fastest.

The real key to a clean architecture isn't bolting on some heavyweight enterprise framework. It's applying small, deliberate structures where they fit:

  1. Match the pattern to the actual symptom: reach for Strategy when conditionals spiral out of control, bring in pub/sub when modules start calling each other in circles, and use an adapter when a third-party contract threatens to destabilize your UI.
  2. Favor simple over clever: you don't need every pattern on day one. Let a piece of logic repeat itself twice before you bother abstracting it on the third occurrence.
  3. Keep functions pure and interfaces well-defined: predictable inputs and outputs make future refactors far less painful.

Clean code isn't something you write perfectly once — it's code that's still easy to change six months from now.