Loading learning content...
The Singleton pattern holds a unique position in software design: it's one of the original Gang of Four patterns, widely taught and used, yet simultaneously one of the most criticized. Many experienced engineers consider it an anti-pattern in most contexts.
This criticism isn't academic pedantry. The problems with Singleton manifest in real codebases as reduced testability, hidden dependencies, tight coupling, and unpredictable behavior. Understanding these criticisms is essential—not to avoid Singleton entirely, but to apply it judiciously and recognize when alternatives better serve your design.
This page presents a balanced, critical evaluation. We'll examine the legitimate criticisms, understand when they apply, and explore alternatives that address the underlying needs without Singleton's downsides.
By the end of this page, you will understand the major criticisms of Singleton (global state, testing difficulty, hidden dependencies), recognize when these criticisms apply to your use case, evaluate dependency injection as an alternative, and develop criteria for when Singleton remains appropriate.
The most fundamental criticism of Singleton is that it introduces global state into your application. The singleton instance is accessible from anywhere, modifiable from anywhere, and its state persists for the application lifetime.
Why global state causes problems:
1. Non-Local Effects When any code can modify the singleton, understanding the cause of state changes becomes nearly impossible. A bug in module A might manifest as incorrect behavior in module B because both share singleton state.
2. Unpredictable Initialization Global state must be initialized at some point. In complex applications, the order of initialization can be non-deterministic, leading to subtle bugs where the singleton is accessed before it's ready.
3. Hidden Coupling
Every class that uses Singleton.getInstance() is implicitly coupled to that Singleton. This coupling isn't visible in the class's interface—it's hidden in the implementation. Refactoring becomes treacherous.
4. Reasoning Difficulty Pure functions—those that only depend on their inputs—are easy to reason about. Functions that access global state depend on invisible inputs. You can't understand what a function does by looking at its signature alone.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// PROBLEM: Hidden global state makes code unpredictable class ApplicationState { private static instance: ApplicationState | null = null; public currentUser: User | null = null; public theme: 'light' | 'dark' = 'light'; public notificationCount: number = 0; public static getInstance(): ApplicationState { if (!ApplicationState.instance) { ApplicationState.instance = new ApplicationState(); } return ApplicationState.instance; }} // This function's behavior depends on hidden global statefunction displayWelcomeMessage(): string { const state = ApplicationState.getInstance(); const user = state.currentUser; if (user) { return `Welcome back, ${user.name}!`; } return "Welcome, guest!";} // PROBLEM: What affects this function's output?// Looking at the signature, you'd think nothing - no parameters.// But actually, it depends on:// 1. Whether ApplicationState was initialized// 2. What currentUser was set to// 3. Whether something else modified currentUser // Calling displayWelcomeMessage() twice might return different results// even though no parameters changed. This is non-local mutation. // MODULE Afunction handleLogin(credentials: Credentials) { const user = authenticate(credentials); ApplicationState.getInstance().currentUser = user; // Mutation!} // MODULE Bfunction renderHeader() { return displayWelcomeMessage(); // Reads state set by MODULE A // There's no visible connection between these modules!} // When the header shows the wrong username, where do you look?// The dependency on ApplicationState is hidden in implementations.Singleton is often described as 'a global variable with extra steps.' The static accessor and private constructor don't change the fundamental nature: it's mutable state accessible from anywhere in the program. If global variables are bad (and they are), Singleton is often equally bad.
Singletons fundamentally conflict with unit testing best practices. The issues are severe enough that testing difficulty alone is often cited as reason to avoid Singleton:
State Persistence Between Tests
The singleton instance persists for the lifetime of the test runner. State modifications from one test affect subsequent tests. Test order becomes significant. Parallel test execution becomes impossible.
Inability to Substitute
Unit tests should isolate the code under test by substituting dependencies with mocks or stubs. When code calls Singleton.getInstance() directly, you can't substitute a test double without modifying the singleton itself or using invasive techniques like reflection.
Hidden Test Dependencies
Tests that use singletons have invisible setup requirements. A test might pass or fail depending on which other tests ran before it. This creates flaky test suites where tests pass in isolation but fail when run together.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// SINGLETON: Hard to testclass PaymentGateway { private static instance: PaymentGateway | null = null; private constructor() {} public static getInstance(): PaymentGateway { if (!PaymentGateway.instance) { PaymentGateway.instance = new PaymentGateway(); } return PaymentGateway.instance; } public processPayment(amount: number): boolean { // Real payment processing - hits external API console.log(`Processing payment of $${amount}`); return true; // Simplified }} // Code under test - directly depends on singletonclass OrderService { public placeOrder(order: Order): boolean { // Hidden dependency! Not visible in constructor or method signature. const gateway = PaymentGateway.getInstance(); if (order.total > 0) { return gateway.processPayment(order.total); } return false; }} // TEST: This is problematicdescribe('OrderService', () => { it('should process payment for valid order', () => { const orderService = new OrderService(); const order = { total: 100 }; // PROBLEM 1: This calls the real PaymentGateway! // We're hitting external payment APIs in our unit test. const result = orderService.placeOrder(order); // PROBLEM 2: We can't verify HOW the payment was processed // because we can't mock PaymentGateway. // PROBLEM 3: If PaymentGateway has any state, // it carries over to other tests. expect(result).toBe(true); // This test is fragile });}); // BETTER DESIGN: Dependency injection makes testing easyinterface IPaymentGateway { processPayment(amount: number): boolean;} class TestableOrderService { // Dependency is explicit and injected constructor(private paymentGateway: IPaymentGateway) {} public placeOrder(order: Order): boolean { if (order.total > 0) { return this.paymentGateway.processPayment(order.total); } return false; }} // TEST: Now we can mock properlydescribe('TestableOrderService', () => { it('should process payment for valid order', () => { // Create mock gateway const mockGateway: IPaymentGateway = { processPayment: jest.fn().mockReturnValue(true) }; // Inject mock const orderService = new TestableOrderService(mockGateway); const order = { total: 100 }; const result = orderService.placeOrder(order); // Verify behavior without hitting real APIs expect(mockGateway.processPayment).toHaveBeenCalledWith(100); expect(result).toBe(true); }); it('should not process payment for zero total', () => { const mockGateway: IPaymentGateway = { processPayment: jest.fn() }; const orderService = new TestableOrderService(mockGateway); const order = { total: 0 }; const result = orderService.placeOrder(order); // Verify gateway was never called expect(mockGateway.processPayment).not.toHaveBeenCalled(); expect(result).toBe(false); });});When a class uses a Singleton, that dependency is invisible to callers. The class's public interface—its constructor and method signatures—doesn't reveal what other components it requires. This creates "action at a distance" where understanding code requires reading through all implementations.
Why explicit dependencies matter:
Constructor Signature as Documentation When dependencies are constructor parameters, you immediately see what a class needs. The API documents itself. With singletons, you must read the implementation to discover dependencies.
Compile-Time Verification Constructor injection fails compilation if a dependency isn't provided. Singleton access always "works" at compile time—failures are runtime, often in production.
Controlled Object Graphs Explicit dependencies let you construct object graphs deliberately. You decide what gets what. Singletons let any code grab any singleton, creating uncontrolled dependency webs.
123456789101112131415161718192021222324252627282930313233343536
// HIDDEN DEPENDENCIES class ReportGenerator { // Constructor tells us nothing constructor() {} generate(): Report { // Surprise dependencies! const logger = Logger.getInstance(); const config = Config.getInstance(); const db = Database.getInstance(); const cache = Cache.getInstance(); logger.log("Generating report..."); const data = this.fetchData(db, cache); return this.format(data, config); } private fetchData(db: any, cache: any) { // Implementation... return {}; } private format(data: any, config: any) { // Implementation... return {} as Report; }} // What does ReportGenerator need?// You have NO IDEA from the signature.// You must read generate() to find out. const gen = new ReportGenerator();// This might fail at runtime if any// singleton isn't initialized!12345678910111213141516171819202122232425262728293031323334353637383940
// EXPLICIT DEPENDENCIES class ReportGenerator { // Dependencies are documented! constructor( private logger: ILogger, private config: IConfig, private db: IDatabase, private cache: ICache ) {} generate(): Report { // No surprises this.logger.log("Report..."); const data = this.fetchData(); return this.format(data); } private fetchData() { // Uses this.db, this.cache return {}; } private format(data: any) { // Uses this.config return {} as Report; }} // Constructor documents requirements// new ReportGenerator(?)// IDE tells you exactly what's needed const gen = new ReportGenerator( logger, // Must provide config, // Must provide db, // Must provide cache // Must provide);// Cannot forget a dependency!Good code declares its dependencies explicitly. The constructor signature is documentation. When dependencies are hidden inside methods, you lose the ability to reason about code in isolation. Every method call potentially touches any global state.
Singleton access couples code to a specific concrete implementation. When you call ConfigurationManager.getInstance(), you're not depending on "something that provides configuration"—you're depending on that exact class.
The Coupling Problem:
Suppose you have 50 classes calling FileLogger.getInstance(). Now you want to:
Every one of those 50 classes directly references FileLogger. You must modify all of them, even though they don't care about logging implementation—they just want something that logs.
Interface-Based Design: When depending on interfaces rather than concrete classes, you can swap implementations without modifying clients. The singleton pattern, by providing a global concrete instance, defeats this flexibility.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// TIGHTLY COUPLED TO IMPLEMENTATION class FileLogger { private static instance: FileLogger | null = null; private constructor(private filePath: string) {} public static getInstance(): FileLogger { if (!FileLogger.instance) { FileLogger.instance = new FileLogger('/var/log/app.log'); } return FileLogger.instance; } public log(message: string): void { // Writes to file console.log(`[FILE] ${message}`); }} // 50 classes all do this:class UserService { public createUser(data: UserData): void { FileLogger.getInstance().log(`Creating user: ${data.email}`); // ... }} class PaymentService { public processPayment(amount: number): void { FileLogger.getInstance().log(`Processing payment: $${amount}`); // ... }} // ... 48 more classes ... // NOW: Requirements change - need cloud logging in production! // Option A: Modify FileLogger to conditionally use cloud// - FileLogger now does two things (file + cloud) - SRP violation// - Still coupled to FileLogger class // Option B: Create CloudLogger singleton, update all 50 classes// - Massive code change// - What about environments that still need file logging? // Option C: Should have used dependency injection from the startinterface ILogger { log(message: string): void;} class FlexibleUserService { constructor(private logger: ILogger) {} public createUser(data: UserData): void { this.logger.log(`Creating user: ${data.email}`); // ... }} // Now you can inject ANY logger:// - FileLogger in development// - CloudLogger in production// - MockLogger in tests// WITHOUT changing FlexibleUserService at all!Dependency Injection (DI) addresses the same underlying need as Singleton—providing a shared instance of a component—without the global state, hidden dependencies, or testing difficulties.
The Core Idea: Instead of components fetching their dependencies from global accessors, dependencies are provided to components from outside. The component declares what it needs; something else provides it.
What DI Provides:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// DEPENDENCY INJECTION: The modern alternative // Step 1: Define interfaces for dependenciesinterface ILogger { log(message: string): void;} interface IConfigProvider { get(key: string): string | undefined;} interface IDatabase { query<T>(sql: string): Promise<T[]>;} // Step 2: Classes declare dependencies through constructorclass UserRepository { constructor( private db: IDatabase, private logger: ILogger ) {} async findById(id: string): Promise<User | null> { this.logger.log(`Finding user by ID: ${id}`); const results = await this.db.query<User>( `SELECT * FROM users WHERE id = '${id}'` ); return results[0] || null; }} class UserService { constructor( private userRepository: UserRepository, private logger: ILogger, private config: IConfigProvider ) {} async createUser(data: UserData): Promise<User> { this.logger.log(`Creating user: ${data.email}`); // Use config, repository, etc. return {} as User; }} // Step 3: Composition Root wires everything together// This is the ONE place that knows about concrete types function bootstrap(): UserService { // Create concrete implementations const logger: ILogger = new ConsoleLogger(); const config: IConfigProvider = new EnvironmentConfig(); const database: IDatabase = new PostgresDatabase(config); // Wire the dependency graph const userRepository = new UserRepository(database, logger); const userService = new UserService(userRepository, logger, config); return userService;} // Or use a DI container to automate wiring:// container.register<ILogger>(ConsoleLogger, { lifecycle: 'singleton' });// container.register<IConfigProvider>(EnvironmentConfig, { lifecycle: 'singleton' });// container.register<IDatabase>(PostgresDatabase, { lifecycle: 'singleton' });// container.register<UserRepository>(UserRepository);// container.register<UserService>(UserService);// const userService = container.resolve<UserService>(UserService); // Step 4: Tests easily substitute dependenciesdescribe('UserService', () => { it('should create user', async () => { // Create mocks const mockLogger: ILogger = { log: jest.fn() }; const mockConfig: IConfigProvider = { get: jest.fn().mockReturnValue('test-value') }; const mockDb: IDatabase = { query: jest.fn().mockResolvedValue([]) }; const mockRepo = new UserRepository(mockDb, mockLogger); // Inject mocks const userService = new UserService(mockRepo, mockLogger, mockConfig); // Test in isolation await userService.createUser({ email: 'test@example.com' }); expect(mockLogger.log).toHaveBeenCalled(); });});DI containers support 'singleton scope' where only one instance is created and shared. The critical difference: the container manages the single instance, components receive it as a dependency, and the global accessor pattern is eliminated. You get single-instance behavior without global state.
Despite the criticisms, Singleton isn't universally wrong. There are legitimate use cases where the pattern's benefits outweigh its costs:
1. True Infrastructure Singletons
Components that genuinely represent singular physical or OS-level resources:
2. Stateless or Read-Only Singletons
When the singleton maintains no mutable state, many criticisms don't apply:
3. Logging and Instrumentation
Practical compromise: logging singletons are common and generally acceptable:
4. Bootstrapping and Entry Points
Application entry points sometimes use singleton patterns during initial setup:
| Question | If Yes... | If No... |
|---|---|---|
| Is there genuinely only one of this resource in the system? | Singleton might be appropriate | Don't use Singleton |
| Do multiple instances cause correctness issues, not just inefficiency? | Singleton might be appropriate | Use DI with singleton scope |
| Is the singleton stateless or read-only after initialization? | Singleton is safer | Carefully evaluate global state implications |
| Do you need to substitute this in tests? | Avoid Singleton; use DI | Singleton may be acceptable |
| Is this a cross-cutting concern (logging, instrumentation)? | Singleton is pragmatic | Prefer explicit dependencies |
| Can you easily refactor later if Singleton becomes problematic? | Risk is lower | Start with DI to avoid future pain |
If you have existing code with Singleton dependencies and want to migrate toward dependency injection, here's a gradual refactoring path:
Step 1: Extract Interface Create an interface that the singleton implements. This enables substitution even while the singleton access remains.
Step 2: Add Constructor Injection Modify classes to accept the dependency through constructors while defaulting to the singleton. This allows injection for tests while maintaining backward compatibility.
Step 3: Migrate Callers Gradually Update calling code to provide dependencies. Start with test code, then move to production code module by module.
Step 4: Remove Singleton Access Once all callers inject dependencies, remove the default parameter and eventually the singleton accessor entirely.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// ORIGINAL: Tight singleton couplingclass ConfigManager { private static instance: ConfigManager | null = null; private settings: Map<string, string> = new Map(); private constructor() { this.loadSettings(); } public static getInstance(): ConfigManager { if (!ConfigManager.instance) { ConfigManager.instance = new ConfigManager(); } return ConfigManager.instance; } public get(key: string): string | undefined { return this.settings.get(key); } private loadSettings(): void { // Load from sources... }} class OriginalService { doWork(): void { const dbHost = ConfigManager.getInstance().get('db.host'); // Use dbHost... }} // -------------------------------------------------// STEP 1: Extract interface// ------------------------------------------------- interface IConfigProvider { get(key: string): string | undefined;} // ConfigManager now implements the interfaceclass ConfigManager implements IConfigProvider { // ... same implementation ...} // -------------------------------------------------// STEP 2: Add constructor injection with default// ------------------------------------------------- class TransitionalService { private config: IConfigProvider; // Default to singleton for backward compatibility constructor(config: IConfigProvider = ConfigManager.getInstance()) { this.config = config; } doWork(): void { const dbHost = this.config.get('db.host'); // Use dbHost... }} // Existing code works unchanged:const service1 = new TransitionalService(); // But tests can inject mocks:const mockConfig: IConfigProvider = { get: () => 'test-host' };const service2 = new TransitionalService(mockConfig); // -------------------------------------------------// STEP 3: Migrate callers to provide dependencies// ------------------------------------------------- // composition-root.tsfunction createApplication() { const config = ConfigManager.getInstance(); // Still singleton for now // But now we INJECT it explicitly const service = new TransitionalService(config); return service;} // -------------------------------------------------// STEP 4: Remove singleton access (end state)// ------------------------------------------------- class FinalService { // No default - dependency MUST be provided constructor(private config: IConfigProvider) {} doWork(): void { const dbHost = this.config.get('db.host'); // Use dbHost... }} // Composition root handles instantiationfunction createFinalApplication() { const config = new EnvironmentConfig(); // Could be any implementation return new FinalService(config);}We've examined the Singleton pattern critically, understanding both its problems and its remaining legitimate uses:
You've now completed a comprehensive study of the Singleton pattern—from understanding when single instances are genuinely required, through implementation with thread safety, to critical evaluation and alternatives. Apply Singleton carefully, prefer dependency injection in most cases, and remember: the goal is building maintainable, testable software, not pattern conformance.