Common Design Patterns
Design patterns are reusable solutions to recurring design problems — named, documented blueprints that give teams a shared vocabulary and prevent reinventing wheels. The Gang of Four patterns (Creational, Structural, Behavioral) and the later patterns for async/distributed systems all show up in Node codebases. This page covers the patterns you'll encounter most: Repository, Factory, Singleton, Middleware, Observer, and Strategy — what problem each solves, what it looks like in Node, and when to use it.
Repository — abstract data access
TS
// Interface defines the contract — service depends on THIS, not a concrete DB:
interface UsersRepository {
findById(id: string): Promise<User | null>
insert(data: NewUser): Promise<User>
deleteById(id: string): Promise<void>
}
// Postgres implementation:
class PostgresUsersRepository implements UsersRepository {
async findById(id: string) { return db.query('SELECT ...', [id]).then(r => r.rows[0]) }
// ...
}
// In-memory fake for tests — no DB needed:
class InMemoryUsersRepository implements UsersRepository {
private store = new Map<string, User>()
async findById(id: string) { return this.store.get(id) ?? null }
// ...
}Repository wraps data access behind an interface — swap Postgres for a fake in tests without changing a line of service code
The **Repository pattern** puts all data access for a domain entity behind an interface. Services depend on the interface (`UsersRepository`), not the concrete database implementation. The payoffs: tests use a fast, deterministic **in-memory repository** without a database; swapping from raw SQL to an ORM only changes the repository implementation; and the service has zero SQL knowledge — it can't accidentally embed a query where business logic belongs. This is the most impactful single pattern for making Node business logic **independently testable**. Combine with [dependency injection](/nodejs/dependency-injection) to supply the right implementation per environment.
Factory — centralize object creation
TS
// Factory function creates a configured service — the caller doesn't know the details:
function createEmailService(config: Config): EmailService {
if (config.EMAIL_PROVIDER === 'sendgrid') return new SendGridEmailService(config.SENDGRID_KEY)
if (config.NODE_ENV === 'test') return new NullEmailService() // silent in tests
return new SmtpEmailService(config.SMTP_HOST, config.SMTP_PORT)
}Factories centralize construction logic — the caller gets the right implementation without knowing how it's built or which variant is used
The **Factory pattern** encapsulates the logic for creating objects, letting the caller ask for "an email service" without knowing whether they're getting SendGrid, SMTP, or a no-op test double. The factory reads config or environment to decide, keeping that branching in one place rather than scattered at every instantiation. Factory functions (as seen in [dependency injection](/nodejs/dependency-injection)) are the idiomatic Node form. Combined with a Repository or Strategy interface, factories wire up the right implementation at startup without any caller caring about the concrete type — the caller just uses the interface.
Singleton — one shared instance
TS
// Module-level singleton — Node's module cache ensures this runs once:
// db.ts
import pg from 'pg'
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL })
// Every import of './db' gets the SAME Pool instance — the module cache is the singleton.
// For explicit singleton with lazy init:
let pool: pg.Pool | null = null
export function getDb(): pg.Pool {
if (!pool) pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
return pool
}Node's module cache makes every module export a singleton — use it for shared connection pools, loggers, and config objects
In Node, the **Singleton pattern** is almost free: because the module system **caches modules** after first load, a module-level `export const db = new Pool(...)` is a singleton — every file that imports `db` gets the same `Pool` instance, guaranteeing you don't open a new connection pool per request. This is the idiomatic way to share a database pool, logger, or config object: create it once at module level, export it, and rely on the cache. Be aware of the testing implication: a module-level singleton is global state, and resetting it between tests requires explicit teardown or dynamic requires. For testable code, prefer passing the singleton in via [dependency injection](/nodejs/dependency-injection) rather than importing it directly everywhere.
Middleware — chainable handlers
TS
// Express's middleware IS the middleware pattern — each handler calls next():
const authenticate: RequestHandler = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Unauthorized' })
req.user = verifyJwt(token)
next() // pass control to the next handler in the chain
}
// Composing: apply authentication only to the routes that need it:
router.use('/admin', authenticate, requireRole('admin'))
router.get('/admin/users', getUsers)Express middleware is the Middleware pattern — a chain of handlers where each does its part and calls `next()` to continue or short-circuits to respond
The **Middleware pattern** is a chain of handlers where each step can process the request, modify it, and either call the *next* handler or short-circuit with a response. Express is built on this pattern — `app.use()` adds to the chain, and every handler follows the `(req, res, next)` contract. It's powerful for **cross-cutting concerns** (authentication, logging, compression, rate-limiting) because you compose behavior by stacking handlers, and each handler is independently testable and reusable. The pattern extends beyond Express: Fastify hooks, Koa's `async` middleware, and even Redux reducers all use the same idea of a composable processing chain.
Observer / EventEmitter — loose coupling
TS
import { EventEmitter } from 'node:events'
// A typed domain event bus:
class DomainEvents extends EventEmitter {}
export const events = new DomainEvents()
// Service emits — it doesn't know who's listening:
events.emit('user.created', { id: user.id, email: user.email })
// Multiple subscribers react independently — email, analytics, audit log:
events.on('user.created', async (payload) => sendWelcomeEmail(payload.email))
events.on('user.created', async (payload) => analytics.track('signup', payload))Observer/EventEmitter decouples the emitter from its subscribers — side effects attach without the core service knowing about them
The **Observer pattern** (Node's `EventEmitter`) decouples the code that *produces* an event from the code that *reacts* to it. A service creates a user and emits `user.created` — it has zero knowledge of emails, analytics, or audit logs. Those concerns register their own listeners. New side effects attach without touching the core service; listeners can be removed, mocked in tests, or run conditionally. This is the right tool for **domain events** (things that happened in your domain that others may care about) and for keeping the core service [single-responsibility](/nodejs/separation-of-concerns). Caveat: listeners throw synchronously, and an unhandled rejection in a listener can crash the process — handle errors in each listener and consider whether a proper [message queue](/nodejs/message-queues) is more appropriate for critical side effects.
Strategy — swap algorithms at runtime
TS
// Strategy: a function or object that encapsulates a replaceable algorithm.
interface StorageStrategy { upload(key: string, data: Buffer): Promise<string> }
class S3Storage implements StorageStrategy { /* ... */ }
class LocalStorage implements StorageStrategy { /* ... */ }
class FileService {
constructor(private storage: StorageStrategy) {}
async save(filename: string, data: Buffer) {
const url = await this.storage.upload(filename, data)
return { url }
}
}
const fileService = new FileService(
process.env.NODE_ENV === 'test' ? new LocalStorage() : new S3Storage()
)Strategy encapsulates a swappable algorithm behind an interface — inject the right variant per environment, no `if` statements in the core logic
The **Strategy pattern** extracts a family of algorithms behind an interface so they're interchangeable. The consuming class uses the strategy without knowing its implementation — it calls `storage.upload()` regardless of whether it's S3, local disk, GCS, or a test stub. This is DI applied specifically to algorithms and behaviors, and it's the pattern behind most "pluggable" Node systems: logging transports, caching backends, authentication providers, payment processors. The key benefit: the `FileService` has zero branching for environments — the environment-specific `if` lives only in the factory/wiring code, and the strategy is swapped out there. Add a new storage backend by implementing the interface, not by editing the `FileService`.
Next
Conventions that keep a codebase consistent across the whole team: [Code Style & Conventions](/nodejs/code-style).