skip to content

Why is the Singleton pattern widely criticised, and how does injecting a single shared instance via dependency injection address those criticisms while still guaranteeing one instance?

level: seniorimportance: must knowfreq 70%

answer

  1. global mutable state → non-local reasoning
  2. hidden dependency → constructor lies
  3. static accessor is not a test seam
  4. state bleeds across tests → order-dependent flakes
  5. DI keeps uniqueness, drops global access

basics

~20 s

Singletons are global mutable state reached through a static call, so dependencies are invisible in signatures, state leaks between tests, and you cannot substitute a fake. Dependency injection keeps one shared instance but passes it in explicitly, so it stays visible and replaceable.

solid answer

~50 s

Three criticisms dominate. (1) **Global mutable state** — any code can mutate the instance, so behaviour depends on execution history, and reasoning becomes non-local. (2) **Hidden dependencies** — a class that calls `Clock.getInstance()` has a dependency that appears nowhere in its constructor or signature, so the type lies about what it needs, and the dependency graph is undiscoverable without grepping. (3) **Testability** — you cannot pass a fake, static state persists across tests in the same process, creating order-dependence and flakes under parallel execution, and any teardown hook is itself global mutable state. Dependency injection separates the two intents Singleton fuses: the composition root creates **exactly one** instance and injects it, so uniqueness is preserved while access becomes explicit. Constructors now document requirements, tests inject a stub, and lifetime becomes a per-environment configuration decision instead of something compiled into the class.

code

pseudocode · 18 lines
pseudocode
// Hidden dependency: signature says nothing about TaxPolicy
class Invoice {
    Invoice(lines)                 // constructor lies
    total() { return sum(lines) * TaxPolicy.getInstance().rate() }
}

// Explicit dependency: same ONE instance, now visible and substitutable
class Invoice {
    Invoice(lines, TaxPolicy policy)
    total() { return sum(lines) * policy.rate() }
}

// composition root creates exactly one
policy = new TaxPolicy(config)      // uniqueness is a wiring decision
app    = new App(new Invoice(lines, policy))

// test: no reset hook, no static mocking, parallel-safe
assert new Invoice(lines, FixedTaxPolicy(0.0)).total() == sum(lines)

go deeper

for a junior

Name the three problems — global mutable state, hidden dependencies, hard to test — and say DI passes the same single instance in explicitly.

for a middle

Give a concrete testing failure (state leaking between tests, no way to inject a fake) and show the constructor-injection alternative with the composition root creating one instance.

for a senior

Separate the two intents, note that DI fixes hidden/unsubstitutable but not shared-mutable, distinguish singleton scope from the pattern, and name the cases where a static facade is still justified.

for a principal

Treat it as a coupling and governance question: static accessors defeat module-boundary enforcement and make lifetime unchangeable when requirements shift to per-tenant/per-request. Describe the interface-plus-delegating-adapter migration and the review rule that keeps new statics out.

## Terms first - **Global mutable state**: data reachable from anywhere that can be changed by anyone. It defeats local reasoning — the behaviour of a function depends on what other code did earlier. - **Hidden (implicit) dependency**: a collaborator a class uses without receiving it. Nothing in the class's public shape reveals it. - **Composition root**: the single place near the program's entry point where the object graph is wired. Everything else receives its collaborators. - **Dependency injection (DI)**: passing collaborators in (usually via the constructor) rather than letting the class find them. A **DI container** automates that wiring; its **singleton scope** means "create one instance and reuse it for every injection point". - **Service locator / ambient context**: a global registry that code queries for its collaborators. This is Singleton wearing a hat — it fixes substitutability but keeps the hidden-dependency problem. ## Criticism 1 — global mutable state Once a singleton holds mutable fields, any component can change what every other component observes. Effects: - **Non-local reasoning**: to understand a method, you must know the history of every writer. - **Concurrency**: shared mutable state accessed from many threads needs a coherent locking or immutability story; because access is easy and invisible, this is exactly where it is usually forgotten. - **Temporal coupling**: correctness depends on "configure it before anyone reads it", enforced by nothing. ## Criticism 2 — hidden dependencies ``` class Invoice { total() { return sum(lines) * TaxPolicy.getInstance().rate() } } ``` `Invoice`'s constructor claims it needs only lines. In truth it needs a tax policy, an initialised one, initialised *before* this call. Consequences: the module dependency graph is not derivable from signatures, architectural boundaries (layering rules, module allow-lists) are trivially bypassed because a static call is invisible to most structural checks, and a change to the singleton's contract ripples to callers you cannot enumerate. ## Criticism 3 — testability A static accessor is not a seam. Concretely: - You cannot hand `Invoice` a fake tax policy; you must mutate the real global or reach for bytecode-level static mocking, which is slow and brittle. - **State bleeds across tests** in the same process: test A configures the singleton, test B silently depends on that, and the suite passes only in one order. - **Parallel tests** in one process share the instance, so isolation is impossible without ugly serialization. - A `reset()` for tests is production code existing only for tests — and it is itself global mutable state, so it can be called at the wrong time. - Tests that *must* run integration-style because of one static call are slower and flakier than they need to be. ## Criticism 4 (often forgotten) — lifetime is compiled in Because the class decides it is unique, you cannot later say "one per tenant", "one per request", or "two, pointed at different regions" without editing every call site. Requirements change; the pattern makes that change maximally expensive. ## What DI actually changes — and what it doesn't ``` // composition root — one instance, created once policy = new TaxPolicy(config) invoice = new Invoice(lines, policy) ``` - **Uniqueness: preserved.** Only the root constructs it, so there is exactly one for the application's lifetime. - **Global access point: removed.** Access requires having been given the reference, which restores the dependency to the type signature. - **Testability: solved structurally.** `new Invoice(lines, fakePolicy)` needs no framework, no reset hook, no static mocking, and is parallel-safe because each test builds its own graph. - **Global mutable state: only partly addressed.** A single injected instance shared by everyone is still shared mutable state if it has mutable fields. DI makes it *visible* and *replaceable*, not automatically safe. The real fix is immutability or a well-defined thread-safe contract. That last point is the senior-level nuance interviewers listen for: DI cures the *hidden* and *unsubstitutable* parts, not the *shared mutable* part. ## Terminology trap: "singleton scope" ≠ Singleton pattern When a framework registers a service as a singleton it means *lifetime = one per container*. The class stays an ordinary type with a public constructor, implements an interface, and can be constructed freely in tests. The GoF pattern instead compiles the constraint into the class. Saying "we use singletons everywhere" is ambiguous — clarify which you mean. ## When the classic pattern is still defensible - **Bootstrap-level facilities** that exist before any container: a logging facade, a crash handler, process-wide metrics. Even here, prefer a thin static facade that *delegates* to an injectable implementation, so the static is an adapter rather than the logic. - **Genuinely exclusive resources** where a second instance is a correctness bug (a device handle, a process-wide lock). - **Stateless, immutable values** (a null object, a comparator) where sharing is a harmless optimisation. ## Migration seam The standard refactoring: extract an interface, make the singleton's methods instance methods, keep `getInstance()` as a thin delegating adapter, then push constructor injection outward call site by call site, and finally delete the accessor. You never need a big-bang rewrite — the static accessor can coexist with injection during the transition.

  • Isn't a DI container's singleton-scoped service just a Singleton with extra steps?
    No — it keeps the uniqueness and drops the global access point. The class has a normal public constructor, usually implements an interface, and is handed to collaborators, so its dependency is visible in signatures and a test can construct it or a fake directly. The container also lets you change the lifetime (per-request, per-tenant) without touching call sites.
  • Does injecting one shared instance eliminate the global-mutable-state problem?
    Only partially. If the shared instance has mutable fields, everyone still observes each other's writes and it still needs a thread-safety story. DI makes the sharing explicit and the collaborator replaceable; making it *safe* requires immutability or an explicitly documented concurrent contract.
  • How would you incrementally remove Singletons from a large existing codebase?
    Extract an interface, convert the logic to instance methods, and leave `getInstance()` as a thin adapter that delegates to a single injected implementation. Then migrate call sites to constructor injection module by module — new code injects, legacy code keeps compiling — and delete the accessor once the last caller is gone. Prioritise the singletons that force integration tests or cause cross-test flakes.

A Singleton is an employee who quietly phones head office whenever they need a decision. Nothing on their job description mentions head office, so you can't tell who depends on it, you can't test them without a real head office on the line, and if someone changed head office's policy this morning, today's answers differ from yesterday's. Injection is writing 'reports to: this specific manager' into the job description — same single manager, now visible and swappable for a stand-in during a drill.

saying these in an interview costs you the question

  • "Singletons are fine because we only ever have one instance" — the objection is to the hidden global access, not to the instance count.
  • "You can just add a reset() method for tests" — production code existing only for tests, and itself global mutable state that can be invoked at the wrong moment.
  • Replacing Singleton with a service locator and calling it dependency injection; dependencies are still hidden inside method bodies.
  • Believing DI removes shared mutable state — it makes sharing explicit, not safe.
  • Using "singleton" for both the GoF pattern and a container's singleton lifetime scope without distinguishing them.
  • Claiming static-mocking libraries make singletons perfectly testable, ignoring the speed, brittleness, and cross-test-leakage costs.
  • Dogmatically banning all singletons, including immutable, stateless ones and pre-container bootstrap facilities.

context