JavaScript Module Pattern: Advanced Tutorial

Master the JavaScript module pattern with advanced techniques. Covers IIFE modules, private state encapsulation, augmentation patterns, loose and tight augmentation, sub-modules, cross-file modules, and comparing module patterns with ES modules.

JavaScriptadvanced
16 min read

The module pattern uses closures and IIFEs to create private scope, encapsulating implementation details while exposing a clean public API. Despite ES modules being the modern standard, understanding this pattern is essential for maintaining legacy code and grasping JavaScript scope mechanics.

For the revealing variant, see Implementing the Revealing Module Pattern JS.

Basic Module Pattern

javascriptjavascript
// The module pattern uses an IIFE to create private scope
const CounterModule = (function () {
  // Private state - not accessible from outside
  let count = 0;
  const MAX_COUNT = 100;
 
  // Private function
  function validate(value) {
    if (value < 0) throw new Error("Count cannot be negative");
    if (value > MAX_COUNT) throw new Error(`Count cannot exceed ${MAX_COUNT}`);
    return true;
  }
 
  // Public API (returned object)
  return {
    increment() {
      const newCount = count + 1;
      validate(newCount);
      count = newCount;
      return count;
    },
 
    decrement() {
      const newCount = count - 1;
      validate(newCount);
      count = newCount;
      return count;
    },
 
    getCount() {
      return count;
    },
 
    reset() {
      count = 0;
      return count;
    },
  };
})();
 
console.log(CounterModule.increment()); // 1
console.log(CounterModule.increment()); // 2
console.log(CounterModule.getCount());  // 2
// CounterModule.count -> undefined (private)
// CounterModule.validate -> undefined (private)

Module with Dependencies

javascriptjavascript
// Pass dependencies as IIFE arguments for explicit wiring
const UserService = (function (storage, validator, eventBus) {
  const STORAGE_KEY = "users";
  let cache = null;
 
  function loadFromStorage() {
    if (cache) return cache;
    const data = storage.get(STORAGE_KEY);
    cache = data ? JSON.parse(data) : [];
    return cache;
  }
 
  function saveToStorage(users) {
    cache = users;
    storage.set(STORAGE_KEY, JSON.stringify(users));
  }
 
  return {
    getAll() {
      return [...loadFromStorage()];
    },
 
    getById(id) {
      return loadFromStorage().find((u) => u.id === id) || null;
    },
 
    create(userData) {
      const errors = validator.validate(userData, {
        name: { required: true, minLength: 2 },
        email: { required: true, pattern: /^[^@]+@[^@]+$/ },
      });
 
      if (errors.length > 0) {
        throw new Error(`Validation failed: ${errors.join(", ")}`);
      }
 
      const users = loadFromStorage();
      const newUser = {
        id: Date.now().toString(36),
        ...userData,
        createdAt: new Date().toISOString(),
      };
 
      users.push(newUser);
      saveToStorage(users);
      eventBus.emit("user:created", newUser);
      return newUser;
    },
 
    delete(id) {
      const users = loadFromStorage().filter((u) => u.id !== id);
      saveToStorage(users);
      eventBus.emit("user:deleted", { id });
    },
 
    clearCache() {
      cache = null;
    },
  };
})(StorageModule, ValidatorModule, EventBusModule);

Module Augmentation

javascriptjavascript
// Module augmentation adds functionality to an existing module
// Useful for splitting large modules across files
 
// File 1: core.js
var AppModule = (function (mod) {
  mod.version = "1.0.0";
 
  mod.init = function (config) {
    mod._config = config;
    console.log("App initialized with:", config);
  };
 
  return mod;
})(AppModule || {});
 
// File 2: utils.js (augments AppModule)
var AppModule = (function (mod) {
  mod.utils = {
    formatDate(date) {
      return new Date(date).toLocaleDateString();
    },
 
    generateId() {
      return Math.random().toString(36).slice(2, 11);
    },
 
    debounce(fn, delay) {
      let timer;
      return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
      };
    },
  };
 
  return mod;
})(AppModule || {});
 
// File 3: api.js (augments AppModule)
var AppModule = (function (mod) {
  const baseUrl = "/api";
 
  mod.api = {
    async get(endpoint) {
      const response = await fetch(`${baseUrl}${endpoint}`);
      return response.json();
    },
 
    async post(endpoint, data) {
      const response = await fetch(`${baseUrl}${endpoint}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      return response.json();
    },
  };
 
  return mod;
})(AppModule || {});
 
// All three files contribute to the same module
// AppModule.init({ debug: true });
// AppModule.utils.formatDate(Date.now());
// AppModule.api.get("/users");

Tight vs Loose Augmentation

javascriptjavascript
// LOOSE AUGMENTATION: files can load in any order
// Uses (AppModule || {}) so module is created if it does not exist yet
var AppModule = (function (mod) {
  mod.featureA = function () { return "A"; };
  return mod;
})(AppModule || {});
 
// TIGHT AUGMENTATION: requires specific load order
// Module MUST already exist (no || {} fallback)
var AppModule = (function (mod) {
  // Can override or extend existing methods
  const originalInit = mod.init;
 
  mod.init = function (config) {
    console.log("Pre-init hook");
    originalInit(config);
    console.log("Post-init hook");
  };
 
  return mod;
})(AppModule); // Will throw if AppModule does not exist
 
// CHOOSING BETWEEN THEM:
// Loose: independent features that don't depend on each other
// Tight: extensions that override or depend on existing module members
PatternLoad OrderModule Must ExistUse Case
Loose augmentationAny orderNoIndependent features
Tight augmentationSpecific orderYesOverrides and extensions
Sub-modulesAfter parentYesNamespace organization
Dependency injectionIIFE argumentsDependencies firstTestable modules

Module Factory

javascriptjavascript
// Factory function creates configurable module instances
function createStore(initialState = {}) {
  let state = { ...initialState };
  const listeners = new Set();
  const middleware = [];
 
  function notify() {
    const currentState = getState();
    listeners.forEach((listener) => listener(currentState));
  }
 
  function getState() {
    return { ...state };
  }
 
  function setState(updater) {
    const prevState = { ...state };
    const updates = typeof updater === "function"
      ? updater(prevState)
      : updater;
 
    // Run middleware
    let finalUpdates = updates;
    for (const mw of middleware) {
      finalUpdates = mw(prevState, finalUpdates) || finalUpdates;
    }
 
    state = { ...state, ...finalUpdates };
    notify();
    return getState();
  }
 
  function subscribe(listener) {
    listeners.add(listener);
    return function unsubscribe() {
      listeners.delete(listener);
    };
  }
 
  function use(mw) {
    middleware.push(mw);
  }
 
  return { getState, setState, subscribe, use };
}
 
// Each call creates an independent instance
const userStore = createStore({ users: [], loading: false });
const cartStore = createStore({ items: [], total: 0 });
 
userStore.subscribe((state) => console.log("Users:", state.users.length));
userStore.setState({ users: [{ name: "Alice" }], loading: false });

Module Pattern vs ES Modules

javascriptjavascript
// MODULE PATTERN (pre-ES6)
const MyModule = (function () {
  let _private = 0;
  return {
    get() { return _private; },
    set(v) { _private = v; },
  };
})();
 
// ES MODULE (modern)
// myModule.js
let _private = 0;
export function get() { return _private; }
export function set(v) { _private = v; }
 
// Comparison:
// Module Pattern:
//   + Works in all environments
//   + No build tools required
//   + Immediate execution
//   - Global namespace pollution
//   - No static analysis for tree shaking
//   - Manual dependency management
 
// ES Modules:
//   + Native browser support
//   + Static analysis (tree shaking)
//   + Clear dependency graph (import/export)
//   + Scoped by default (no global pollution)
//   - Requires build tools for older browsers
//   - Asynchronous loading semantics
Rune AI

Rune AI

Key Insights

  • IIFEs create true private scope: Variables declared inside the IIFE are inaccessible from outside, providing genuine encapsulation through closures
  • Dependency injection via IIFE arguments: Pass dependencies as arguments to make modules testable and loosely coupled
  • Loose augmentation allows any load order: Using (Module || {}) lets files extend a module independently without requiring a specific script order
  • Module factories create independent instances: Factory functions return new module objects each time, unlike the singleton IIFE pattern
  • ES modules are the modern replacement: For new code, use native import/export syntax which provides static analysis, tree shaking, and browser-native loading
RunePowered by Rune AI

Frequently Asked Questions

When should I still use the module pattern over ES modules?

Use the module pattern when maintaining legacy codebases that cannot adopt ES modules, when writing inline scripts that must run without build tools, or when building browser extensions or userscripts that load in non-module contexts. For all new projects, prefer ES modules with import/export syntax.

How does the module pattern achieve private variables?

The IIFE creates a closure that captures variables in its scope. Since JavaScript closures persist the scope chain, variables declared inside the IIFE remain accessible to the returned public methods but are invisible to code outside the IIFE. This leverages JavaScript's lexical scoping rather than any access modifier keyword.

Can I unit test private functions in a module pattern?

Not directly, since private functions are not exposed. You can test them indirectly through public methods that call them. Alternatively, expose private functions only in test builds using a conditional flag, or restructure the module to accept private functions as injectable dependencies.

What is the difference between module pattern and namespace pattern?

The namespace pattern simply groups functions under an object (`var App = { utils: {}, api: {} }`) without any privacy. All members are publicly accessible. The module pattern uses an IIFE closure to create truly private members that cannot be accessed from outside, exposing only the returned public API.

How do I handle circular dependencies with the module pattern?

Use loose augmentation where both modules create themselves with `(Module || {})`. Since they extend rather than replace, load order does not matter. For tight coupling, restructure so shared functionality lives in a third module that both depend on, breaking the cycle.

Conclusion

The module pattern remains foundational JavaScript knowledge for understanding closures, encapsulation, and scope. Module factories create configurable instances, while augmentation patterns split large modules across files. For the cleaner revealing variant, see Implementing the Revealing Module Pattern JS. For the singleton pattern built on modules, see JavaScript Singleton Pattern: Complete Guide.