Episode 9 — System Design / 9.3 — Creational Design Patterns
9.3.a Singleton Pattern
The Problem It Solves
Some resources should exist exactly once in your application:
- A database connection pool (opening 100 pools wastes resources)
- A configuration manager (conflicting configs cause bugs)
- A logger (you want one centralized log stream)
- A cache layer (multiple caches = stale data)
Without the Singleton pattern, every module that calls new DBConnection() creates a separate instance. You lose shared state, waste memory, and risk conflicts.
BEFORE (No Singleton):
Module A --> new DBConnection() --> Connection #1
Module B --> new DBConnection() --> Connection #2
Module C --> new DBConnection() --> Connection #3
// 3 pools, 3x the resources, no shared state
AFTER (Singleton):
Module A --> DBConnection.getInstance() --> Connection #1
Module B --> DBConnection.getInstance() --> Connection #1 (same!)
Module C --> DBConnection.getInstance() --> Connection #1 (same!)
// 1 pool, shared across the entire app
UML Diagram
+-----------------------------------+
| Singleton |
+-----------------------------------+
| - instance: Singleton [static] |
| - data: any |
+-----------------------------------+
| - constructor() [private] |
| + getInstance(): Singleton [static]|
| + someBusinessLogic(): void |
+-----------------------------------+
Client A ----+
|
Client B ----+--> getInstance() --> [single instance]
|
Client C ----+
Implementation 1: Basic Singleton (Class-Based)
class Singleton {
constructor() {
// If an instance already exists, return it
if (Singleton._instance) {
return Singleton._instance;
}
// First-time initialization
this.createdAt = new Date();
this.data = {};
// Store the instance
Singleton._instance = this;
}
static getInstance() {
if (!Singleton._instance) {
Singleton._instance = new Singleton();
}
return Singleton._instance;
}
setData(key, value) {
this.data[key] = value;
}
getData(key) {
return this.data[key];
}
}
// --- Usage ---
const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
s1.setData('env', 'production');
console.log(s2.getData('env')); // "production" -- same instance!
console.log(s1 === s2); // true
How it works:
_instanceis a static property that holds the single instance.getInstance()checks if_instanceexists -- if not, it creates one.- Every subsequent call returns the same object.
Implementation 2: Frozen Singleton (Tamper-Proof)
class Config {
constructor() {
if (Config._instance) {
return Config._instance;
}
this.settings = {
port: 3000,
host: 'localhost',
debug: false,
};
Config._instance = this;
// Freeze to prevent accidental property additions
Object.freeze(this);
}
static getInstance() {
if (!Config._instance) {
Config._instance = new Config();
}
return Config._instance;
}
get(key) {
return this.settings[key];
}
}
// --- Usage ---
const config = Config.getInstance();
console.log(config.get('port')); // 3000
config.newProp = 'test'; // Silently fails (frozen)
console.log(config.newProp); // undefined
Implementation 3: Module-Based Singleton (The JavaScript Way)
In Node.js, every module is cached after the first require()/import. This means a module-level object is inherently a singleton.
// logger.js -- Module-based singleton (idiomatic JS)
class Logger {
constructor() {
this.logs = [];
this.level = 'info';
}
setLevel(level) {
this.level = level;
}
log(message) {
const entry = {
timestamp: new Date().toISOString(),
level: this.level,
message,
};
this.logs.push(entry);
console.log(`[${entry.level.toUpperCase()}] ${entry.timestamp}: ${message}`);
}
warn(message) {
const prevLevel = this.level;
this.level = 'warn';
this.log(message);
this.level = prevLevel;
}
error(message) {
const prevLevel = this.level;
this.level = 'error';
this.log(message);
this.level = prevLevel;
}
getLogs() {
return [...this.logs]; // Return a copy
}
}
// Export a SINGLE instance -- not the class
const logger = new Logger();
module.exports = logger;
// app.js
const logger = require('./logger');
logger.log('App started');
// routes.js
const logger = require('./logger'); // Same instance as app.js!
logger.log('Route hit');
Why this works: Node.js caches require() results by resolved filename. Every file that imports ./logger gets the same object. No getInstance() ceremony needed.
Implementation 4: Thread-Safe Singleton (Relevant for Async/Worker Threads)
JavaScript is single-threaded in the main event loop, so classic thread-safety concerns (mutexes, locks) don't apply to standard Node.js code. However, with Worker Threads, each worker gets its own module cache -- meaning the module-based singleton won't share across workers.
For async initialization (e.g., a DB pool that requires an async connect()), you need to guard against race conditions:
class Database {
static _instance = null;
static _initPromise = null;
constructor(connection) {
this.connection = connection;
}
static async getInstance() {
// If already initialized, return immediately
if (Database._instance) {
return Database._instance;
}
// If initialization is in progress, wait for it
if (Database._initPromise) {
return Database._initPromise;
}
// Start initialization -- store the promise so concurrent callers wait
Database._initPromise = Database._initialize();
return Database._initPromise;
}
static async _initialize() {
try {
// Simulate async connection
const connection = await connectToDatabase('mongodb://localhost:27017');
Database._instance = new Database(connection);
return Database._instance;
} finally {
Database._initPromise = null;
}
}
query(sql) {
return this.connection.query(sql);
}
}
// Simulated async connect
function connectToDatabase(uri) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
uri,
query: (sql) => console.log(`Executing: ${sql}`),
});
}, 100);
});
}
// --- Usage ---
async function main() {
// Even if called concurrently, only ONE connection is created
const [db1, db2] = await Promise.all([
Database.getInstance(),
Database.getInstance(),
]);
console.log(db1 === db2); // true
db1.query('SELECT * FROM users');
}
main();
Key insight: We store the Promise of initialization, not just the result. Concurrent callers await the same promise instead of each triggering a new connect().
Real-World Use Cases
1. Database Connection Pool
// dbPool.js
const { Pool } = require('pg'); // PostgreSQL
let pool = null;
function getPool() {
if (!pool) {
pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASS,
max: 20, // Max connections in pool
idleTimeoutMillis: 30000,
});
pool.on('error', (err) => {
console.error('Unexpected pool error', err);
});
}
return pool;
}
module.exports = { getPool };
2. Application Config
// config.js
class AppConfig {
static _instance = null;
constructor() {
this.config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
apiKey: process.env.API_KEY,
features: {
darkMode: true,
betaAccess: false,
},
};
Object.freeze(this.config);
}
static getInstance() {
if (!AppConfig._instance) {
AppConfig._instance = new AppConfig();
}
return AppConfig._instance;
}
get(path) {
return path.split('.').reduce((obj, key) => obj?.[key], this.config);
}
}
// --- Usage ---
const config = AppConfig.getInstance();
console.log(config.get('port')); // 3000
console.log(config.get('features.darkMode')); // true
3. Event Bus / Pub-Sub
// eventBus.js
class EventBus {
constructor() {
if (EventBus._instance) return EventBus._instance;
this.listeners = new Map();
EventBus._instance = this;
}
static getInstance() {
if (!EventBus._instance) {
EventBus._instance = new EventBus();
}
return EventBus._instance;
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return this; // Chainable
}
emit(event, data) {
const handlers = this.listeners.get(event) || [];
handlers.forEach((handler) => handler(data));
}
off(event, callback) {
const handlers = this.listeners.get(event);
if (handlers) {
this.listeners.set(
event,
handlers.filter((h) => h !== callback)
);
}
}
}
// --- Usage ---
const bus = EventBus.getInstance();
bus.on('user:login', (user) => console.log(`${user.name} logged in`));
bus.emit('user:login', { name: 'Alice' }); // "Alice logged in"
When to Use
| Use Singleton When... | Example |
|---|---|
| Resource is expensive to create | Database connection pool |
| You need consistent shared state | Application configuration |
| Exactly one coordinator is needed | Logger, event bus, cache |
| External system limits connections | API rate limiter, license manager |
When NOT to Use
| Avoid Singleton When... | Why |
|---|---|
| You need multiple instances later | Singleton is hard to un-singleton |
| Different modules need different configs | Singleton forces shared state |
| You're hiding dependency injection | Makes testing harder |
| You're using it as a "convenient global" | Leads to tight coupling |
Testing Challenges and Solutions
The Problem
// This test pollutes state for the next test
test('test 1', () => {
const config = AppConfig.getInstance();
config.settings.debug = true; // Mutated!
});
test('test 2', () => {
const config = AppConfig.getInstance();
// config.settings.debug is STILL true -- leaked from test 1!
});
Solution 1: Add a Reset Method (for tests only)
class AppConfig {
static _instance = null;
// ...existing code...
// Only for testing!
static _resetInstance() {
AppConfig._instance = null;
}
}
// In test setup
beforeEach(() => {
AppConfig._resetInstance();
});
Solution 2: Use Dependency Injection Instead
// Instead of reaching for a Singleton inside a function:
function handleRequest(req) {
const config = AppConfig.getInstance(); // Hidden dependency!
// ...
}
// Inject the dependency:
function handleRequest(req, config) {
// config is passed in -- easy to mock in tests
// ...
}
Solution 3: Module-Level Singleton with Jest Mocking
// Jest can mock modules easily
jest.mock('./logger', () => ({
log: jest.fn(),
error: jest.fn(),
}));
const logger = require('./logger');
// logger.log is now a mock function
Global State Concerns
Singletons are essentially managed global variables. The criticisms of global state apply:
Global Variable Problems:
1. Any code can modify it --> Unpredictable behavior
2. Hidden dependencies --> Hard to trace data flow
3. Test pollution --> State leaks between tests
4. Tight coupling --> Hard to swap implementations
Singleton mitigates #1 with encapsulation (controlled API),
but #2, #3, #4 remain real risks.
The Modern Consensus:
- Use module-level singletons (idiomatic JS) for simple cases.
- Use dependency injection for anything that needs testing.
- Reserve class-based Singletons for resources that truly MUST be unique (connection pools, hardware interfaces).
Before / After Comparison
Before: No Pattern
// fileA.js
const db = new Database('mongodb://localhost');
db.connect();
// fileB.js
const db = new Database('mongodb://localhost'); // Another connection!
db.connect();
// fileC.js
const db = new Database('mongodb://localhost'); // Yet another!
db.connect();
// Result: 3 connections, 3x memory, potential connection limit issues
After: Singleton
// database.js
let instance = null;
async function getDatabase() {
if (!instance) {
instance = new Database('mongodb://localhost');
await instance.connect();
}
return instance;
}
module.exports = { getDatabase };
// fileA.js
const { getDatabase } = require('./database');
const db = await getDatabase(); // Creates connection
// fileB.js
const { getDatabase } = require('./database');
const db = await getDatabase(); // Reuses connection
// fileC.js
const { getDatabase } = require('./database');
const db = await getDatabase(); // Reuses connection
// Result: 1 connection, shared efficiently
Key Takeaways
- Singleton ensures exactly one instance of a class exists, with a global access point.
- In JavaScript, module caching provides a natural singleton mechanism -- prefer it over class-based singletons.
- For async initialization, store the initialization Promise to prevent duplicate work from concurrent callers.
- Singletons introduce global state -- use them sparingly and prefer dependency injection for testability.
- Always provide a reset mechanism for testing, or better yet, design for DI from the start.
Explain-It Challenge: A teammate proposes making the User model a Singleton. Explain in three sentences why this is a terrible idea and what pattern they should use instead.