Singleton Pattern: Ensuring Single Instance

The Singleton pattern restricts a class to a single instance and provides global access to that instance. It's one of the original Gang of Four creational patterns, and it's probably the most...

Key Insights

  • The Singleton pattern ensures exactly one instance of a class exists, but manual implementations introduce complexity around thread safety and testing that modern dependency injection containers handle better.
  • Double-checked locking and enum-based singletons solve thread-safety issues, but they don’t address the fundamental problem of hidden dependencies and global state that make code harder to test and reason about.
  • Use Singleton sparingly and prefer DI container-managed scopes—you get the single-instance guarantee without the tight coupling and testability headaches.

What Is the Singleton Pattern?

The Singleton pattern restricts a class to a single instance and provides global access to that instance. It’s one of the original Gang of Four creational patterns, and it’s probably the most controversial.

The intent is straightforward: some resources shouldn’t be duplicated. You don’t want multiple database connection pools competing for connections. You don’t want two configuration managers loading different settings. You don’t want separate logging services writing to the same file and corrupting output.

Singleton gives you a controlled access point to these shared resources. The class itself enforces the constraint—no matter how many times code requests an instance, it always gets the same one.

The Problem Singleton Solves

Consider a database connection pool. Creating connections is expensive. If every component instantiated its own pool, you’d exhaust database connections quickly and waste memory on redundant pools.

// Without singleton: chaos
public class OrderService {
    private ConnectionPool pool = new ConnectionPool(); // New pool!
}

public class UserService {
    private ConnectionPool pool = new ConnectionPool(); // Another pool!
}

public class InventoryService {
    private ConnectionPool pool = new ConnectionPool(); // Yet another!
}

Each service creates its own pool. With 50 services, you have 50 pools. The database server rejects connections. Your application crashes.

The same problem applies to:

  • Configuration managers that load settings from files or environment variables
  • Logging services that maintain file handles or network connections
  • Caches that should share data across the application
  • Thread pools that manage worker threads
  • Hardware interface managers like print spoolers

These resources need coordination. Uncontrolled instantiation breaks that coordination.

Basic Implementation

The classic singleton has three components: a private constructor preventing external instantiation, a static variable holding the single instance, and a public static method providing access.

// Basic singleton in TypeScript
class ConfigurationManager {
    private static instance: ConfigurationManager | null = null;
    private settings: Map<string, string>;

    private constructor() {
        this.settings = new Map();
        this.loadSettings();
    }

    public static getInstance(): ConfigurationManager {
        if (ConfigurationManager.instance === null) {
            ConfigurationManager.instance = new ConfigurationManager();
        }
        return ConfigurationManager.instance;
    }

    private loadSettings(): void {
        // Load from environment, files, etc.
        this.settings.set('database.url', process.env.DATABASE_URL || '');
        this.settings.set('api.timeout', process.env.API_TIMEOUT || '30000');
    }

    public get(key: string): string | undefined {
        return this.settings.get(key);
    }
}

// Usage
const config = ConfigurationManager.getInstance();
console.log(config.get('database.url'));

The Java equivalent follows the same structure:

public class ConfigurationManager {
    private static ConfigurationManager instance = null;
    private Map<String, String> settings;

    private ConfigurationManager() {
        settings = new HashMap<>();
        loadSettings();
    }

    public static ConfigurationManager getInstance() {
        if (instance == null) {
            instance = new ConfigurationManager();
        }
        return instance;
    }

    private void loadSettings() {
        settings.put("database.url", System.getenv("DATABASE_URL"));
        settings.put("api.timeout", System.getenv("API_TIMEOUT"));
    }

    public String get(String key) {
        return settings.get(key);
    }
}

This implementation uses lazy initialization—the instance isn’t created until first requested. That’s efficient for expensive objects that might never be used, but it introduces a serious problem in multi-threaded environments.

Thread-Safety Considerations

The basic implementation has a race condition. Two threads can simultaneously check instance == null, both see true, and both create instances. You end up with two singletons, defeating the pattern’s purpose.

// Race condition scenario:
// Thread A: checks instance == null (true)
// Thread B: checks instance == null (true)
// Thread A: creates new instance
// Thread B: creates new instance (oops!)

Synchronized Method

The simplest fix synchronizes the entire method:

public static synchronized ConfigurationManager getInstance() {
    if (instance == null) {
        instance = new ConfigurationManager();
    }
    return instance;
}

This works but introduces performance overhead. Every call acquires a lock, even after the instance exists. For frequently accessed singletons, this becomes a bottleneck.

Double-Checked Locking

Double-checked locking reduces synchronization overhead by only locking during creation:

public class ConfigurationManager {
    private static volatile ConfigurationManager instance = null;
    private Map<String, String> settings;

    private ConfigurationManager() {
        settings = new HashMap<>();
        loadSettings();
    }

    public static ConfigurationManager getInstance() {
        if (instance == null) {                    // First check (no lock)
            synchronized (ConfigurationManager.class) {
                if (instance == null) {            // Second check (with lock)
                    instance = new ConfigurationManager();
                }
            }
        }
        return instance;
    }

    // ... rest of implementation
}

The volatile keyword is critical. Without it, the compiler might reorder instructions, allowing threads to see a partially constructed instance. This pattern is correct in Java 5+ but was broken in earlier versions.

Enum-Based Singleton (Java)

Java enums provide the cleanest thread-safe singleton:

public enum ConfigurationManager {
    INSTANCE;

    private final Map<String, String> settings;

    ConfigurationManager() {
        settings = new HashMap<>();
        loadSettings();
    }

    private void loadSettings() {
        settings.put("database.url", System.getenv("DATABASE_URL"));
        settings.put("api.timeout", System.getenv("API_TIMEOUT"));
    }

    public String get(String key) {
        return settings.get(key);
    }
}

// Usage
String url = ConfigurationManager.INSTANCE.get("database.url");

The JVM guarantees enum instances are created exactly once, thread-safely. It also prevents reflection attacks and handles serialization correctly. Joshua Bloch recommends this approach in Effective Java.

Module Pattern (JavaScript/TypeScript)

In JavaScript, modules are singletons by default:

// configurationManager.ts
class ConfigurationManagerImpl {
    private settings: Map<string, string>;

    constructor() {
        this.settings = new Map();
        this.loadSettings();
    }

    private loadSettings(): void {
        this.settings.set('database.url', process.env.DATABASE_URL || '');
    }

    public get(key: string): string | undefined {
        return this.settings.get(key);
    }
}

// Export a single instance
export const configurationManager = new ConfigurationManagerImpl();

// Usage in other files
import { configurationManager } from './configurationManager';
configurationManager.get('database.url');

The module system caches exports. Every import gets the same instance. No special pattern needed.

Modern Approaches and Dependency Injection

Modern applications rarely implement singletons manually. Dependency injection containers manage object lifecycles, including singleton scope.

Spring Framework

@Configuration
public class AppConfig {
    
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_SINGLETON) // Default, but explicit
    public ConfigurationManager configurationManager() {
        return new ConfigurationManager();
    }
}

@Service
public class OrderService {
    private final ConfigurationManager config;

    @Autowired
    public OrderService(ConfigurationManager config) {
        this.config = config;  // Injected singleton
    }
}

Spring beans are singletons by default. The container creates one instance and injects it wherever needed.

NestJS (TypeScript)

@Injectable()
export class ConfigurationService {
    private settings: Map<string, string>;

    constructor() {
        this.settings = new Map();
        this.loadSettings();
    }

    private loadSettings(): void {
        this.settings.set('database.url', process.env.DATABASE_URL || '');
    }

    get(key: string): string | undefined {
        return this.settings.get(key);
    }
}

@Module({
    providers: [ConfigurationService],  // Singleton by default
    exports: [ConfigurationService],
})
export class ConfigModule {}

NestJS providers are singletons within their module scope. The framework handles instantiation and injection.

Container-managed singletons offer significant advantages: dependencies are explicit through constructor injection, testing is straightforward with mock injection, and you avoid the static access that makes code harder to trace.

Criticisms and Alternatives

Singleton has earned its controversial reputation. The problems are real.

Hidden dependencies: Code using ConfigurationManager.getInstance() hides its dependency. You can’t tell from the constructor what the class needs. This makes code harder to understand and refactor.

Testing difficulties: Static access makes mocking painful. You need PowerMock or similar tools to intercept static method calls. Compare that to constructor injection where you simply pass a mock.

Global state: Singletons are global variables with extra steps. They carry state across test runs, causing flaky tests. They make it impossible to run tests in parallel safely.

Tight coupling: Code becomes coupled to the concrete singleton class. You can’t substitute implementations without modifying the singleton itself.

Alternatives

Dependency injection solves most singleton problems. Let the container manage lifecycle and inject dependencies explicitly:

// Instead of this:
public class OrderService {
    public void process() {
        String url = ConfigurationManager.getInstance().get("database.url");
    }
}

// Do this:
public class OrderService {
    private final ConfigurationManager config;

    public OrderService(ConfigurationManager config) {
        this.config = config;
    }

    public void process() {
        String url = config.get("database.url");
    }
}

Monostate pattern provides shared state without restricting instantiation:

public class Configuration {
    private static Map<String, String> settings = new HashMap<>();

    public String get(String key) {
        return settings.get(key);
    }

    public void set(String key, String value) {
        settings.put(key, value);
    }
}

// Multiple instances, shared state
Configuration config1 = new Configuration();
Configuration config2 = new Configuration();
config1.set("key", "value");
config2.get("key");  // Returns "value"

Monostate allows normal instantiation and polymorphism while sharing state. It’s still global state, but it’s more flexible than singleton.

When to Use (and Avoid) Singleton

Use singleton when:

  • You genuinely need exactly one instance (hardware interfaces, connection pools)
  • The instance is stateless or has read-only state after initialization
  • You’re using a DI container that manages the lifecycle for you

Avoid singleton when:

  • You’re using it for convenience rather than necessity
  • The “singleton” could reasonably have multiple instances in tests
  • You’re not using dependency injection and will access it statically
  • State changes during the application lifecycle

Decision checklist:

  1. Do I need exactly one instance, or do I just want shared state?
  2. Can I use DI container singleton scope instead of implementing the pattern?
  3. Will static access make testing difficult?
  4. Is this truly application-scoped, or could different contexts need different instances?

If you answered “shared state,” “yes,” “yes,” or “different instances,” reconsider singleton. Dependency injection with explicit scoping almost always serves you better than hand-rolled singletons with static access.

The pattern exists for a reason, but that reason is narrower than most developers assume. Use it deliberately, not by default.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.