Design Patterns & Clean Code
Code is read far more often than it is written. In a company, the code you write today will be changed by a colleague next month, and by you next year when you have forgotten how it works. Clean code and well-known design patterns (named, reusable solutions to common design problems) make that possible. When a senior developer says "use a strategy here" or "make it an observable store", they are speaking this shared vocabulary.
Best Practices & Security covered the small habits: clear names, small functions, early returns. This lesson goes one level up, to how you structure code: separate pure logic from side effects, avoid changing shared data, and use the patterns that appear again and again in real JavaScript projects and frameworks.
Pure functions and side effects
A pure function returns a value calculated only from its arguments, and changes nothing outside itself: no DOM, no network, no global variables, no changing the objects it was given. Anything else a function does is a side effect. Side effects are necessary (an app that changes nothing is useless), but they are where bugs hide and what makes code hard to test.
The clean-code answer is "a pure core and a thin shell": put calculations in pure functions, and keep side effects in a few small functions at the edges.
const GST_RATE = 0.18; // example rate for this demo
const FREE_DELIVERY_FROM = 499;
const DELIVERY_FEE = 40;
// Pure: same input, same output, changes nothing outside itself
function priceCart(items) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const gst = Math.round(subtotal * GST_RATE);
const delivery = subtotal >= FREE_DELIVERY_FROM ? 0 : DELIVERY_FEE;
return { subtotal, gst, delivery, total: subtotal + gst + delivery };
}
// Impure "shell": reads and writes the outside world, calls the pure core
function showCart(items, print) {
const bill = priceCart(items);
print(`Subtotal ₹${bill.subtotal}, GST ₹${bill.gst}, delivery ₹${bill.delivery}, total ₹${bill.total}`);
}
const cart = [
{ name: "Notebook", price: 60, qty: 3 },
{ name: "Geometry set", price: 180, qty: 1 },
];
showCart(cart, console.log);
showCart([...cart, { name: "School bag", price: 1299, qty: 1 }], console.log);
console.log(cart.length); // the original array was not changedSubtotal ₹360, GST ₹65, delivery ₹40, total ₹465 Subtotal ₹1659, GST ₹299, delivery ₹0, total ₹1958 2
priceCart() can be tested with a single line and reused on the checkout page, in an invoice PDF or on a server. showCart() receives the print function as an argument, so it could write to the page, the console or a test. Notice also the named constants: a reader understands FREE_DELIVERY_FROM at once, while a bare 499 in the middle of a formula is a "magic number" nobody dares to change. (The 18% rate is only an example for this demo.)
Immutability goes with purity: instead of changing an object or array, create a new one. Use spread ({ ...user, city: "Pune" }), map, filter and the copying methods toSorted(), toReversed() and with() from Array Methods. Then nobody's data changes behind their back, and comparing "before" and "after" is easy.
Factories: functions that build objects
A factory is a function that creates and returns objects. It is often simpler than a class, and it can keep data truly private in a closure:
function createWallet(owner, openingBalance = 0) {
let balance = openingBalance; // private: only these functions can touch it
return {
owner,
pay(amount) {
if (amount > balance) throw new Error("Insufficient balance");
balance -= amount;
return balance;
},
getBalance: () => balance,
};
}
const wallet = createWallet("Meera", 500);
wallet.pay(120);
console.log(wallet.getBalance()); // 380
console.log(wallet.balance); // undefined: not reachable from outsideFactories are also the right place to choose which kind of object to create, for example createPaymentMethod("upi") returning a different object than createPaymentMethod("card"), while the rest of the code uses both the same way. Classes with private #fields are an equally good choice; teams usually pick one style and stay consistent.
Strategy: swap behaviour without if/else chains
When the same job can be done in several interchangeable ways, such as shipping methods, discount rules, sorting options or payment providers, put each way in its own function with the same shape, and pick one by name. This is the strategy pattern:
// Each strategy has the same shape: (order) => shipping cost in rupees
const shippingStrategies = {
standard: (order) => (order.amount >= 499 ? 0 : 40),
express: (order) => 99 + Math.ceil(order.weightKg) * 10,
pickup: () => 0,
};
function shippingCost(order, method) {
const strategy = shippingStrategies[method];
if (!strategy) throw new Error(`Unknown shipping method: ${method}`);
return strategy(order);
}
const order = { amount: 650, weightKg: 2.4 };
for (const method of Object.keys(shippingStrategies)) {
console.log(`${method}: ₹${shippingCost(order, method)}`);
}
// Adding a new option means adding one entry, not editing a long if/else
shippingStrategies.sameDay = (o) => (o.amount >= 1000 ? 49 : 149);
console.log(`sameDay: ₹${shippingCost(order, "sameDay")}`);standard: ₹0 express: ₹129 pickup: ₹0 sameDay: ₹149
Compare that with a 40-line if...else if chain inside one function. Each strategy can now be tested on its own, a new method is one new entry, and an unknown method fails with a clear error. This is the same idea as using an object instead of switch in switch Statements, applied to behaviour.
Observer: tell interested parts when something changes
Several parts of a page often need to react to the same data: the cart badge in the header, the cart total in the footer and the checkout button all care about the cart. Instead of the cart code calling each of them directly, they subscribe to the cart and are notified when it changes. This is the observer pattern (also called publish/subscribe), and it is the heart of every state-management library:
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // call this to unsubscribe
},
update(changes) {
state = { ...state, ...changes }; // a new object: never mutate the old one
for (const listener of listeners) listener(state);
},
};
}
const cartStore = createStore({ items: 0, total: 0 });
const stopBadge = cartStore.subscribe((s) => console.log(`Badge shows ${s.items}`));
cartStore.subscribe((s) => console.log(`Footer shows ₹${s.total}`));
cartStore.update({ items: 1, total: 349 });
stopBadge(); // the badge component was removed from the page
cartStore.update({ items: 2, total: 798 });
console.log(cartStore.getState());Badge shows 1
Footer shows ₹349
Footer shows ₹798
{ items: 2, total: 798 }The store knows nothing about badges or footers, and new subscribers can be added without touching it. subscribe() returns an unsubscribe function: calling it when a component is removed prevents the memory leaks described in Memory Management & Leaks. DOM events and Custom Events are the browser's built-in version of the same pattern.
State machines: no more boolean soup
UI code often grows flags like isLoading, isError, isDone. With three booleans there are eight combinations, and some make no sense ("loading and done at the same time?"), yet bugs create them. A finite state machine lists the possible states and which events may move between them. Anything not listed is simply ignored:
// Every state lists the events it accepts and where each one leads
const transitions = {
idle: { submit: "sending" },
sending: { success: "sent", failure: "error" },
error: { submit: "sending" },
sent: {},
};
function createMachine(start) {
let current = start;
return {
get state() { return current; },
send(event) {
const next = transitions[current][event];
if (!next) {
console.log(` ignored "${event}" while ${current}`);
return;
}
console.log(`${current} --${event}--> ${next}`);
current = next;
},
};
}
const form = createMachine("idle");
form.send("submit");
form.send("submit"); // double click while sending: ignored
form.send("failure");
form.send("submit"); // retry
form.send("success");
form.send("submit"); // already sent: ignoredidle --submit--> sending ignored "submit" while sending sending --failure--> error error --submit--> sending sending --success--> sent ignored "submit" while sent
A double click while sending cannot submit the form twice, and a sent form cannot be sent again, without a single extra if. The table of transitions also documents the feature: a new developer can read every possible flow in five lines. For larger apps, libraries such as XState build on this idea.
Dependency injection: pass in what you depend on
Code that reaches out for its tools (calling fetch, Date.now() or localStorage directly) is hard to test, because a test cannot control the network or the clock. Dependency injection simply means passing those tools in, usually with sensible defaults:
function createOrderService({ fetchFn = fetch, now = () => new Date() } = {}) {
return {
async placeOrder(cart) {
const response = await fetchFn("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cart, placedAt: now().toISOString() }),
});
if (!response.ok) throw new Error(`Order failed: ${response.status}`);
return response.json();
},
};
}
// In a test: a fake fetch and a fixed clock, no network needed
const service = createOrderService({
fetchFn: async () => new Response(JSON.stringify({ id: "ORD-77" })),
now: () => new Date("2026-11-02T09:00:00Z"),
});The real app calls createOrderService() with no arguments. Tests pass fakes, as you saw in Testing JavaScript. The logger in Error Handling & Logging in Production used the same trick for its clock.
Patterns to use with care
- Singleton (exactly one shared instance): an ES module already gives you this, because a module runs once and every importer shares its exports. Too many singletons become hidden global state, so prefer passing things in.
- Deep inheritance: long class chains are hard to change. Prefer composition, as Inheritance & Prototypes explained.
- Boolean parameters:
createUser(data, true, false)is unreadable. Use an options object:createUser(data, { sendWelcomeSms: true }). - Abstracting too early: "Don't Repeat Yourself" is good advice, but two pieces of code that look alike today may change differently tomorrow. Wait until you see the same thing three times.
Refactor in small steps with tests running, and never mix a refactor with a behaviour change in the same commit; reviewers can then check each one properly.
Common mistakes
- Mixing calculation with DOM and network code in one big function that nobody can test.
- Mutating objects that were passed in, changing data that other parts of the app still use.
- Growing
if...elsechains for options that should be strategies. - Subscribing without ever unsubscribing.
- Juggling several booleans for one UI state instead of one state value.
- Using a pattern because it has a name. A pattern is worth it only when it makes the code simpler to change.
Key points
- Keep a pure core (calculations) and a thin shell (DOM, network, storage); prefer immutable updates.
- Factories build objects and can hide private data in closures; classes with
#fieldsare an equal alternative. - The strategy pattern replaces long conditionals with interchangeable functions chosen by name.
- The observer pattern lets many parts react to one source of data; always return an unsubscribe function.
- State machines replace combinations of booleans with explicit states and allowed transitions.
- Dependency injection (passing
fetch, clocks and storage in) makes code easy to test.
Exercise
Refactor this discount code using the strategy pattern and a pure function. Add a new "festival" coupon that gives 15% off with a maximum discount of ₹300.
function applyCoupon(code) {
let total = Number(document.querySelector("#total").textContent);
if (code === "FLAT100") {
if (total >= 999) total = total - 100;
} else if (code === "STUDENT10") {
total = total - total * 0.1;
}
document.querySelector("#total").textContent = total;
}Show solution
const coupons = {
FLAT100: (total) => (total >= 999 ? 100 : 0),
STUDENT10: (total) => Math.round(total * 0.1),
FESTIVAL: (total) => Math.min(Math.round(total * 0.15), 300),
};
// Pure: works out the new total, touches nothing else
function totalAfterCoupon(total, code) {
const discountFor = coupons[code];
return discountFor ? total - discountFor(total) : total;
}
// Shell: reads and writes the page
function applyCoupon(code) {
const totalBox = document.querySelector("#total");
totalBox.textContent = totalAfterCoupon(Number(totalBox.textContent), code);
}
console.log(totalAfterCoupon(2500, "FESTIVAL")); // 2200 (15% is 375, capped at 300)
console.log(totalAfterCoupon(800, "FLAT100")); // 800 (below ₹999)Each coupon is a small strategy that returns its discount, the calculation is pure and testable without a page, and the new coupon is a single line.
Quick Quiz
1. Which function is pure?
A pure function depends only on its arguments and changes nothing outside itself; pushing to cart and reading the clock are side effects.
2. What problem does the strategy pattern solve?
Each behaviour becomes its own function with the same shape, so adding one does not mean editing a long conditional.
3. Why should subscribe() return an unsubscribe function?
Forgotten subscriptions keep components and their data alive and keep running their code.
4. Why use one state value such as "sending" instead of isLoading, isError and isDone?
A state machine can only be in one listed state at a time, which removes a whole class of bugs.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
