EngineeringMarch 28, 2024 · 4 min readUpdated September 6, 2026

Module Pattern in Node.js

Part of my Node.js Design Patterns series.

An integration outage I still think about started with one line: client.token = null. A colleague had reached into an API client from a cron job to force a re-login. It worked for months. Then the client grew a refresh timer that also read that field, and the two fought each other every night at 2am. The bug was not the cron job. The bug was that token was reachable at all.

The module pattern is the discipline of deciding what is reachable. In the old days that meant an IIFE with a closure. In Node 24 it means an ES module with a deliberately small export list, and the discipline is the same: every exported name is a promise you will keep.

The pattern in modern Node

partner-client.mjsJavaScript
// Private state: not exported, so nothing outside this file can touch it.
let token = null;
let expiresAt = 0;
 
async function login() {
  // pretend this calls the partner's auth endpoint
  token = `tok-${Date.now()}`;
  expiresAt = Date.now() + 60_000;
}
 
async function ensureToken() {
  if (!token || Date.now() >= expiresAt) await login();
  return token;
}
 
// Public surface: two functions. That is the whole contract.
export async function getOrders(since) {
  const t = await ensureToken();
  return { since, auth: t.slice(0, 4) + "…" };
}
 
export async function ackOrder(id) {
  await ensureToken();
  return { id, acked: true };
}
app.mjsJavaScript
import { getOrders, ackOrder } from "./partner-client.mjs";
 
console.log(await getOrders("2026-09-06"));
console.log(await ackOrder("A1"));

Nothing outside partner-client.mjs can see token. If the cron job needs a forced re-login, it has to ask for one, which means you have to write export function forceRelogin(), which means you have to think about it. That pause is the pattern working.

Two Node details worth knowing. First, ES modules are singletons by URL: every import of the same file gets the same module instance and the same token, which is exactly what you want for a client and exactly what bites you in tests. Second, exports are live bindings, not copies. If a module exports let count and later increments it, importers see the new value. Useful, occasionally surprising.

The test seam

The honest objection to private state is "how do I test it". The answer is not to export the internals. It is to accept the thing you want to fake as a parameter, with a default:

partner-client-testable.mjsJavaScript
let token = null;
 
export function createClient({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
  async function login() {
    token = `tok-${now()}`;
  }
  return {
    async getOrders(since) {
      if (!token) await login();
      // fetchImpl would be used here in real code
      return { since, hasToken: Boolean(token), usingFetch: typeof fetchImpl === "function" };
    },
  };
}
partner-client.test.mjsJavaScript
import { test } from "node:test";
import assert from "node:assert/strict";
import { createClient } from "./partner-client-testable.mjs";
 
test("logs in before the first call", async () => {
  const client = createClient({ now: () => 1000 });
  const result = await client.getOrders("today");
  assert.equal(result.hasToken, true);
});

That test runs with node --test, no library installed. The factory function createClient also solves the singleton-in-tests problem: each test builds its own instance, and production builds one at startup.

Where it goes wrong

Exporting the kitchen. A module with fourteen exports is not modular, it is a folder with extra steps. When I review one, I ask which exports have a second caller. Usually two or three do. The rest were exported "for testing" and are now load-bearing for someone.

The CommonJS seam. Most of the ecosystem has moved to ESM, but you will still meet a .cjs dependency. Importing CommonJS from ESM works (import pkg from "old-lib", then pick properties off pkg). Requiring ESM from CommonJS is where the pain is; since Node 22 require(esm) works for modules without top-level await, and on Node 24 it is on by default. If you maintain a shared library, ship ESM and keep a .cjs entry only if a real consumer needs it.

Circular imports. Module A imports B, B imports A, and one of them reads an export before it is initialised and gets undefined. Node will not stop you. The fix is structural: pull the shared piece into a third module both can import.

When not to use it

If a file has one function and no state, it does not need private anything. Export the function and move on. The pattern earns its keep the moment a module holds something that must stay consistent: a token, a connection, a cache. Then the small export list is not ceremony, it is the thing that would have saved us that 2am fight over token.

Next in the series: Factory Pattern, for when the thing you create depends on which partner you are talking to.

Nguyễn Hải Nam

Nguyễn Hải Nam

Project Management Lead. 16+ years from code to delivery. PMP®. Writing here about project management and engineering.

About me