skip to content

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?

level: seniorimportance: must knowfreq 62%

answer

  1. single instance (ok) vs global access point (harmful)
  2. hidden dependencies, untestable, cross-test static leakage
  3. lazy init races; use holder/enum/object/sync.Once
  4. one per class loader / per pod, never distributed
  5. DI: singleton scope + constructor injection = same guarantee, explicit

basics

~20 s

Classic 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 s

The 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
pseudocode
// 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 needed

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context