Singleton Pattern

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. While this pattern is common in legacy code, it's often overused and can create problems for testing, concurrency, and maintainability. This lesson explores when singletons are appropriate and how to work with them in legacy systems.

Video Summary

The video presentation critically examines the Singleton pattern:

  • What singletons are and how they work
  • Why singletons are controversial
  • Problems singletons create in legacy systems
  • When singletons are actually appropriate
  • Refactoring away from singletons
  • Testing strategies for singleton-heavy code
  • Alternatives to consider

Key Concepts

1. Classic Singleton Implementation

class Database {
  private static instance: Database | null = null;

  private constructor() {
    // Private constructor prevents direct instantiation
    this.connect();
  }

  static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  query(sql: string): any[] {
    // Database operations
  }

  private connect(): void {
    // Establish connection
  }
}

// Usage
const db = Database.getInstance();
db.query('SELECT * FROM users');

2. Why Singletons Are Problematic

Global state:

  • Makes code harder to reason about
  • Creates hidden dependencies
  • Causes action-at-a-distance bugs

Testing difficulties:

  • Can't easily mock or replace
  • Test pollution (state persists between tests)
  • Order-dependent tests

Concurrency issues:

  • Not thread-safe by default
  • Race conditions during initialization

Tight coupling:

  • Code directly coupled to singleton class
  • Hard to swap implementations

When Singletons ARE Appropriate

Legitimate Use Cases

1. Resource Management

class ConnectionPool {
  private static instance: ConnectionPool;
  private connections: Connection[] = [];

  private constructor() {
    // Initialize expensive connection pool once
  }

  static getInstance(): ConnectionPool {
    if (!ConnectionPool.instance) {
      ConnectionPool.instance = new ConnectionPool();
    }
    return ConnectionPool.instance;
  }
}

2. Logging

class Logger {
  private static instance: Logger;

  private constructor() {}

  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

3. Configuration

class AppConfig {
  private static instance: AppConfig;
  private config: Record<string, any>;

  private constructor() {
    this.config = this.loadConfig();
  }

  static getInstance(): AppConfig {
    if (!AppConfig.instance) {
      AppConfig.instance = new AppConfig();
    }
    return AppConfig.instance;
  }

  get(key: string): any {
    return this.config[key];
  }

  private loadConfig(): Record<string, any> {
    // Load from environment, files, etc.
  }
}

Working with Legacy Singletons

Strategy 1: Add Seam for Testing

class LegacySingleton {
  private static instance: LegacySingleton;

  static getInstance(): LegacySingleton {
    if (!LegacySingleton.instance) {
      LegacySingleton.instance = new LegacySingleton();
    }
    return LegacySingleton.instance;
  }

  // ADD: Test seam
  static setInstance(instance: LegacySingleton): void {
    LegacySingleton.instance = instance;
  }

  static resetInstance(): void {
    LegacySingleton.instance = null!;
  }

  doSomething(): void {
    // Legacy logic
  }
}

// Testing
beforeEach(() => {
  const mock = {
    doSomething: jest.fn(),
  } as any;
  LegacySingleton.setInstance(mock);
});

afterEach(() => {
  LegacySingleton.resetInstance();
});

Strategy 2: Extract Interface

// Extract interface
interface UserService {
  getUser(id: string): Promise<User>;
  saveUser(user: User): Promise<void>;
}

// Singleton implements interface
class LegacyUserService implements UserService {
  private static instance: LegacyUserService;

  static getInstance(): LegacyUserService {
    if (!LegacyUserService.instance) {
      LegacyUserService.instance = new LegacyUserService();
    }
    return LegacyUserService.instance;
  }

  async getUser(id: string): Promise<User> {
    // Implementation
  }

  async saveUser(user: User): Promise<void> {
    // Implementation
  }
}

// New code uses interface, not singleton directly
class OrderService {
  constructor(private userService: UserService) {}

  async processOrder(order: Order) {
    const user = await this.userService.getUser(order.userId);
    // Process order
  }
}

// Production: inject singleton
const orderService = new OrderService(LegacyUserService.getInstance());

// Testing: inject mock
const orderService = new OrderService(mockUserService);

Strategy 3: Dependency Injection Container

class Container {
  private instances = new Map<string, any>();

  register<T>(key: string, factory: () => T, singleton = false): void {
    if (singleton) {
      const instance = factory();
      this.instances.set(key, () => instance);
    } else {
      this.instances.set(key, factory);
    }
  }

  resolve<T>(key: string): T {
    const factory = this.instances.get(key);
    if (!factory) {
      throw new Error(`No registration for ${key}`);
    }
    return factory();
  }
}

// Register services
const container = new Container();

container.register('database', () => new Database(), true); // Singleton
container.register('userService', () =>
  new UserService(container.resolve('database')),
true);

// Resolve when needed
const userService = container.resolve<UserService>('userService');

Refactoring Away from Singletons

Before: Direct Singleton Usage

class OrderProcessor {
  processOrder(order: Order): void {
    // Direct singleton dependency
    const logger = Logger.getInstance();
    const db = Database.getInstance();
    const config = Config.getInstance();

    logger.log(`Processing order ${order.id}`);

    const settings = config.get('orderProcessing');

    db.insert('orders', order);
  }
}

After: Dependency Injection

class OrderProcessor {
  constructor(
    private logger: Logger,
    private database: Database,
    private config: Config
  ) {}

  processOrder(order: Order): void {
    this.logger.log(`Processing order ${order.id}`);

    const settings = this.config.get('orderProcessing');

    this.database.insert('orders', order);
  }
}

// Production
const processor = new OrderProcessor(
  Logger.getInstance(),
  Database.getInstance(),
  Config.getInstance()
);

// Testing
const processor = new OrderProcessor(mockLogger, mockDb, mockConfig);

Testing Strategies

Strategy 1: Reset Between Tests

describe('UserService', () => {
  afterEach(() => {
    // Reset singleton state
    UserService.resetInstance();
  });

  it('creates user', async () => {
    const service = UserService.getInstance();
    await service.createUser({ name: 'Test' });
    // Test logic
  });
});

Strategy 2: Use Dependency Injection

// Instead of this
const result = LegacySingleton.getInstance().doSomething();

// Do this
class MyClass {
  constructor(private service: ServiceInterface) {}

  doWork() {
    return this.service.doSomething();
  }
}

// Test with mock
const myClass = new MyClass(mockService);

Strategy 3: Module-Level Singleton (TypeScript/ES6)

// logger.ts
class Logger {
  log(message: string): void {
    console.log(message);
  }
}

// Export single instance
export const logger = new Logger();

// Usage
import { logger } from './logger';
logger.log('Message');

// Testing: can mock the module
jest.mock('./logger', () => ({
  logger: { log: jest.fn() },
}));

Alternatives to Singletons

1. Dependency Injection

// Not a singleton, just injected
class ConfigService {
  constructor(private env: Record<string, string>) {}

  get(key: string): string {
    return this.env[key];
  }
}

// Create once in main, inject everywhere
const config = new ConfigService(process.env);
const app = new App(config);

2. Module Pattern

// config.ts
const config = {
  apiUrl: process.env.API_URL,
  timeout: 5000,
};

export default config;

// Import where needed
import config from './config';

3. Factory Pattern

class DatabaseFactory {
  private static instance: Database | null = null;

  static createDatabase(): Database {
    if (!DatabaseFactory.instance) {
      DatabaseFactory.instance = new Database();
    }
    return DatabaseFactory.instance;
  }
}

Key Takeaways

  • Singletons ensure single instance but create global state
  • Often overused in legacy code, creating testing and maintenance problems
  • Appropriate for: logging, configuration, resource pools
  • Problematic for: business logic, data access
  • Add test seams to legacy singletons for testability
  • Prefer dependency injection over direct singleton access
  • Refactor to interfaces to enable testing and flexibility
  • Consider module-level exports as lightweight alternative
  • When in doubt, avoid singletons—dependency injection is usually better

Further Reading

Sunsetting - Singleton Pattern | Sunsetting Learn