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.
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 insidecalculateShippingitself. - 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
userAdapteronce 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
canAuthenticateworks 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:
- 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.
- 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.
- 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.