mdashikjs/blog
All posts
The Module Pattern in Modern JavaScript: What Still Matters
Frontend

The Module Pattern in Modern JavaScript: What Still Matters

Frontend4 min

The Module Pattern in Modern JavaScript: What Still Matters

ES modules made the old IIFE module pattern obsolete — but the thinking behind it is still the thing that separates a tidy codebase from a tangled one. Here is what I keep from it.

JavaScriptDesign PatternsModules
Share:

The original pattern, briefly

const Counter = (function () {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
})();

The IIFE creates a private scope. count is inaccessible from outside. Only the returned object is public. In 2026 you rarely write this, but the idea — hide state behind a small public surface — is what good modules still do.

What changed with ES modules

// counter.js
let count = 0;
export const increment = () => ++count;
export const value = () => count;

Everything not exported is private. The module itself is the closure. No wrapping function needed.

Where the pattern still earns its keep

Inside a module you often want multiple independent instances with private state. A bare ES module gives you one singleton. For instances, return a factory:

// counter.js
export function createCounter(initial = 0) {
  let count = initial;
  return {
    increment: () => ++count,
    value: () => count,
  };
}

This is the module pattern, alive and well — just scoped to the function rather than the file. Each call gets its own count.

Why not a class?

Classes give you the same thing with different ergonomics:

export class Counter {
  #count = 0;
  increment() { return ++this.#count; }
  value() { return this.#count; }
}

Private fields (#count) now enforce encapsulation at runtime. For most teams, classes are fine. I reach for factory functions when I want structural typing (anything shaped like { increment, value } works), or when I want to return a subset of the closure based on construction arguments.

The trap I watched a team fall into

They wrote a module with top-level state:

let config = null;
export function init(c) { config = c; }
export function getFlag(name) { return config.flags[name]; }

Works fine until two tests import it. Test A calls init({...}). Test B reads stale state. Module singletons are global state with extra steps — and they bleed across test boundaries.

The fix is boring: pass config explicitly, or return a factory that closes over it.

The rule I follow

If a module exports only pure functions, top-level is fine. The moment there is mutable state, wrap it in a factory and let the caller decide how many instances exist. Future-you, writing a test at 11pm, will be grateful.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.