StackPractices
beginner By Mathias Paulenko

Module Pattern

Encapsulate private state and behavior inside a self-contained unit with a public API. A structural pattern for organizing code into reusable, scope-safe modules.

Topics: design

Overview

The Module Pattern keeps private state and helper functions inside a self-contained unit and exposes only the API you choose. Nothing leaks into the global namespace, so unrelated parts of a codebase can’t collide or reach into each other’s internals.

Before ES6, JavaScript achieved this with Immediately Invoked Function Expressions — wrap everything in a function, call it, return the public bits. Modern languages ship native modules instead (ES modules, Python packages, Java packages), but the core idea is the same either way: hide internals, publish an interface.

When to Use

  • You need private state that can’t be reached from outside the unit
  • Components sharing a codebase must not collide on names
  • You want consumers to see a clean API while implementation details stay hidden
  • Tests need clear boundaries between the public contract and private helpers

If you need to wrap an existing class behind a different interface rather than encapsulate state, the Adapter Pattern is the one you’re after.

When to Avoid

  • The language gives you native modules with real encapsulation — use those first
  • A simple script that gains nothing but indirection from being wrapped
  • Modules that would depend on each other deeply enough to create circular imports

Solution

JavaScript (IIFE)

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

  function log(operation) {
    console.log(`Counter ${operation}: ${count}`);
  }

  return {
    increment() {
      count++;
      log('incremented');
      return count;
    },
    decrement() {
      count--;
      log('decremented');
      return count;
    },
    getCount() {
      return count;
    }
  };
})();

// Usage
CounterModule.increment();
CounterModule.increment();
console.log(CounterModule.getCount()); // 2
// CounterModule.count is undefined — not accessible

JavaScript (ES Module)

// counter.js
let count = 0; // Module-scoped — not exported, not accessible

function log(operation) {
  console.log(`Counter ${operation}: ${count}`);
}

export function increment() {
  count++;
  log('incremented');
  return count;
}

export function decrement() {
  count--;
  log('decremented');
  return count;
}

export function getCount() {
  return count;
}

// main.js
import { increment, getCount } from './counter.js';
increment();
console.log(getCount());

Python

# counter.py
_count = 0  # Module-private by convention

def _log(operation):
    print(f"Counter {operation}: {_count}")

def increment():
    global _count
    _count += 1
    _log("incremented")
    return _count

def decrement():
    global _count
    _count -= 1
    _log("decremented")
    return _count

def get_count():
    return _count

# main.py
from counter import increment, get_count

increment()
increment()
print(get_count())  # 2
# counter._count is accessible but discouraged — leading underscore marks intent

Java

// com.myapp.counter.Counter.java
package com.myapp.counter;

public class Counter {
    private static int count = 0;

    private static void log(String operation) {
        System.out.println("Counter " + operation + ": " + count);
    }

    public static int increment() {
        count++;
        log("incremented");
        return count;
    }

    public static int decrement() {
        count--;
        log("decremented");
        return count;
    }

    public static int getCount() {
        return count;
    }
}

// Usage
Counter.increment();
System.out.println(Counter.getCount());

Explanation

The mechanism differs per language, but the contract is identical. In an IIFE, the returned object is the only thing that escapes — count and log live inside the closure, and the JavaScript engine keeps them alive only because the returned methods still reference them. There’s no syntax for reaching in from outside; privacy isn’t a convention, it’s enforced by scope.

ES modules do the same thing statically. Top-level declarations that aren’t exported are invisible to importers — count and log exist in the module’s scope and nowhere else. The difference matters at build time: a bundler can analyze import/export statements without executing anything, which is what makes tree-shaking possible (the bundle size optimization guide goes deep on why that matters for shipped code). An IIFE is opaque to that analysis — the bundler can’t tell which parts are used, so everything ships.

A module keeps private state and helpers inside a boundary; consumers only reach the public API

Python takes the weakest form: import gives real module scoping, but _count is only private by convention — anyone can still reach counter._count if they insist. Java sits between the two: private members are truly inaccessible from other classes, but “module” in the Java sense (a package, or a JPMS module since Java 9) is about grouping classes more than hiding values.

One consequence worth understanding: module state is shared state. Every importer of counter.js gets the same count — the module is a singleton namespace, whether you meant it or not. When you need several independent counters, you need a factory or a class, not a module. (And if you’re tempted to keep one global module per app state slice, the Singleton Pattern has the same shape with the same traps.)

Variants

VariantMechanismUse Case
IIFE ModuleClosure-based privacyPre-ES6 JavaScript, inline scripts
Revealing ModuleReturns an object literal mapping public names to private functionsCleaner API definition in JS
CommonJSmodule.exports / requireNode.js before ES modules, some tooling
ES Moduleexport/importModern JavaScript standard
Namespace ModuleObject literal namespaceGrouping utilities under one global

What Works

  • One concern per module. A module that counts, formats dates, and makes HTTP requests is three modules wearing a trench coat.
  • Export explicitly. Wildcard exports hide the contract — a reader should see the public API at a glance.
  • Name private members clearly. Python’s _prefix says “internal, don’t touch” without enforcement; JavaScript closures don’t need the hint.
  • Kill circular dependencies early. A imports B, B imports A — extract the shared part into a third module before the cycle ossifies.
  • Keep modules small. Past ~200 lines, a module usually wants splitting; past 500, it definitely does.

Common Mistakes

  • Global state in modules makes them untestable. A counter module with shared count can’t be reset between tests — you’ll end up with order-dependent test suites.
  • export * wildcard exports leak every internal helper as an accidental public API. The moment someone imports it, you’re stuck maintaining it.
  • Side effects on import — connecting to a database, registering listeners, mutating globals at load time — make modules unpredictable and break test isolation.
  • Deeply nested paths (a.b.c.d.e) create brittle import chains that snap on every refactor. Keep the graph shallow.
  • Treating modules as classes. A module is a namespace, not a blueprint — there’s no new CounterModule(). When you need instances, write a factory or a class.

Real-World Examples

Node.js Core Modules

fs, path, and http wrap OS operations behind stable APIs. The internal C++ bindings stay private — you never touch them directly, which is exactly the point.

Python Standard Library

json, re, and urllib hide parsers and regex compilers behind a handful of functions. json.loads is a public function; the scanner underneath is module-private.

Angular Modules

NgModule groups components, services, and directives into feature units with explicit exports — the framework’s own take on “hide internals, publish an interface.”

Troubleshooting

  • Circular imports at runtime: A imports B, B imports A — Node gives you a partially-initialized module (undefined exports where you expected functions). Extract the shared dependency into a third module both sides import.
  • State leaks between tests: module state persists for the whole test process. Reset it explicitly in setup, or inject a factory so each test builds a fresh instance.
  • require of an ES module fails: Node won’t require() an .mjs file or an "type": "module" package on older versions — switch to import or pin to a version where require(esm) is supported.
  • Import works locally but breaks in the bundler: usually a case-sensitivity mismatch (./Counter.js vs ./counter.js) — Linux CI will find it even if macOS doesn’t.
  • Everything is exported “just in case”: a bloated public surface becomes maintenance debt. Export what consumers need today; add the rest when asked.

Advanced Topics

Scenario: Module Pattern for Encapsulation in JS

// Module pattern: private encapsulation with closures
const PaymentModule = (function() {
  // Private: not accessible from outside
  let apiKey = "";
  let transactions: Transaction[] = [];
  const MAX_TRANSACTIONS = 1000;

  // Private: validate API key
  function validateKey(key: string): boolean {
    return key.startsWith("pk_") && key.length === 32;
  }

  // Private: internal logging
  function log(msg: string) {
    console.log(`[PAYMENT] ${new Date().toISOString()} ${msg}`);
  }

  // Public: exposed API
  return {
    init(key: string) {
      if (!validateKey(key)) throw new Error("Invalid API key");
      apiKey = key;
      log("Module initialized");
    },
    charge(amount: number, currency: string): Promise<Transaction> {
      if (!apiKey) throw new Error("Module not initialized");
      if (transactions.length >= MAX_TRANSACTIONS) {
        throw new Error("Transaction limit reached");
      }
      log(`Charging ${amount} ${currency}`);
      const tx = { id: crypto.randomUUID(), amount, currency, status: "pending" };
      transactions.push(tx);
      return Promise.resolve(tx);
    },
    getTransactions(): Transaction[] { return [...transactions]; },
    getStats() {
      return {
        total: transactions.length,
        pending: transactions.filter(t => t.status === "pending").length,
        completed: transactions.filter(t => t.status === "completed").length,
      };
    }
  };
})();

// Usage
PaymentModule.init("pk_test_1234567890123456789012345678");
const tx = await PaymentModule.charge(99.99, "USD");
console.log(PaymentModule.getStats()); // { total: 1, pending: 1, completed: 0 }
// PaymentModule.apiKey -> undefined (private)
// PaymentModule.validateKey -> undefined (private)

This example shows why the pattern survived ES6: init creates a lifecycle the caller controls, apiKey and transactions are unreachable without reflection tricks, and the cap on MAX_TRANSACTIONS shows state enforcement that would be awkward to bolt onto a plain object. The trade-off is the one mentioned earlier — everything ships, always, because the bundler can’t see inside the closure.

Further Reading

Frequently Asked Questions

Is the Module Pattern the same as a class?

No. A class is a blueprint you instantiate many times; a module is a single namespace evaluated once. Use modules to organize code and classes when you need several instances with their own state.

Can modules depend on each other?

Yes, that's normal — just avoid cycles. If A imports B and B imports A, extract the shared piece into a third module both depend on.

Should I still use the IIFE Module Pattern in 2026?

Only for legacy code or inline scripts without a build step. ES modules are the standard — native in browsers and Node, statically analyzable, tree-shakeable. An IIFE ships everything; an ES module lets the bundler drop what you don't import.

How do I migrate an IIFE module to an ES module?

Move the file's contents to top level, delete the wrapper function, and replace the returned object with individual export statements. Private members stay unexported — they were private by scope before, and they're private by non-export now. The behavior is identical; the syntax is cleaner.

How do I test module-private functions?

You don't — test them through the public API. If a private function is complex enough to need direct tests, that's a sign it belongs in its own module.

What's the difference between a module and a singleton?

A module is a file-level namespace; a singleton is a deliberately single instance of a class or object. Every ES module is a singleton in practice, but singletons can also be created lazily and injected — modules can't be swapped that way.