Learning Guides
Menu

The Module System

8 min readNode.js Design Patterns

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

JAVASCRIPT
// 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)); // 5

How require() Works

When you call require(), Node.js:

  1. Resolves the module path
  2. Loads the module file
  3. Wraps in a function
  4. Executes the code
  5. Caches the result
  6. Returns module.exports

The Module Wrapper

Every module is wrapped in a function before execution:

JAVASCRIPT
// 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'):

PLAINTEXT
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')

PLAINTEXT
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_FOUND

Module Caching

Modules are cached after first load. This is crucial to understand:

JAVASCRIPT
// 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 instance

Warning

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:

JAVASCRIPT
// 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 reference

ES Modules: The Modern Standard

ES Modules (ESM) are the JavaScript standard, now fully supported in Node.js.

Basic Syntax

JAVASCRIPT
// 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)); // 3

Enabling ES Modules

Three ways:

  1. Use .mjs extension
  2. Add "type": "module" to package.json
  3. Use --input-type=module flag
JSON
// package.json
{
  "name": "my-app",
  "type": "module"
}

Key Differences from CommonJS

FeatureCommonJSES Modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous
ParsingRuntimeStatic (compile time)
Top-level awaitNoYes
__filenameAvailableNot available (use import.meta.url)
JSON importrequire('./data.json')import data from './data.json' with { type: 'json' }

Static vs Dynamic

ES Modules are statically analyzed:

JAVASCRIPT
// 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:

JAVASCRIPT
// 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:

JAVASCRIPT
// 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:

JAVASCRIPT
// 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 outside

The Substack Pattern

Export a single function (the main functionality):

JAVASCRIPT
// 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:

JAVASCRIPT
// 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:

JAVASCRIPT
// 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

JAVASCRIPT
// 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 = true

Solutions

1. Restructure to Remove Cycle

The best solution is to refactor. Extract shared code to a third module.

2. Move require() Inside Functions

JAVASCRIPT
// a.js
module.exports = {
  getB: () => require("./b"),
  loaded: true,
};
 
// b.js
module.exports = {
  getA: () => require("./a"),
  loaded: true,
};

3. Export Before Requiring

JAVASCRIPT
// a.js
module.exports = { loaded: true }; // Export first
const b = require("./b"); // Then require

Warning

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

JSON
{
  "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:

JSON
{
  "exports": {
    ".": "./src/index.js",
    "./utils": "./src/utils/index.js",
    "./package.json": "./package.json"
  }
}
JAVASCRIPT
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 exports

Subpath Imports (#imports)

Private imports within your package:

JSON
{
  "imports": {
    "#utils/*": "./src/utils/*.js",
    "#db": "./src/database/index.js"
  }
}
JAVASCRIPT
// Anywhere in your package
import { helper } from "#utils/helper";
import db from "#db";

Dependency Types

JSON
{
  "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

PLAINTEXT
^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:

SystemUse Case
CommonJSLegacy code, dynamic loading, simpler tooling
ES ModulesModern code, tree shaking, static analysis, top-level await

Key patterns:

PatternWhen to Use
Revealing ModuleHide implementation details
SubstackSingle main function with helpers
FactoryCreate configured instances
SingletonGlobal shared state

Essential knowledge:

  1. Module caching makes exports effectively singletons
  2. exports is a reference to module.exports—don't reassign it
  3. Circular dependencies return incomplete exports—avoid them
  4. ES Modules are async and statically analyzed
  5. exports field in package.json defines public API
  6. 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.