What is Module design pattern?
Module pattern is a design pattern that wraps code in an IIFE to create a private scope and returns an object as a public API.
Theory
TL;DR
- Wrap code in an IIFE (immediately invoked function expression) to get a private scope
- Variables declared inside never leak out
- The returned object is the public API - only what you return is accessible
- Before ES6 modules existed, this was the standard way to avoid polluting the global scope
- You still see it in legacy codebases and wherever a singleton with private state is needed
Quick example
const Counter = (function() {
// count is private - nothing outside can reach it
let count = 0;
return {
increment() { count++; },
decrement() { count--; },
getCount() { return count; }
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.getCount()); // 2
console.log(Counter.count); // undefined - truly privateThe IIFE runs once, creates a closure over count, then disappears. What remains is only the returned object. count lives in memory but is completely unreachable from outside.
How it works
Two things make this work: the IIFE and the closure.
The IIFE runs immediately and drops the function reference after execution. But the inner functions increment, decrement, and getCount still hold a reference to the scope where count lives. That is a closure. As long as those functions exist, count stays in memory and is accessible only through them.
This is not fake privacy. Counter.count returns undefined because there is no property called count on the object. The actual variable sits in a scope with no name - unreachable by any other code.
Revealing Module Pattern
A useful variation is the Revealing Module Pattern. Instead of defining methods inside the returned object, you define everything as private first, then selectively expose references.
const UserStore = (function() {
let users = [];
function add(user) {
users.push(user);
}
function getAll() {
return [...users]; // return a copy, not the original reference
}
function count() {
return users.length;
}
// Reveal only what's needed
return { add, getAll, count };
})();
UserStore.add({ id: 1, name: 'Alice' });
console.log(UserStore.getAll()); // [{ id: 1, name: 'Alice' }]All logic is defined the same way. The return statement becomes a clear manifest of the public API - everything above it is private.
When to use
- You need a singleton with private internal state
- You are working in a pre-ES6 environment or writing a script without a bundler
- You want to wrap a library or SDK and expose only a clean interface
- You need a self-contained script that cannot touch the global scope
Module pattern vs ES6 modules
ES6 modules (import/export) solve the same problem at the language level. An ES6 module file has its own scope by default - no IIFE needed. Anything not exported is private automatically.
// ES6 module - userStore.js
let users = [];
export function add(user) { users.push(user); }
export function getAll() { return [...users]; }The Module pattern still makes sense when everything lives in one file without a bundler, when building a script-tag library, or when you need a factory that creates multiple independent instances.
Common mistakes
Returning a reference instead of a copy
Most bugs I have seen with this pattern come from exposing a direct reference to an internal array or object.
const Store = (function() {
let items = [];
return {
getItems() { return items; }, // wrong - exposes the reference
getItemsSafe() { return [...items]; } // right - returns a copy
};
})();
const ref = Store.getItems();
ref.push('injected'); // you just mutated the private stateReturn a copy. Always.
The Revealing Module Pattern trap
If a public method calls a private method, and you later reassign the public method from outside, the internal calls still point to the original private function. The override does not propagate inward.
Using the pattern when ES6 modules are available
In any modern project with a bundler, use import/export. The Module pattern is a workaround for a missing language feature. In a React or Node.js project it adds complexity without benefit.
Where you see it
- jQuery wrapped the entire library in a single
$namespace using this pattern - Older browser SDKs (Google Analytics, legacy Stripe.js) use it to avoid touching the global object
- Node packages written before CommonJS used variations of this approach
- Interview questions reference it often because it tests closure knowledge directly
Follow-up questions
Q: What is an IIFE and why does the Module pattern need one?
A: An IIFE is a function that runs immediately after being defined. The Module pattern uses it to create a temporary private scope. Once the IIFE finishes, the outer scope is gone, but the closures it created still hold references to the inner variables.
Q: What happens if two modules need to share private state?
A: They cannot, by design. Each IIFE gets its own scope. If you need shared state between modules, expose it through public API methods or move it to a third module that both use.
Q: How does the Module pattern differ from a class with private fields (#field)?
A: A class creates a prototype chain and supports multiple instances via new. The Module pattern in IIFE form typically produces a singleton. ES2022 private fields (#) are the class-based equivalent of what the Module pattern achieves with closures - but with better tooling support and clearer syntax.
Q: Can you create multiple instances with the Module pattern?
A: Yes. Drop the IIFE and use a factory function. Each call creates a fresh closure with independent state.
function makeCounter() {
let count = 0;
return {
increment() { count++; },
getCount() { return count; }
};
}
const c1 = makeCounter();
const c2 = makeCounter(); // independent instance, separate countQ: What does the interviewer actually want to hear when they ask about Module pattern?
A: That you know it is built on closures, that "private" here is not a language keyword but an inaccessible scope, and that you know when ES6 modules are the better choice.
Examples
Basic counter
const Counter = (function() {
let count = 0;
return {
increment() { count++; },
reset() { count = 0; },
getCount() { return count; }
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.getCount()); // 2
Counter.count = 999; // no effect on the internal variable
console.log(Counter.getCount()); // still 2Counter.count = 999 creates a new property on the returned object. The internal count variable is untouched - it lives in a different scope entirely.
API client with private config
const ApiClient = (function() {
const BASE_URL = 'https://api.example.com';
let authToken = null;
function buildHeaders() {
return {
'Content-Type': 'application/json',
...(authToken && { Authorization: `Bearer ${authToken}` })
};
}
return {
setToken(token) {
authToken = token;
},
async get(path) {
const response = await fetch(`${BASE_URL}${path}`, {
headers: buildHeaders()
});
return response.json();
},
async post(path, body) {
const response = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(body)
});
return response.json();
}
};
})();
ApiClient.setToken('my-secret-token');
ApiClient.get('/users'); // BASE_URL and authToken are inaccessible from outsideBASE_URL and authToken are locked inside the closure. External code can call setToken to update the token but cannot read it directly.
Factory for multiple instances
function createFormValidator(rules) {
const errors = {};
let submitted = false;
function validate(data) {
Object.keys(rules).forEach(field => {
if (rules[field].required && !data[field]) {
errors[field] = `${field} is required`;
}
});
return Object.keys(errors).length === 0;
}
return {
validate,
submit(data) {
if (validate(data)) {
submitted = true;
return true;
}
return false;
},
getErrors() { return { ...errors }; },
isSubmitted() { return submitted; }
};
}
const loginValidator = createFormValidator({
email: { required: true },
password: { required: true }
});
const signupValidator = createFormValidator({
email: { required: true },
username: { required: true },
password: { required: true }
});
// Each validator has completely independent state
loginValidator.submit({ email: 'a@b.com' }); // fails: password missing
console.log(signupValidator.isSubmitted()); // false - unaffectedEach call to createFormValidator produces a new closure with its own errors and submitted. The two validators share no state.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.