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
- Design Patterns - Original Singleton pattern
- Singleton Considered Harmful - Critique
- Dependency Injection Principles - Better alternative
- Working Effectively with Legacy Code - Testing singletons