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:

  1. _instance is a static property that holds the single instance.
  2. getInstance() checks if _instance exists -- if not, it creates one.
  3. 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 createDatabase connection pool
You need consistent shared stateApplication configuration
Exactly one coordinator is neededLogger, event bus, cache
External system limits connectionsAPI rate limiter, license manager

When NOT to Use

Avoid Singleton When...Why
You need multiple instances laterSingleton is hard to un-singleton
Different modules need different configsSingleton forces shared state
You're hiding dependency injectionMakes 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

  1. Singleton ensures exactly one instance of a class exists, with a global access point.
  2. In JavaScript, module caching provides a natural singleton mechanism -- prefer it over class-based singletons.
  3. For async initialization, store the initialization Promise to prevent duplicate work from concurrent callers.
  4. Singletons introduce global state -- use them sparingly and prefer dependency injection for testability.
  5. 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.