The Module System
The Module System
Modules are the building blocks of Node.js applications. Understanding how they work—loading, caching, resolution—is essential for writing maintainable code and avoiding common pitfalls.
CommonJS: The Original Module System
Node.js originally used CommonJS modules, and they're still widely used today.
Basic Syntax
// math.js - exporting
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// or
exports.add = add;
exports.multiply = multiply;
// app.js - importing
const { add, multiply } = require("./math");
console.log(add(2, 3)); // 5How require() Works
When you call require(), Node.js:
- Resolves the module path
- Loads the module file
- Wraps in a function
- Executes the code
- Caches the result
- Returns
module.exports
The Module Wrapper
Every module is wrapped in a function before execution:
// Your code:
const x = 10;
module.exports = { x };
// What Node.js actually runs:
(function (exports, require, module, __filename, __dirname) {
const x = 10;
module.exports = { x };
});This is why __filename, __dirname, and module are available—they're function parameters, not globals.
Module Resolution Algorithm
When you require('something'):
require('./local') → ./local.js, ./local.json, ./local/index.js
require('/absolute') → /absolute.js, /absolute.json, /absolute/index.js
require('package') → node_modules/package, then parent node_modules, ...Resolution Steps for require('lodash')
1. Is it a core module? (fs, http, etc.) → No
2. Check ./node_modules/lodash
3. Check ../node_modules/lodash
4. Check ../../node_modules/lodash
5. Continue until root
6. Check global node_modules
7. Throw MODULE_NOT_FOUNDModule Caching
Modules are cached after first load. This is crucial to understand:
// counter.js
let count = 0;
module.exports = {
increment: () => ++count,
getCount: () => count,
};
// a.js
const counter = require("./counter");
counter.increment();
console.log(counter.getCount()); // 1
// b.js
const counter = require("./counter");
console.log(counter.getCount()); // Still 1! Same instanceWarning
Module caching means singletons by default. This can cause issues: - Shared mutable state between different parts of your app - Different versions of the same package may be cached separately - Circular dependencies can return partially initialized modules
exports vs module.exports
A common source of confusion:
// This works
exports.foo = "bar";
// This works
module.exports = { foo: "bar" };
// This DOESN'T work
exports = { foo: "bar" }; // Reassigns local variable, not the actual export
// Why? Because of the wrapper:
// exports is just a reference to module.exports
// Reassigning exports breaks the referenceES Modules: The Modern Standard
ES Modules (ESM) are the JavaScript standard, now fully supported in Node.js.
Basic Syntax
// math.mjs (or .js with "type": "module" in package.json)
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export default function subtract(a, b) {
return a - b;
}
// app.mjs
import subtract, { add, multiply } from "./math.mjs";
console.log(add(2, 3)); // 5
console.log(subtract(5, 2)); // 3Enabling ES Modules
Three ways:
- Use
.mjsextension - Add
"type": "module"topackage.json - Use
--input-type=moduleflag
// package.json
{
"name": "my-app",
"type": "module"
}Key Differences from CommonJS
| Feature | CommonJS | ES Modules |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Parsing | Runtime | Static (compile time) |
| Top-level await | No | Yes |
__filename | Available | Not available (use import.meta.url) |
| JSON import | require('./data.json') | import data from './data.json' with { type: 'json' } |
Static vs Dynamic
ES Modules are statically analyzed:
// This is INVALID - imports must be top-level
if (condition) {
import { foo } from "./foo.mjs"; // SyntaxError!
}
// Use dynamic import() for conditional loading
if (condition) {
const { foo } = await import("./foo.mjs"); // Works!
}Interoperability
Using CommonJS from ESM:
// ESM file importing CJS
import pkg from "cjs-package"; // Default import
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const cjsModule = require("./legacy.cjs");Using ESM from CommonJS:
// CJS file importing ESM (must use dynamic import)
async function main() {
const esmModule = await import("./modern.mjs");
console.log(esmModule.default);
}
main();Module Patterns
The Revealing Module Pattern
Expose only what's needed:
// logger.js
const logs = []; // Private
function log(message) {
logs.push({ message, timestamp: Date.now() });
console.log(message);
}
function getLogs() {
return [...logs]; // Return copy
}
module.exports = { log, getLogs };
// logs array is not accessible from outsideThe Substack Pattern
Export a single function (the main functionality):
// greet.js
function greet(name) {
return `Hello, ${name}!`;
}
greet.formal = function (name) {
return `Good day, ${name}.`;
};
greet.casual = function (name) {
return `Hey ${name}!`;
};
module.exports = greet;
// Usage
const greet = require("./greet");
greet("World"); // "Hello, World!"
greet.formal("World"); // "Good day, World."The Factory Pattern
Export a function that creates instances:
// database.js
function createDatabase(config) {
const connection = connect(config);
return {
query: (sql) => connection.execute(sql),
close: () => connection.end(),
};
}
module.exports = createDatabase;
// Usage
const createDatabase = require("./database");
const db = createDatabase({ host: "localhost", port: 5432 });The Singleton Pattern
Ensure a single instance exists:
// cache.js
class Cache {
constructor() {
if (Cache.instance) {
return Cache.instance;
}
this.data = new Map();
Cache.instance = this;
}
set(key, value) {
this.data.set(key, value);
}
get(key) {
return this.data.get(key);
}
}
module.exports = new Cache();
// Every require gets the same instance
// (This works due to module caching anyway)Note
Due to module caching, any object you export is effectively a singleton. The
explicit singleton pattern is only needed when you want to prevent users from
creating new instances via new.
Handling Circular Dependencies
Circular dependencies happen when module A requires B, and B requires A.
The Problem
// a.js
const b = require("./b");
console.log("in a, b.loaded =", b.loaded);
module.exports = { loaded: true };
// b.js
const a = require("./a");
console.log("in b, a.loaded =", a.loaded);
module.exports = { loaded: true };
// main.js
require("./a");
// Output:
// in b, a.loaded = undefined ← a.js hasn't finished exporting yet!
// in a, b.loaded = trueSolutions
1. Restructure to Remove Cycle
The best solution is to refactor. Extract shared code to a third module.
2. Move require() Inside Functions
// a.js
module.exports = {
getB: () => require("./b"),
loaded: true,
};
// b.js
module.exports = {
getA: () => require("./a"),
loaded: true,
};3. Export Before Requiring
// a.js
module.exports = { loaded: true }; // Export first
const b = require("./b"); // Then requireWarning
Circular dependencies are a code smell. They indicate tightly coupled modules. Refactor to break the cycle when possible.
Package Management Deep Dive
package.json Essentials
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.js"
},
"imports": {
"#utils": "./src/utils/index.js",
"#config": "./src/config.js"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
}The exports Field
Modern way to define entry points:
{
"exports": {
".": "./src/index.js",
"./utils": "./src/utils/index.js",
"./package.json": "./package.json"
}
}import pkg from "my-package"; // Loads ./src/index.js
import utils from "my-package/utils"; // Loads ./src/utils/index.js
import deep from "my-package/deep/path"; // Error! Not in exportsSubpath Imports (#imports)
Private imports within your package:
{
"imports": {
"#utils/*": "./src/utils/*.js",
"#db": "./src/database/index.js"
}
}// Anywhere in your package
import { helper } from "#utils/helper";
import db from "#db";Dependency Types
{
"dependencies": {
"express": "^4.18.0" // Required at runtime
},
"devDependencies": {
"jest": "^29.0.0" // Only for development
},
"peerDependencies": {
"react": "^18.0.0" // User must install
},
"optionalDependencies": {
"fsevents": "^2.3.0" // OK if installation fails
}
}Version Ranges
^1.2.3 → >=1.2.3 <2.0.0 (compatible changes)
~1.2.3 → >=1.2.3 <1.3.0 (patch-level changes)
1.2.3 → exactly 1.2.3
* → any version
>=1.0.0 → 1.0.0 or higher
1.2.x → 1.2.0, 1.2.1, ... (any patch)Summary
The Node.js module system is foundational:
| System | Use Case |
|---|---|
| CommonJS | Legacy code, dynamic loading, simpler tooling |
| ES Modules | Modern code, tree shaking, static analysis, top-level await |
Key patterns:
| Pattern | When to Use |
|---|---|
| Revealing Module | Hide implementation details |
| Substack | Single main function with helpers |
| Factory | Create configured instances |
| Singleton | Global shared state |
Essential knowledge:
- Module caching makes exports effectively singletons
exportsis a reference tomodule.exports—don't reassign it- Circular dependencies return incomplete exports—avoid them
- ES Modules are async and statically analyzed
exportsfield in package.json defines public API- Subpath imports (
#) enable internal aliases
Note
When starting new projects, prefer ES Modules. When maintaining legacy code, understand CommonJS deeply. Both will coexist for years to come.