Node.js Module System: CommonJS and ES Modules Explained

Node.js Module System: CommonJS and ES Modules Explained

이 글의 핵심

A practical guide to Node.js modules: CommonJS and ES modules, exports, caching, circular dependencies, and real patterns used in production apps.

Introduction

What is a module?

A module is a reusable unit of code in its own file. Node.js supports two systems:

  1. CommonJS — default in Node (require, module.exports)
  2. ES modules — standard ECMAScript syntax (import, export)

Benefits:

  • Reuse code across files
  • Avoid polluting the global scope
  • Keep features separated for maintenance
  • Express dependencies explicitly
  • Test units in isolation

1. CommonJS modules

Basics

Export:

// math.js
function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

const PI = 3.14159;

module.exports = {
    add,
    subtract,
    PI
};

Import:

// app.js
const math = require('./math');

console.log(math.add(10, 5));
console.log(math.subtract(10, 5));
console.log(math.PI);

exports vs module.exports

// OK: add properties to exports (same object as module.exports)
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;

// OK: replace module.exports entirely
module.exports = {
    add: (a, b) => a + b,
    subtract: (a, b) => a - b
};

// WRONG: reassigning exports breaks the link to module.exports
exports = {
    add: (a, b) => a + b
};

Conceptually, Node wraps your file and passes module and exports where exports starts as a reference to module.exports. Reassigning exports only changes the local variable.

Rules:

  • exports.foo = ... — OK
  • module.exports = ... — OK
  • exports = { ... } — does not change what require returns

Export patterns

Multiple functions:

// utils.js
exports.formatDate = (date) => date.toISOString().split('T')[0];
exports.capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);

Single class:

// user.js
class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    greet() {
        return `Hello, ${this.name}!`;
    }
}

module.exports = User;

Singleton:

// database.js
class Database {
    constructor() {
        this.connection = null;
    }
    connect() {
        if (!this.connection) {
            this.connection = { connected: true };
        }
        return this.connection;
    }
}

module.exports = new Database();

Factory:

// logger.js
function createLogger(prefix) {
    return {
        log: (message) => console.log(`[${prefix}] ${message}`),
        error: (message) => console.error(`[${prefix}] ERROR: ${message}`)
    };
}

module.exports = createLogger;

2. ES modules

Setup

package.json:

{
  "type": "module"
}

Or use the .mjs extension.

Named exports

// math.mjs
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

export const PI = 3.14159;

export { multiply, divide };
// app.mjs
import { add, subtract, PI } from './math.mjs';
import { add as plus } from './math.mjs';
import * as math from './math.mjs';

Default export

// calculator.mjs
export default class Calculator {
    add(a, b) { return a + b; }
    subtract(a, b) { return a - b; }
}

export const VERSION = '1.0.0';
import Calculator, { VERSION } from './calculator.mjs';

Dynamic import()

async function loadModule() {
    if (condition) {
        const mod = await import('./heavy-module.mjs');
        mod.doSomething();
    }
}

3. CommonJS vs ES modules

FeatureCommonJSES modules
Syntaxrequire, module.exportsimport, export
LoadingSynchronous require at runtimeParsed statically; async load
Extension.js (default).mjs or "type": "module"
Default exportmodule.exports = ...export default
Tree shakingLimitedYes
BrowserNo (without bundler)Native in modern browsers

Use CommonJS for legacy code, many older packages, or quick scripts.
Use ES modules for new apps, shared browser/Node code, and bundler-friendly tree shaking.

Interop

From CommonJS, load ESM:

async function loadESM() {
    const mod = await import('./es-module.mjs');
    mod.default();
}

From ESM, load CommonJS:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjs = require('./commonjs-module.js');

4. Built-in modules (overview)

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const url = require('url');
const querystring = require('querystring');
const os = require('os');
const crypto = require('crypto');
const EventEmitter = require('events');
const stream = require('stream');
const child_process = require('child_process');

fs (promises)

const fs = require('fs').promises;
const path = require('path');

async function fileOperations() {
    try {
        const data = await fs.readFile('input.txt', 'utf8');
        await fs.writeFile('output.txt', 'Hello, Node.js!', 'utf8');
        await fs.appendFile('output.txt', '\nMore', 'utf8');
        await fs.copyFile('output.txt', 'backup.txt');
        await fs.rename('backup.txt', 'backup-new.txt');
        await fs.unlink('backup-new.txt');

        const stats = await fs.stat('output.txt');
        console.log(stats.size, stats.mtime);

        await fs.mkdir('new-folder', { recursive: true });
        const files = await fs.readdir('.');
        await fs.rmdir('new-folder');
    } catch (err) {
        console.error('Error:', err.message);
    }
}

fileOperations();

path

const path = require('path');

const filePath = path.join(__dirname, 'data', 'users.json');
const absolutePath = path.resolve('data', 'users.json');

console.log(path.basename('/foo/bar/file.txt'));        // file.txt
console.log(path.basename('/foo/bar/file.txt', '.txt')); // file
console.log(path.dirname('/foo/bar/file.txt'));          // /foo/bar
console.log(path.extname('file.txt'));                   // .txt
console.log(path.parse('/foo/bar/file.txt'));
console.log(path.normalize('/foo/bar/../baz'));
console.log(path.relative('/foo/bar', '/foo/baz/file.txt'));

os

const os = require('os');

console.log('platform:', os.platform());
console.log('CPUs:', os.cpus().length);
console.log('total mem GB:', (os.totalmem() / 1024 / 1024 / 1024).toFixed(2));
console.log('free mem GB:', (os.freemem() / 1024 / 1024 / 1024).toFixed(2));
console.log('homedir:', os.homedir());
console.log('tmpdir:', os.tmpdir());
console.log('networkInterfaces:', os.networkInterfaces());

crypto

const crypto = require('crypto');

function hashPassword(password) {
    return crypto.createHash('sha256').update(password).digest('hex');
}

const randomString = crypto.randomBytes(16).toString('hex');
const { randomUUID } = require('crypto');
console.log(randomUUID());

5. Module caching

// counter.js
let count = 0;
exports.increment = () => {
    count++;
    console.log('Count:', count);
};
exports.getCount = () => count;
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();
counter2.increment();
console.log(counter1 === counter2); // true

Clear cache (testing):

delete require.cache[require.resolve('./counter')];
const counter3 = require('./counter');

6. Circular dependencies

Problem: a.js requires b.js, b.js requires a.js — partially initialized exports may be undefined.

Fix 1 — shared module:

// shared.js
exports.nameA = 'Module A';
exports.nameB = 'Module B';

Fix 2 — lazy require inside a function:

exports.greet = () => {
    const a = require('./a');
    console.log(`B sees: ${a.name}`);
};

Fix 3 — dependency injection (pass dependencies after both load).


7. Module resolution

require('./math');           // relative
require('../utils/math');
require('express');          // node_modules
require('fs');               // built-in

Resolution order for ./math: math.js, math.json, math.node, math/index.js, or math/package.json main.

require.resolve('express') shows the resolved path.


8. Practical examples

Common patterns include config (dotenv, structured module.exports), logger (singleton writing to disk), database wrapper singleton, and ApiClient with https.request. Translate user-facing strings for your locale in your own codebase.


9. package.json advanced

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": { "express": "^4.18.2" },
  "devDependencies": { "nodemon": "^3.0.1" }
}

Semantic versioning: ^4.18.2 allows minor/patch updates below 5.0.0; ~4.18.2 allows patch only; pin exact versions when you need reproducible builds.

Lifecycle npm scripts: prebuild, build, postbuild run in order when you npm run build.


10. Module patterns

Singleton with guard:

class Database {
    constructor() {
        if (Database.instance) return Database.instance;
        this.connection = null;
        Database.instance = this;
    }
}
module.exports = new Database();

IIFE module with private state:

const counter = (() => {
    let count = 0;
    return {
        increment() { return ++count; },
        getCount() { return count; }
    };
})();
module.exports = counter;

11. Common problems

Cannot find module

Check path typos, install the package, or add "type": "module" / .mjs for ESM.

import outside a module

Add "type": "module" to package.json or rename to .mjs.

No __dirname in ESM

import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

12. Project structure tip

src/
├── config/
├── models/
├── controllers/
├── routes/
├── middlewares/
├── utils/
└── index.js

Use models/index.js to re-export models for cleaner imports.


Summary

  1. CommonJSrequire / module.exports
  2. ES modulesimport / export
  3. Caching — one evaluation per resolved path
  4. Circular deps — refactor, lazy load, or inject
  5. package.json — metadata, semver, scripts

Next steps

Resources