EthicsComputerCourses
Join training +91 90532 10052

Design Patterns & Clean Code

JavaScript · Lesson 71 of 74 Advanced 9 min read Updated

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.

JavaScriptRun it yourself
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 changed
Output
Subtotal ₹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:

JavaScript
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 outside

Factories 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:

JavaScriptRun it yourself
// 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")}`);
Output
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:

JavaScriptRun it yourself
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());
Output
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:

JavaScriptRun it yourself
// 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: ignored
Output
idle --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:

JavaScript
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...else chains 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 #fields are 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.

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

2. What problem does the strategy pattern solve?

3. Why should subscribe() return an unsubscribe function?

4. Why use one state value such as "sending" instead of isLoading, isError and isDone?

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