Chapter 2: The Module System
Summary
Modules are the fundamental mechanism for structuring Node.js applications. This
chapter traces the evolution from the revealing module pattern (IIFE-based
encapsulation) through CommonJS (Node's original require/module.exports
system) to ESM (the official JavaScript standard with import/export).
The chapter builds a homemade module loader to show exactly how require()
works internally: wrapping source code in a function, creating a module object,
caching it before execution (critical for circular dependencies), then
evaluating the code. Understanding this mechanism demystifies module.exports vs
exports, the singleton cache, and circular dependency behavior.
The central lessons are: CommonJS loads synchronously and returns a cached object (snapshot semantics on primitives), while ESM uses live bindings and loads in three phases (parsing, instantiation, evaluation). Both systems handle circular dependencies, but through fundamentally different mechanisms -- CommonJS returns partially loaded exports, while ESM leverages live bindings for a cleaner resolution.
Key Concepts
The Need for Modules
Why Modules Matter
- Code splitting -- break large programs into manageable files
- Reusability -- use the same code across projects
- Encapsulation -- hide internal implementation, expose only the API
- Dependency management -- explicitly declare what a module needs
Without a module system, everything lives in the global scope -- name collisions, implicit dependencies, and no encapsulation. Modules solve all of these.
The Revealing Module Pattern
const myModule = (() => {
// Private scope -- not accessible outside
const privateFoo = 'secret';
const privateBar = () => privateFoo;
// Public API -- only these are exposed
return {
getSecret: privateBar
};
})();
myModule.getSecret(); // 'secret'
myModule.privateFoo; // undefined -- truly private
Conceptual Foundation The IIFE creates a closure that acts as a private scope. Only properties returned in the object are public. This is exactly what Node.js does under the hood -- it wraps each file in a function to create module scope. The revealing module pattern is the conceptual ancestor of CommonJS.
CommonJS Modules
Node.js wraps every module file in a function:
(function(exports, require, module, __filename, __dirname) {
// Your module code lives here
});
This gives each module its own scope with access to exports, require,
module, __filename, and __dirname -- injected as function parameters,
not globals.
The Homemade Module Loader
The book builds a simplified require() to show how it works. The key insight:
function loadModule(filename, module, require) {
const wrappedSrc = `(function(module, exports, require, __filename, __dirname) {
${fs.readFileSync(filename, 'utf8')}
})(module, module.exports, require, filename, dirname)`;
eval(wrappedSrc);
}
The source code is wrapped in a function that receives module/exports/require as parameters. This is why top-level variables in a module don't leak globally -- they are local to the wrapper function.
How require() Works -- The 6 Steps
require('myModule')
|
v
+-------------+ +--------------+
| 1. Resolve |---->| Full path |
| path | | of the file |
+-------------+ +--------------+
|
v
+-------------+ +------------------+
| 2. Check |-YES>| Return cached |
| cache | | module.exports |
+-------------+ +------------------+
| NO
v
+-------------+
| 3. Create | new Module({ id, exports: {}, loaded: false })
| module |
+-------------+
|
v
+-------------+
| 4. Cache it | require.cache[resolvedPath] = module
| (BEFORE | <-- Critical for circular deps!
| loading!) |
+-------------+
|
v
+-------------+
| 5. Load & | Read file, wrap in function, execute
| evaluate |
+-------------+
|
v
+-------------+
| 6. Return | return module.exports
| exports |
+-------------+
Step 4 is Critical The module is cached before its code runs. This is how Node.js handles circular dependencies -- when module B requires module A (which is still loading), it gets A's partially-loaded exports from the cache. Without pre-caching, circular dependencies would cause infinite recursion.
module.exports vs exports
// exports is just a shorthand reference
exports === module.exports // true (initially)
// Adding properties works fine
exports.hello = 'world'; // modifies module.exports
module.exports.hello = 'world'; // same thing
// Reassigning exports BREAKS the reference
exports = { hello: 'world' }; // exports now points to NEW object
// module.exports is still {}
// Reassigning module.exports WORKS
module.exports = { hello: 'world' }; // this is what require() returns
Common Gotcha
require() always returns module.exports, never exports. If you
reassign exports = { ... }, you're only changing a local variable.
module.exports still points to the original object. The link is broken.
Always use module.exports when replacing the entire export.
require() is Synchronous
require() blocks the event loop while it reads and executes the module file.
This is why:
- You can use
const fs = require('fs')and immediately callfs.readFile() - Module loading is acceptable at startup but expensive in hot paths
- ESM was designed to be asynchronous (supporting browser HTTP fetching)
The Resolving Algorithm
require('X') from module at path Y:
1. If X is a core module (fs, path, http...)
-> return core module
2. If X starts with './' or '/' or '../'
-> resolve as file: X, X.js, X.json, X.node
-> resolve as directory: X/index.js, X/index.json, X/index.node
-> or check X/package.json -> "main" field
3. Otherwise (bare package name like 'lodash')
-> look in ./node_modules/X
-> look in ../node_modules/X
-> look in ../../node_modules/X
-> ... walk up to filesystem root
This "walk up" algorithm is how Node.js solves dependency hell -- each
package can have its own node_modules/ with its own version of a dependency.
Package A can use lodash@3 while package B uses lodash@4, with no conflicts.
Module Cache
// counter.js
let count = 0;
module.exports = { increment: () => ++count, getCount: () => count };
// a.js
const counter = require('./counter');
counter.increment();
counter.increment();
// b.js
const counter = require('./counter');
console.log(counter.getCount()); // 2 -- same instance as a.js!
Singleton-Like Behavior
The cache means every require('./counter') returns the same object.
State is shared across all consumers. The cache is stored in require.cache
keyed by the resolved absolute filename.
Circular Dependencies
// a.js
exports.loaded = false;
const b = require('./b'); // B starts loading here
exports.loaded = true; // This runs AFTER b.js finishes
console.log('b.loaded:', b.loaded); // true
// b.js
const a = require('./a'); // Gets a's PARTIAL exports: { loaded: false }
exports.loaded = true;
console.log('a.loaded:', a.loaded); // false (!)
Partial Exports
When b.js requires a.js, it gets whatever a.js has exported SO FAR.
Since a.js hasn't finished executing (it's waiting for b.js), a.loaded
is still false. Circular deps work but can produce surprising results.
This works only because step 4 caches the module before step 5 executes it.
Module Definition Patterns
| Pattern | Syntax | Use Case |
|---|---|---|
| Named exports | exports.foo = ... | Multiple related utilities |
| Export a function | module.exports = function() {} | Single-purpose module (substack pattern) |
| Export a class | module.exports = class {} | Stateful objects |
| Export an instance | module.exports = new Foo() | Singleton / shared state |
| Monkey patching | Modify other modules/globals | Plugins, polyfills (use sparingly) |
The substack pattern (exporting a single function) is considered the most
focused: module.exports = function myFunction() { ... }. Named after the
prolific npm contributor who advocated small, single-purpose modules.
ESM (ECMAScript Modules)
Named and Default Exports
// Named exports
export const PI = 3.14159;
export function circleArea(r) { return PI * r ** 2; }
// Default export
export default class Logger { /* ... */ }
// Importing
import Logger, { PI, circleArea } from './math.js';
import { PI as pi } from './math.js'; // Rename
import * as math from './math.js'; // Namespace
Dynamic import()
// Conditional loading
if (needsFeature) {
const { feature } = await import('./feature.js');
}
// Computed specifier
const lang = getUserLang();
const strings = await import(`./i18n/${lang}.js`);
import() Works Everywhere
import() returns a Promise and can be used in both ESM and CommonJS.
It's the ONLY way to load ESM from CommonJS code.
ESM Loading Phases
Phase 1: PARSING
Parse all source files, build dependency graph
(static analysis -- no code runs)
|
v
Phase 2: INSTANTIATION
Create live bindings for all exports/imports
(memory allocated, connections made, no values yet)
|
v
Phase 3: EVALUATION
Execute code in dependency order (deepest dependency first)
(values flow through the live bindings)
These three phases are possible because import/export are static syntax -- they must appear at the top level and cannot be conditional. The engine knows the full dependency graph before any code runs, enabling tree-shaking and static analysis.
Live Bindings
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 -- LIVE! reflects the change
count = 5; // TypeError: read-only binding
CJS Snapshot vs ESM Live Bindings
CommonJS: require() returns an object. Primitive values on it are snapshots.
ESM: import creates a live, read-only reference. Changes in the exporter
ARE reflected in the importer. This also enables cleaner circular dependency
resolution -- a circular import gets a binding that will be filled in when
the exporting module evaluates.
ESM vs CommonJS
| Feature | CommonJS | ESM |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous (3 phases) |
| Bindings | Object properties (snapshot for primitives) | Live, read-only |
| Strict mode | Optional | Always on |
__filename, __dirname | Available | Not available (use import.meta.url) |
require function | Available | Not available (use import or import()) |
this at top level | module.exports | undefined |
| Conditional import | if (x) require(y) | if (x) await import(y) |
| File extensions | .js, .cjs | .mjs or .js with "type": "module" |
| JSON import | require('./data.json') | Experimental / import assertion |
| Circular deps | Partial exports (snapshot) | Live bindings (cleaner) |
| Tree-shaking | Not possible (dynamic) | Possible (static analysis) |
Interoperability
// ESM importing CommonJS -- works
import cjsModule from './legacy.cjs'; // default = module.exports
import { named } from './legacy.cjs'; // may work (static analysis)
// CommonJS importing ESM -- must use import()
const esmModule = await import('./modern.mjs');
The Asymmetry ESM can import CJS freely. CJS cannot require() ESM -- must use async import(). This asymmetry is the biggest source of friction when migrating codebases or mixing module types.
Mind Map
Connections
- Previous: Chapter 1 -- The Node.js Platform
- Next: Chapter 3 -- Callbacks and Events
- Module patterns (named exports, export function) are used throughout the Streams and Behavioral Design Patterns chapters
- The singleton pattern via module cache connects to Creational Design Patterns
- The wrapper function concept ties back to the IIFE / revealing module pattern