Singleton is the most criticized creational pattern. What problems does the classic implementation cause, and how does a dependency-injection container provide the same single-instance guarantee without them?
answer
- single instance (ok) vs global access point (harmful)
- hidden dependencies, untestable, cross-test static leakage
- lazy init races; use holder/enum/object/sync.Once
- one per class loader / per pod, never distributed
- DI: singleton scope + constructor injection = same guarantee, explicit
basics
~20 sClassic Singleton hides a global variable behind a static accessor: callers reach for it directly, so dependencies are invisible, tests can't substitute it, and shared mutable state plus lazy initialization creates concurrency problems. A DI container keeps one instance per scope but injects it, so dependencies stay explicit and replaceable.
solid answer
~60 sThe classic Singleton conflates two things: **a lifetime constraint** (exactly one instance) and **a global access point**. The lifetime constraint is often legitimate; the global access is what causes the damage. Consequences: call sites depend on a concrete class via a static call, so the dependency is invisible in the API and cannot be substituted in tests; static state survives across tests, producing order-dependent failures; the instance becomes shared mutable state needing thread-safe access; lazy initialization needs correct double-checked locking or a language-provided guarantee (static initializer, holder idiom, enum, module-level object); it violates the Single Responsibility Principle by owning both its job and its own lifecycle; subclassing and configuration become awkward; and "one per process" is wrong the moment there are multiple class loaders, multiple JVMs/pods, or serverless instances. A DI container keeps the useful half: register the type with singleton scope and the container creates exactly one instance per container, injecting it through constructors. Dependencies stay explicit, a test can supply a fake, scope can change to per-request without touching call sites, and the container handles ordered initialization and shutdown.
code
pseudocode · 11 lines// Hidden dependency: nothing in the signature reveals the Registry
class OrderService {
fun place(o: Order) = Registry.getInstance().rateFor(o) // untestable
}
// Explicit dependency; one instance still guaranteed by the container's scope
class OrderService(private val rates: RateSource) {
fun place(o: Order) = rates.rateFor(o)
}
container.register<RateSource>(Registry(), scope = SINGLETON)
// test: OrderService(FakeRates()) — no static state, no reset neededgo deeper
Say it guarantees one instance with a global access point, and that the global access makes code hard to test because you cannot substitute a fake.
Add hidden dependencies, cross-test static state, thread-safe lazy initialization idioms, and that DI's singleton scope with constructor injection gives one instance without the static accessor.
Separate the lifetime guarantee from the access mechanism, cover initialization ordering, scope mismatch when injecting shorter-lived objects, class-loader/replica scoping, and a concrete migration path off a static accessor.
Discuss it as a governance issue: global mutable state as an architectural coupling channel, lifetime as a deployment concern (per replica, not per system), how to prevent shared singletons from becoming god objects, and what genuinely needs external coordination.
## What the pattern actually says GoF Singleton: *"Ensure a class has only one instance, and provide a global point of access to it."* Two clauses. Interviews reward candidates who notice that most Singleton criticism targets the second clause, not the first. Classic shape: ``` class Registry { private static Registry instance; private Registry() {} public static Registry getInstance() { if (instance == null) instance = new Registry(); // not thread-safe return instance; } } // call site anywhere: Registry.getInstance().lookup("x") ``` ## The problems, one by one **1. Hidden dependencies.** `OrderService`'s constructor may take nothing, yet internally call `Registry.getInstance()`. The API lies about what the class needs. You discover the dependency only by reading the body. **2. Untestable in isolation.** You cannot pass a stub because nothing is passed. Workarounds — a static `setInstanceForTesting`, reflection, bytecode-rewriting mock frameworks — are all admissions that the design leaked. **3. Test pollution and order dependence.** Static state persists across tests in the same process. Test A mutates the singleton; test B passes alone and fails in the suite. Parallel test execution makes it worse. **4. Concurrency.** One instance shared by all threads means every mutable field needs synchronization. And the lazy `if (instance == null)` check above is a classic race: two threads can both construct. Correct options: eager static initialization, the holder-class idiom, an `enum` singleton (Java), a language-level `object` (Kotlin/Scala) or module-level instance (Python/Go `sync.Once`), or a properly written double-checked lock with a volatile/atomic field. Naive double-checked locking without the memory barrier is broken under most memory models. **5. Lifecycle coupling / SRP violation.** The class owns both its behavior and the policy of how many exist. Changing the policy (now we need one per tenant) means editing the class and every call site. **6. Rigid substitution.** The static accessor names a concrete class, defeating dependency inversion; you cannot swap implementations by configuration, and you cannot easily subclass or parameterize construction. **7. Ordering and shutdown.** Lazily created singletons initialize in whatever order the first call happens, which makes startup order emergent rather than declared, and provides no orderly teardown hook. **8. "One" is scoped by something you did not choose.** One per class loader, per process, per pod, per Lambda instance. In a horizontally scaled deployment, "one instance" is one *per replica* — so a Singleton is never a distributed lock, cache coherence mechanism, or ID generator. **9. Uncontrolled scope creep.** Because it is globally reachable, a Singleton accumulates unrelated responsibilities and becomes a god object, and it creates invisible coupling between modules that both touch it. ## When a single instance is genuinely right - Objects that own a scarce external resource: connection pools, thread pools, caches, metrics registries. - Expensive, immutable, shareable state: compiled regexes, loaded configuration, parser tables. - Stateless service objects where one instance is simply sufficient. Note the pattern: single instance is fine; *global static access* is the liability. Immutable or internally-synchronized singletons are far less dangerous than mutable ones. ## What a DI container does instead Register the type once with a **singleton/application scope**; the container constructs one instance per container and injects it wherever the type is requested (usually via constructor parameters). What that changes: | Concern | Classic Singleton | DI singleton scope | |---|---|---| | Dependency visibility | hidden in the body | explicit constructor parameter | | Test substitution | requires static hacks | pass a fake to the constructor, or override the registration | | Test isolation | static state leaks between tests | a fresh container per test | | Changing the lifetime | edit the class + call sites | change one registration to per-request/per-tenant | | Init order and shutdown | emergent, first-call order | container resolves the graph, calls lifecycle hooks | | Coupling | concrete class named at call sites | callers depend on an interface | Caveats to mention: the container's singleton is one **per container**, not per process — two containers (or two class loaders) means two instances. Injecting a shorter-lived object into a singleton is a **scope-mismatch** bug (a per-request object captured forever); the standard fixes are provider/factory injection or a proxy. And DI does not make shared mutable state thread-safe — a singleton-scoped bean with mutable fields still needs synchronization or should be made stateless/immutable. ## Practical remediation of an existing Singleton 1. Extract an interface for its behavior. 2. Change consumers to accept that interface via their constructors. 3. Keep `getInstance()` temporarily as the composition-root wiring, then delete it once the container or a manual composition root supplies the instance. 4. Where an instance truly must be process-wide and creation must be lazy and safe, use the language's built-in guarantee (holder idiom, enum, `object`, `sync.Once`) rather than hand-rolled locking.
- Is a container-managed singleton-scoped object still the Singleton pattern?It satisfies the lifetime half — one instance per container — but not the pattern's second clause, the global access point. The class itself has no static accessor, does not enforce its own uniqueness, and can be instantiated normally in a test. That separation of lifetime policy from the class is the whole improvement.
- Your singleton-scoped service needs data from the current HTTP request. What goes wrong and how do you fix it?A scope mismatch: injecting a request-scoped object into a longer-lived singleton captures the very first request's object forever, leaking data across requests. Fixes: inject a provider/factory and resolve per call, use a scoped proxy that delegates to the current request's instance, or simply pass the request-specific data as a method parameter.
- Why can't a Singleton enforce uniqueness across a horizontally scaled service?Its scope is one instance per class loader/process. Every replica, pod, or serverless instance has its own. Anything needing global uniqueness — a distributed lock, a sequence generator, a coherent cache — requires external coordination (a database constraint, Redis, ZooKeeper/etcd, a consensus service).
Classic Singleton is a shared tool bolted to the workshop wall that anyone may grab without telling anyone. A DI-scoped singleton is the same single tool, but handed to each worker at the start of the shift — you can see who has it, and you can hand out a replica for practice.
saying these in an interview costs you the question
- Describing Singleton's purpose as 'global access to an object' and treating the single-instance guarantee as secondary
- Using a Singleton as a distributed lock, global counter, or cross-replica cache
- Hand-rolled double-checked locking without a volatile/atomic field, presented as correct
- Saying Singletons are always evil — a stateless or immutable single instance obtained by injection is perfectly reasonable
- Claiming DI eliminates the concurrency concern — singleton-scoped mutable state still needs synchronization
- Adding a static `setInstanceForTests` and calling the design testable