Back to all terms
S1S2
State & Archbasic

Singleton (and why to avoid it)

A creational pattern that ensures a class has only one instance with a global access point, widely considered an anti-pattern due to hidden dependencies and testing difficulties.

Also known as: Singleton Pattern, Singleton Anti-Pattern, Global Instance

Description

The Singleton pattern restricts a class to a single instance and provides a global access point to it, typically via a static getInstance() method. The instance is created lazily on first access and returned on subsequent calls. While it seems simple and useful—database connections, configuration objects, and loggers are natural candidates—the pattern introduces serious architectural problems that have led the software engineering community to largely regard it as an anti-pattern.

The core problems with Singleton are: it introduces hidden global state (any code can access and modify the singleton without declaring it as a dependency, making data flow opaque), it tightly couples consumers to the concrete singleton class (violating the Dependency Inversion Principle), it makes unit testing extremely difficult (you cannot substitute a mock or stub without special tricks like dependency injection frameworks or module mocking), it prevents parallel test execution (tests share the singleton's mutable state), and it can cause subtle bugs in concurrent environments (lazy initialization race conditions, shared mutable state across threads or async operations).

The modern alternative to Singleton is dependency injection: instead of a class accessing DatabaseConnection.getInstance(), it receives the connection through its constructor. A DI container can be configured to provide the same instance to all consumers (the 'singleton' lifecycle scope in DI terminology), achieving the same single-instance guarantee without the coupling. In Node.js, module-level singletons (export const db = createConnection()) are common and slightly better than class-based singletons because they are easier to mock (jest.mock), but they still create hidden dependencies. The best practice is to use DI for any dependency that you might want to replace during testing.

Prompt Snippet

Refactor the existing DatabaseConnection.getInstance() singleton to use dependency injection: define an IDatabaseConnection interface, implement it with PostgresConnection using the pg Pool, and register it in the tsyringe container as a singleton-scoped dependency (container.registerSingleton<IDatabaseConnection>(TOKENS.Database, PostgresConnection)). This preserves the single-instance guarantee while enabling test substitution via container.register(TOKENS.Database, { useValue: mockDb }). For the logger, use module-level scoping (export const logger = pino({...})) with dependency injection for services that need testable logging behavior. Add an ESLint rule (no-restricted-syntax) to flag any static getInstance() method declarations in the codebase.

Tags

design-patternsanti-patternscreationaltestabilitygang-of-four