OOP in Node.js
Companion to my Node.js Design Patterns series.
The integration codebase had a BaseClient class. Under it sat GrabClient, ShopeeClient, TikTokClient, and, eventually, ShopeeV2Client extends ShopeeClient because one endpoint changed and nobody wanted to touch the parent. A bug fix in BaseClient.request() broke two of the four children in different ways, and the review took a day because understanding any one client meant reading three files upward. Nothing in that tree was wrong by the textbook. It was just the textbook applied to a problem that wanted something flatter.
I first wrote this post in 2024 as a tour of classes, inheritance, encapsulation and polymorphism. This version keeps the tour short and spends the space on what JavaScript's object model actually rewards, using syntax that runs on Node 24 without a build step.
What a class buys you in 2026
A class is worth writing when a thing has state that must stay consistent across several operations. A partner client with a token and a rate limiter is a good class. A "user" that is three strings from a database row is a plain object.
export class PartnerClient {
// Private fields: unreachable from outside, even by name. No underscore convention needed.
#token = null;
#calls = 0;
static #instances = 0;
// Static block: runs once when the class is defined. Handy for reading config.
static {
PartnerClient.defaultTimeoutMs = Number(process.env.PARTNER_TIMEOUT_MS ?? 5000);
}
constructor(name, { fetchImpl = globalThis.fetch } = {}) {
this.name = name;
this.fetch = fetchImpl;
PartnerClient.#instances++;
}
static get instances() {
return PartnerClient.#instances;
}
get calls() {
return this.#calls;
}
async #ensureToken() {
if (!this.#token) this.#token = `tok-${this.name}`;
return this.#token;
}
async getOrders(since) {
const token = await this.#ensureToken();
this.#calls++;
return { partner: this.name, since, auth: token.slice(0, 4), timeout: PartnerClient.defaultTimeoutMs };
}
}import { PartnerClient } from "./client.mjs";
const grab = new PartnerClient("grab");
console.log(await grab.getOrders("2026-09-06"));
console.log("calls:", grab.calls, "instances:", PartnerClient.instances);
console.log("token reachable from outside?", "#token" in grab || grab.token !== undefined);Three things here did not exist when most Node OOP tutorials were written. #private fields and methods are real privacy, not a naming convention; grab.token is simply undefined and grab.#token outside the class is a syntax error. static blocks give you one-time setup without a separate init function. And the constructor takes its dependency (fetchImpl) as a parameter, which is the whole of dependency injection in JavaScript and the reason this class can be tested without a network.
Composition over inheritance, with code
The BaseClient tree from the opening wanted to share three things: token handling, retries, and a request helper. Inheritance shares them by making every client be a BaseClient. Composition shares them by making every client have them:
class TokenStore {
#token = null;
async get(name) {
return (this.#token ??= `tok-${name}`);
}
}
class Retrier {
constructor(attempts = 3) {
this.attempts = attempts;
}
async run(fn) {
let last;
for (let i = 0; i < this.attempts; i++) {
try {
return await fn();
} catch (err) {
last = err;
}
}
throw last;
}
}
export function createPartnerClient(name, { tokens = new TokenStore(), retrier = new Retrier() } = {}) {
let failuresLeft = 1;
return {
name,
async getOrders(since) {
const token = await tokens.get(name);
return retrier.run(async () => {
if (failuresLeft-- > 0) throw new Error("503 from partner");
return { partner: name, since, auth: token.slice(0, 4) };
});
},
};
}import { test } from "node:test";
import assert from "node:assert/strict";
import { createPartnerClient } from "./compose.mjs";
test("retries once and succeeds, using injected collaborators", async () => {
const calls = [];
const retrier = { run: async (fn) => { calls.push("run"); try { return await fn(); } catch { return fn(); } } };
const client = createPartnerClient("shopee", { retrier });
const result = await client.getOrders("today");
assert.equal(result.partner, "shopee");
assert.deepEqual(calls, ["run"]);
});Now a partner with a different retry policy gets a different Retrier, not a subclass. The ShopeeV2 endpoint change becomes a new function in the Shopee module, and TokenStore never hears about it. Each piece is a class only where it holds state; the client itself is a plain object returned by a function, which is the shape the Factory and Module posts arrive at from the other direction.
Polymorphism without a hierarchy
JavaScript checks shape, not ancestry. Any object with getOrders(since) and ack(id) is a partner client as far as the sync job is concerned. That is polymorphism, and it needs no extends and no implements. When you do want the contract written down, write it in a .ts file, which Node now runs directly by stripping the types:
export interface PartnerClient {
name: string;
getOrders(since: string): Promise<{ partner: string; since: string }>;
}
export async function syncAll(clients: PartnerClient[], since: string) {
return Promise.all(clients.map((c) => c.getOrders(since)));
}Run that with node contract.ts on Node 24 and it just works, as long as you stay inside the erasable subset: no enum, no parameter properties, no decorators.
Cleanup with using
The one genuinely new OOP-flavoured feature is explicit resource management. An object with a Symbol.dispose method can be declared with using, and it is disposed when the block exits, however the block exits:
class Connection {
constructor(name) {
this.name = name;
console.log("open", name);
}
[Symbol.dispose]() {
console.log("close", this.name);
}
}
function work() {
using conn = new Connection("db");
console.log("working with", conn.name);
throw new Error("something failed mid-way");
}
try {
work();
} catch (err) {
console.log("caught:", err.message);
}The output is open, working, close, caught, in that order. Before using, that guarantee took a try/finally in every caller, and the one caller who forgot it is the one who leaked the connection.
Where class hierarchies go wrong
Depth. Every level of extends is a file the reader has to hold in their head. Two levels is a smell; three is the BaseClient story above. If two subclasses share code, extract a collaborator, not a grandparent.
Losing this. Pass a method as a callback (emitter.on("data", client.handle)) and this is gone inside it. Arrow-function class fields (handle = (data) => { ... }) bind it permanently, at the cost of one closure per instance. Bind at the call site when instances are many.
Classes for data. A class OrderDTO with a constructor that copies twelve fields is a plain object with extra ceremony, and it breaks structuredClone, JSON round-trips and Object.groupBy in small annoying ways. Use objects for data and classes for behaviour with state.
Mutable shared state on the prototype. A static cache = new Map() shared by every subclass across every test is the singleton problem wearing a class; see the Singleton post for the test seam.
When not to use a class at all
If the thing has no state, it is a function. If it has state but only one method, it is a closure. If it has state and several methods that must agree, it is a class, and it should be as flat as the one at the top of this post. The rewrite of that BaseClient tree ended with zero extends, four small classes, and one function per partner. Review time went from a day to twenty minutes, and nobody has needed to read three files upward since.

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.