Skip to content
JavaScript js patterns 4 min read

Module Pattern

The module pattern is one of the oldest and most influential idioms in JavaScript. Before the language had a built-in module system, it was the standard way to hide implementation details, avoid polluting the global namespace, and expose a clean public API. It works by wrapping code in a function that creates a private scope, then returning only the parts you want callers to see. Even in a world of ES modules, understanding this pattern explains how encapsulation works under the hood and how to design well-bounded objects.

The core idea: an IIFE returning an API

The classic module pattern combines two ingredients: a closure for private state and an IIFE (Immediately Invoked Function Expression) to run that closure exactly once. Everything declared inside the function is private; the object you return is the public interface.

const counter = (function () {
  // Private state — unreachable from the outside.
  let count = 0;

  function validate(step) {
    if (typeof step !== "number") throw new TypeError("step must be a number");
  }

  // Public API.
  return {
    increment(step = 1) {
      validate(step);
      count += step;
      return count;
    },
    reset() {
      count = 0;
    },
    get value() {
      return count;
    },
  };
})();

counter.increment();
counter.increment(5);
console.log(counter.value);
console.log(counter.count); // not exposed

Output:

6
undefined

The count variable and validate helper never leak. counter.count is undefined because the returned object simply has no such property — the real state lives in the closure, safe from accidental mutation.

The (function () { ... })() wrapper runs immediately and returns its result. The surrounding parentheses turn the function declaration into an expression so it can be invoked inline.

Why it matters

Two concrete problems drove this pattern’s adoption: global scope pollution and the lack of access control. In script-tag-era JavaScript, every top-level var became a property of window, so two libraries could silently clobber each other’s variables. The module pattern confines names to a private scope and surfaces a single, predictable object instead.

const inventory = (function () {
  const items = new Map();

  return {
    add(name, qty) {
      items.set(name, (items.get(name) ?? 0) + qty);
    },
    count(name) {
      return items.get(name) ?? 0;
    },
    get size() {
      return items.size;
    },
  };
})();

inventory.add("apple", 3);
inventory.add("apple", 2);
console.log(inventory.count("apple"), inventory.size);

Output:

5 1

The revealing module pattern

A popular refinement is the revealing module pattern. Instead of defining methods inline in the returned object, you define all functions and variables as locals, then return an object that simply maps public names to those locals. This keeps the implementation in one place and makes the public surface obvious at a glance.

const calculator = (function () {
  let total = 0;

  function add(n) {
    total += n;
    return api;
  }

  function subtract(n) {
    total -= n;
    return api;
  }

  function result() {
    return total;
  }

  // The "reveal": pick exactly what to expose.
  const api = { add, subtract, result };
  return api;
})();

console.log(calculator.add(10).subtract(3).result());

Output:

7

Because add and subtract return api, calls chain fluently. Notice that total stays private while the names you reveal read like documentation.

Parameterised modules

An IIFE can take arguments, which lets you inject dependencies or configuration at creation time. A common variant passes in the global object or an existing namespace so the module can be defended against an undefined global.

const logger = (function (prefix) {
  function stamp(level, msg) {
    return `[${prefix}] ${level.toUpperCase()}: ${msg}`;
  }

  return {
    info: (msg) => console.log(stamp("info", msg)),
    error: (msg) => console.error(stamp("error", msg)),
  };
})("app");

logger.info("started");

Output:

[app] INFO: started

Modern successor: ES modules

ES2015 introduced native modules (ESM), which solve the same problems at the language level. Each file is its own scope; nothing leaks unless you export it, and consumers import exactly what they need. This is the idiomatic approach today in both browsers and Node.

// counter.js
let count = 0;

export function increment(step = 1) {
  count += step;
  return count;
}

export function value() {
  return count;
}
// main.js
import { increment, value } from "./counter.js";

increment();
increment(5);
console.log(value());

Output:

6

ESM gives you static analysis, tree-shaking, asynchronous loading, and circular-dependency handling — none of which the IIFE pattern can offer. The two approaches differ mainly in when and how the boundary is enforced.

AspectModule pattern (IIFE)ES modules (ESM)
Scope unitA functionA file
PrivacyClosure variablesNon-exported bindings
LoadingSynchronous, inlineStatic import / dynamic import()
Tree-shakingNoYes
InstancesOne per IIFE invocationOne per module (singleton-ish)
Best forLegacy / single-file scriptsAll modern code

A bare ES module is effectively a singleton: the module body runs once and every importer shares the same bindings. Use a factory export when you need independent instances.

Best practices

  • Prefer native ES modules for new code; reach for the IIFE form only in single-file scripts, inline snippets, or legacy environments without a bundler.
  • Return the smallest public API that does the job — keep helpers and state private to reduce coupling.
  • Use the revealing variant when a module has many methods, so the public surface is declared in one readable place.
  • Pass dependencies into the IIFE as arguments instead of reaching for globals; it makes modules testable.
  • Remember that each IIFE invocation creates fresh private state, so a module instance is not shared unless you intend it to be.
  • When you are already writing a class, prefer #private fields over closures for encapsulation — they express intent more clearly.
Last updated June 1, 2026
Was this helpful?