Node.js Modules and CommonJS Explained — require, exports, and Real-World Patterns
Every non-trivial Node.js application is really a collection of smaller, focused files that talk to each other. The moment your server.js file grows past a few hundred lines, you feel the pain: variables clash, logic is hard to trace, and testing becomes a nightmare. Modules are the answer, and they've been baked into Node.js since day one through a system called CommonJS (CJS). Understanding them deeply isn't optional — it's the foundation every senior Node.js developer stands on.
Before modules, JavaScript had a serious scope problem. In a browser, every script tag shared the same global window object, so a variable named 'user' in one file could silently overwrite a 'user' in another. Node.js needed a way to let files share code without polluting a shared global scope. CommonJS solved this by wrapping every file in its own private function scope, giving each file its own isolated universe while providing a clean contract — require() and module.exports — for controlled sharing.
By the end of this article you'll be able to split a real Node.js project into purposeful modules, understand exactly what require() is doing under the hood (including the caching trick that trips up almost everyone), avoid the most common export mistakes, and hold your own in any interview conversation about CommonJS versus ES Modules.
How Node.js Wraps Every File — The Module Wrapper You Never See
Before a single line of your file runs, Node.js silently wraps the entire thing in a function. This is called the Module Wrapper, and it's the core mechanic that makes CommonJS work. It looks like this:
(function(exports, require, module, __filename, __dirname) { / your code / });
This wrapper does three critical things. First, it gives your file a private scope — variables you declare with let or const never leak out. Second, it injects five special variables: exports, require, module, __filename, and __dirname. These aren't globals — they're function arguments scoped to your file. Third, it means every file is a function call at runtime, not a raw script concatenation.
This is WHY you can use require() anywhere in your file without importing it — it's already an argument. It's also why __dirname reliably gives you the directory of the current file rather than wherever you launched the Node process from. Once you picture this invisible wrapper, the whole module system stops feeling like magic and starts feeling like clean engineering.
// Run this file with: node showModuleWrapper.js // It reveals the five injected variables every Node.js file receives for free. // __filename: the absolute path of this specific file console.log('Current file:', __filename); // __dirname: the directory that contains this file // Use this when building file paths — never use process.cwd() for asset paths console.log('Current directory:', __dirname); // module: the object that represents THIS file in the module system console.log('Module id:', module.id); console.log('Has this module been loaded before?', module.loaded); // exports: a shorthand reference to module.exports (more on this soon) console.log('Exports object starts as:', exports); // require: the function we use to pull in other modules console.log('Type of require:', typeof require);
Current directory: /projects/demo
Module id: /projects/demo/showModuleWrapper.js
Has this module been loaded before? false
Exports object starts as: {}
Type of require: function
module.exports vs exports — Why They're Not the Same Thing
This is where most beginners hit a wall. When Node.js initialises your module, it sets things up like this: module.exports = {} and exports = module.exports. Both variables point to the same empty object in memory. That's fine while you're just adding properties — but the moment you reassign exports directly, you break the link.
Think of it like two sticky notes on the same box. As long as you're putting items inside the box, both sticky notes lead to the right place. But if you rip off one sticky note and stick it on a completely different box, the original box is unchanged — and require() always returns the original box (module.exports), never the sticky note.
The practical rule: if you want to export a single value — a class, a function, a plain object — always assign to module.exports. Use the exports shorthand only when you're attaching multiple named properties and have no intention of replacing the entire object. Mixing both in one file is a recipe for a silent, hours-long debugging session.
// FILE: userService.js // This module encapsulates all user-related business logic. // We export a single object with named methods — a clean, common pattern. const MAX_USERNAME_LENGTH = 30; // private constant — NOT exported, not accessible outside // Private helper — callers of this module never know this function exists function sanitiseInput(rawString) { return rawString.trim().toLowerCase(); } // Public API — everything on module.exports is accessible to callers module.exports = { // Creates a user object after validating the inputs createUser(username, email) { const cleanName = sanitiseInput(username); if (cleanName.length > MAX_USERNAME_LENGTH) { throw new Error(`Username exceeds ${MAX_USERNAME_LENGTH} characters`); } // Returns a plain data object — no class overhead needed here return { id: Date.now(), // simple id for demo purposes username: cleanName, email: sanitiseInput(email), createdAt: new Date().toISOString(), }; }, // Checks whether a username string meets basic rules isValidUsername(username) { const clean = sanitiseInput(username); // Only letters, numbers, and underscores allowed return /^[a-z0-9_]+$/.test(clean) && clean.length >= 3; }, }; // --- FILE: app.js --- // const userService = require('./userService'); // // const newUser = userService.createUser(' Alice_Dev ', 'Alice@Example.com'); // console.log(newUser); // console.log('Valid username?', userService.isValidUsername('al')); // console.log('Valid username?', userService.isValidUsername('alice_42'));
id: 1718200000000,
username: 'alice_dev',
email: 'alice@example.com',
createdAt: '2024-06-12T10:00:00.000Z'
}
Valid username? false
Valid username? true
How require() Works — Caching, Resolution Order, and Why It Matters
require() looks simple but does a surprising amount of work. When you call require('./logger'), Node.js follows a four-step process: resolve the path to an absolute filename, check whether that module is already in the cache, if not load and execute the file, then store the result in the cache and return module.exports.
That cache step is the one that bites people. The second time require('./logger') is called anywhere in your app — even from a completely different file — Node.js skips execution entirely and returns the same cached module.exports object. This means modules are effectively singletons by default. Change a property on a required module's export object in file A, and file B sees that change too, because they're sharing the same object in memory.
This singleton behaviour is actually useful for things like database connections or configuration objects — you require() once, the connection is established and cached, and every subsequent require() gets the live, connected instance. But it also means stateful modules can produce surprising cross-file side-effects if you're not deliberate about your design.
For module resolution order: Node.js first checks for a core module (like 'fs' or 'path'), then looks for a file or folder match starting with './' or '../', and if neither prefix is present it digs into node_modules. Knowing this order explains why require('fs') always wins over a local file named fs.js.
// FILE: databaseConnection.js // Demonstrates the singleton caching pattern — perfect for DB connections. // The connection is created ONCE, then every require() returns the same instance. const EventEmitter = require('events'); // Node.js core module — no path needed class DatabaseConnection extends EventEmitter { constructor(connectionString) { super(); this.connectionString = connectionString; this.isConnected = false; this.queryCount = 0; console.log('[DB] New DatabaseConnection instance created'); // runs ONCE due to caching } connect() { // Simulates an async connection — in real life this would be pg.connect() or similar this.isConnected = true; this.emit('connected'); console.log('[DB] Connected to:', this.connectionString); } query(sql) { if (!this.isConnected) throw new Error('Call connect() before querying'); this.queryCount++; console.log(`[DB] Query #${this.queryCount}: ${sql}`); // Would return a Promise in a real driver return { rows: [], rowCount: 0 }; } } // Module exports a SINGLE shared instance — not the class itself. // Every file that require()s this module gets the SAME object from cache. const sharedConnection = new DatabaseConnection('postgres://localhost:5432/myapp'); module.exports = sharedConnection; // FILE: userRepository.js // const db = require('./databaseConnection'); // gets the cached instance // db.query('SELECT * FROM users'); // FILE: productRepository.js // const db = require('./databaseConnection'); // same instance — '[DB] New...' does NOT print again // db.query('SELECT * FROM products'); // console.log('Total queries so far:', db.queryCount); // reflects queries from BOTH files
[DB] Connected to: postgres://localhost:5432/myapp
[DB] Query #1: SELECT * FROM users
[DB] Query #2: SELECT * FROM products
Total queries so far: 2
Structuring a Real Project with CommonJS — Patterns That Scale
Knowing the mechanics is one thing — knowing how to lay out a real project is what separates junior devs from mid-level engineers. The most battle-tested CommonJS pattern is the index.js barrel file. Each feature folder gets an index.js that re-exports only the public API of that folder. Callers require the folder path, Node.js finds index.js automatically, and internal files stay private.
This gives you a stable public contract. You can refactor the internals of a folder — splitting a 400-line file into four 100-line files — without changing a single require() call elsewhere in the codebase. That's the real value: not just code splitting, but encapsulation with a clear boundary.
Another pattern worth knowing is the config module. Instead of sprinkling process.env.DATABASE_URL throughout your codebase, you create a single config.js that reads all environment variables, validates them, and exports a clean object. Every other module requires config and gets structured data — if an env variable is missing, it fails loudly at startup rather than silently at runtime hours later. This pattern alone has saved countless production incidents.
// PROJECT STRUCTURE: // /src // config.js // /services // emailService.js // smsService.js // index.js <-- barrel file, controls public API of the folder // app.js // ───────────────────────────────────────── // FILE: src/config.js // Single source of truth for all environment configuration. // Fails fast at startup if required variables are missing. // ───────────────────────────────────────── function requireEnv(variableName) { const value = process.env[variableName]; if (!value) { // Crashing at startup is far better than a cryptic error mid-request throw new Error(`Missing required environment variable: ${variableName}`); } return value; } module.exports = { port: parseInt(process.env.PORT || '3000', 10), database: { url: process.env.DATABASE_URL || 'postgres://localhost:5432/dev', poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10), }, email: { apiKey: process.env.NODE_ENV === 'test' ? 'test-key' : requireEnv('EMAIL_API_KEY'), fromAddress: process.env.EMAIL_FROM || 'no-reply@myapp.com', }, }; // ───────────────────────────────────────── // FILE: src/services/emailService.js // Handles all outbound email logic. Private to this folder. // ───────────────────────────────────────── const config = require('../config'); // always use config, never process.env directly function sendWelcomeEmail(recipientAddress, username) { console.log(`[Email] Sending welcome to ${recipientAddress} via key: ${config.email.apiKey}`); // In production: return sgMail.send({ to: recipientAddress, ... }) return Promise.resolve({ accepted: [recipientAddress] }); } module.exports = { sendWelcomeEmail }; // ───────────────────────────────────────── // FILE: src/services/smsService.js // Handles all outbound SMS logic. Private to this folder. // ───────────────────────────────────────── function sendVerificationSms(phoneNumber, code) { console.log(`[SMS] Sending code ${code} to ${phoneNumber}`); return Promise.resolve({ sid: 'SM_demo_123' }); } module.exports = { sendVerificationSms }; // ───────────────────────────────────────── // FILE: src/services/index.js — the barrel file // This is the ONLY public face of the services folder. // Callers never need to know whether email lives in one file or ten. // ───────────────────────────────────────── const { sendWelcomeEmail } = require('./emailService'); const { sendVerificationSms } = require('./smsService'); // Explicitly choose what to expose — everything else stays private module.exports = { sendWelcomeEmail, sendVerificationSms, }; // ───────────────────────────────────────── // FILE: src/app.js // Clean import — no knowledge of internal folder structure needed // ───────────────────────────────────────── process.env.NODE_ENV = 'test'; // so config doesn't demand EMAIL_API_KEY const config = require('./config'); const services = require('./services'); // Node finds services/index.js automatically console.log('Server starting on port:', config.port); async function onUserRegistered(email, phone, username) { await services.sendWelcomeEmail(email, username); await services.sendVerificationSms(phone, '847291'); console.log('Onboarding notifications dispatched for:', username); } onUserRegistered('alice@example.com', '+447700900000', 'alice_dev');
[Email] Sending welcome to alice@example.com via key: test-key
[SMS] Sending code 847291 to +447700900000
Onboarding notifications dispatched for: alice_dev
| Feature / Aspect | CommonJS (require) | ES Modules (import) |
|---|---|---|
| Syntax | const x = require('module') | import x from 'module' |
| Loading | Synchronous — file is read and executed immediately | Asynchronous — parsed statically before execution |
| Exports | module.exports — set at runtime, can be any value | export / export default — declared statically |
| Tree-shaking | Not possible — entire module is always loaded | Supported — bundlers can remove unused exports |
| Conditional imports | Yes — require() inside if/try blocks works fine | No — import declarations must be top-level (use import() async instead) |
| Default in Node.js | Yes — .js files use CJS by default | Requires .mjs extension or "type": "module" in package.json |
| Circular dependency handling | Returns partially-built exports object — silent bugs | Handles live bindings — still tricky but more predictable |
| Best for | Legacy codebases, CLI tools, server-only Node.js apps | Modern full-stack apps, shared browser/Node.js code, libraries |
🎯 Key Takeaways
- Every Node.js file is silently wrapped in a function that injects exports, require, module, __filename, and __dirname — they're not globals, they're function arguments scoped to your file.
- module.exports is what require() actually returns; exports is just a shorthand reference to the same object — the moment you reassign exports = {} you lose the link and your caller gets an empty object with no error.
- require() caches its result after the first execution, making modules behave like singletons — exploit this for shared resources like DB connections, but don't rely on it for stateful objects that need to be fresh per caller.
- The barrel file pattern (a folder's index.js re-exporting only the public API) is how professional Node.js codebases stay maintainable at scale — it gives you a stable contract between folders so internals can be refactored freely.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Reassigning exports instead of module.exports — symptoms: require() returns an empty {} and every property access is undefined, no error thrown — Fix: always use module.exports = { ... } when you want to export an object or function wholesale; only use exports.myThing = ... when adding individual named properties to the default object.
- ✕Mistake 2: Assuming require() re-executes the module every time — symptom: you modify process.env inside a module expecting the next require() call to pick up a different value, but it never does — Fix: understand that the first require() execution result is permanently cached in require.cache; if you need fresh state, export a factory function (module.exports = function createThing() { ... }) rather than an instance, so callers invoke the factory to get a new object.
- ✕Mistake 3: Using relative paths that depend on the working directory — symptom: require('./config') works when running node src/app.js from the project root but throws MODULE_NOT_FOUND when the same file is called from a script in a different directory — Fix: always build paths with path.join(__dirname, 'config') so the path is resolved relative to the file's own location, regardless of where Node.js was launched from.
Interview Questions on This Topic
- QWhat is the difference between module.exports and exports in CommonJS, and can you give an example of code where using exports instead of module.exports would silently break your application?
- QNode.js modules are often described as singletons. What does that mean practically, and can you think of a case where that singleton behaviour is useful versus a case where it could cause a subtle bug?
- QIf you call require('./myModule') in two different files within the same running Node.js process, how many times does the code inside myModule.js actually execute, and what mechanism is responsible for that behaviour?
Frequently Asked Questions
What is the difference between CommonJS and ES Modules in Node.js?
CommonJS uses require() and module.exports and loads modules synchronously — it's the default system in Node.js for .js files. ES Modules use import/export syntax, load asynchronously, and support static analysis for tree-shaking. To use ES Modules in Node.js, either name your files .mjs or add "type": "module" to your package.json. For new projects targeting Node.js 18+, ES Modules are the modern choice; for existing codebases and CLI tools, CommonJS remains perfectly solid.
Why does require() return an empty object even though I exported things from my module?
Almost certainly because you wrote exports = { myFunction } instead of module.exports = { myFunction }. Reassigning exports creates a new local variable that has no connection to module.exports, which is what require() returns. Switch to module.exports = { myFunction } and the problem disappears. A quick debugging trick: add console.log(module.exports) at the bottom of your module file — if it logs {} when you expected your exports, this is the culprit.
Can I use require() conditionally or inside a function in Node.js?
Yes — because require() is just a function call in CommonJS, you can put it anywhere: inside an if block, inside a try/catch, or inside another function. This is one of CommonJS's practical advantages over static ES Module imports, which must live at the top level. Conditional requires are useful for loading platform-specific modules or optional peer dependencies, though for performance-sensitive paths it's worth requiring at the top of the file so the module is cached before any request arrives.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.