Skip to content

Singleton and Its Alternatives

Why the most famous pattern is the most regretted, what the legitimate need underneath it is, and how to get one instance without global reach.

Beginner17 minUpdated 8 August 2026

Singleton is the pattern everyone learns first and the one most likely to be argued about in a code review. The disagreement is usually productive once both sides notice they are talking about different things: the requirement "exactly one instance" is often legitimate, and the mechanism "globally reachable static accessor" almost never is.

Singleton bundles the two together. Separating them is the whole topic.

The two claims inside one pattern

Conceptual diagramSingletonClaim 1: exactly one instance existsClaim 2: it is globally reachablefrom anywhere, without being passedOften legitimateconnection pools, caches, clocksAlmost never justifiedhidden dependencies, shared test stateCreate one at start-upand inject it
Diagram, as text
  • Singleton leads to Claim 1: exactly one instance exists
  • Singleton leads to Claim 2: it is globally reachable from anywhere, without being passed
  • Claim 1: exactly one instance exists leads to Often legitimate connection pools, caches, clocks
  • Claim 2: it is globally reachable from anywhere, without being passed leads to Almost never justified hidden dependencies, shared test state
  • Often legitimate connection pools, caches, clocks leads to Create one at start-up and inject it
  • Almost never justified hidden dependencies, shared test state leads to Create one at start-up and inject it

Claim one is a lifecycle requirement. One connection pool, one metrics registry, one configuration object. Creating a second would be wasteful or wrong.

Claim two is an access mechanism. Any code, anywhere, can reach it by naming the class. Nothing in a constructor or a signature reveals the dependency.

Almost every problem attributed to Singleton comes from claim two.

What claim two costs

class OrderService {
  submit(order) {
    Database.getInstance().save(order);       // dependency 1, invisible
    Metrics.getInstance().increment('orders'); // dependency 2, invisible
    Clock.getInstance().now();                 // dependency 3, invisible
  }
}

The dependencies are invisible. new OrderService() takes nothing, so a reader believes it needs nothing. Three collaborators are hidden inside method bodies, and the only way to find them is to read every line.

Tests share state. Whatever the singleton holds persists between tests in the same process. They pass alone and fail together, and the failures are order-dependent — the most expensive kind to diagnose.

Substitution is difficult by design. Testing the failure path needs a database that throws. With injection you pass one. With a singleton you need a static setter, which is itself global mutable state, and one test forgetting to reset it corrupts the rest of the suite.

Concurrency gets a shared mutable rendezvous point. Any mutable field on a singleton is touched by every thread, and nothing in the call sites suggests that.

Getting one instance without global reach

Create it once, at the composition root, and pass it down:

// main.js — the composition root, run once
const db = new Database(config.databaseUrl);
const metrics = new MetricsRegistry();
const clock = new SystemClock();

const orderService = new OrderService(db, metrics, clock);
class OrderService {
  constructor(db, metrics, clock) { /* … */ }   // the complete dependency list
}

This is a singleton in effect and dependency injection in structure. Exactly one Database exists, because exactly one was created. No static accessor exists, so:

  • The dependencies are declared where a reader and a compiler can both see them.
  • A test constructs new OrderService(fakeDb, noopMetrics, fixedClock) with no global setup and no teardown.
  • Nothing can reach the database from an arbitrary corner of the codebase, so the set of code that touches it is knowable.

The uniqueness was never the problem. Discipline at one place in the code gives you uniqueness for free.

The cases where a module-level instance is fine

Pragmatism matters more than purity, and some singletons cost nothing:

Stateless helpers. A pure formatter or validator with no mutable fields. Sharing it has no observable consequences — though a plain function is usually simpler still.

Truly process-wide facilities with no test dependence. A logger is the common example, and even then, code that asserts on log output wants injection.

Language-level module caching. In most module systems, a module's top-level state is already a per-process singleton. Exporting a constructed instance is idiomatic and carries the same caveats — it is fine until something needs to substitute it.

The distinguishing question is: does it hold mutable state, and does any test need to control it? Two noes make it harmless. One yes makes it the thing people complain about.

The double-checked locking distraction

Textbook treatments spend most of their space on making lazy initialisation thread-safe — the double-checked locking idiom, and the reasons the naive version is broken without the right memory-visibility guarantees.

It is worth knowing that the naive version is genuinely broken: without a happens-before relationship, one thread can observe a non-null reference to an object whose fields are not yet visible. But it is worth noticing that the problem only exists because the instance is created lazily on first use from arbitrary threads. Construct it eagerly at start-up, on one thread, before any worker exists, and the entire category of problem is gone. Lazy initialisation is an optimisation; pay for it only when start-up cost is measured and real.

Check your understanding

Common mistakes

Treating "we need one instance" as an argument for a static accessor. It is an argument for constructing one instance.

Hiding collaborators inside method bodies. The constructor stops being the dependency list, and nothing can be substituted without global mutation.

Adding a static setter for tests. That is global mutable state introduced to work around global mutable state, and one missing reset poisons the suite.

Putting mutable state on a singleton without thinking about threads. Every thread shares it, and no call site hints at that.

Lazily initialising by default. It creates a thread-safety problem to solve a start-up cost that was never measured.

Making things singletons because there is currently one. One tenant, one region, one currency — until there is a second, and the assumption is now spread across the codebase rather than confined to one construction site.

Practice

A legacy codebase has `Database.getInstance()` called from 200 places. You cannot rewrite it all at once. Describe a migration that improves things incrementally, and say how you would know it is working.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

The goal is not to delete the accessor on day one. It is to stop it growing and to make new code testable.

Show a strong answer

Reverse the ownership first, and change nothing else. Construct the Database at the composition root and have getInstance() return that instance instead of creating it.

  • This is a one-file change with no call-site edits, so it is easy to review and to revert.
  • It immediately kills the lazy-initialisation race, and it means configuration is read at start-up where a bad value fails fast.
  • Crucially, it establishes that there is now a place where the object is created — every later step needs that place to exist.

Then stop the bleeding. Add a lint rule forbidding new calls to getInstance(), with the existing 200 grandfathered. Without this, the migration races against new code and never finishes; this is the step that decides whether the effort succeeds.

Then migrate by seam, not by call site. Pick a class, add the database as a constructor parameter, and convert its internal calls. Its callers now have to supply it, which pushes the requirement up towards the root one layer at a time.

  • Do the classes you are already changing for other reasons. A migration that requires dedicated time competes with feature work and loses.
  • Prioritise anything with an untested failure path — those are the classes where injection immediately buys a test you could not write before.

How I would know it is working:

  • The count of getInstance() call sites, tracked over time. One number, visible, monotonically decreasing. If it plateaus for a month the migration has stalled and needs a decision rather than more hope.
  • The number of tests that need global setup or teardown, which should fall alongside it. This is the outcome that actually matters — the call-site count is a proxy for it.
  • New tests written against the migrated classes without touching global state. If people are still reaching for the static accessor in tests, injection has not really landed.

What I would not do: a big-bang rewrite. Two hundred call sites in one change cannot be reviewed properly, cannot be bisected when something breaks, and will sit unmerged behind other work until it conflicts.

A colleague argues that a logger is fine as a singleton because 'it is stateless and every class needs it, and injecting it everywhere is noise'. Do you agree? Give the conditions under which each of you is right.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Test the two claims separately: is it stateless, and does anything need to substitute it?

Show a strong answer

I largely agree, with a boundary worth naming.

Where the argument holds. A logger that writes to a sink configured once at start-up is effectively a stateless facility. Injecting it into every constructor adds a parameter that is never varied, and a parameter that is always the same value carries no information — it is genuine noise. Log statements are also cross-cutting: they appear in code where the collaborator list is otherwise meaningfully short, and adding a logger to that list dilutes it.

Where it breaks down, and this is the real boundary: when logging is behaviour you assert on.

  • Audit logging. "Every permission change is recorded" is a requirement with a test. That test needs to capture what was written, which means substituting the sink.
  • Structured logs consumed by alerting. If a dashboard depends on a field being present, its absence is a defect, and defects need tests.
  • Log-level decisions inside logic. Code branching on whether debug logging is enabled has made the logger a collaborator, not a facility.

The condition that decides it: does any test need to control or observe it? If no, singleton is fine and injection is ceremony. If yes, it is a collaborator wearing a facility's clothes and should be passed in.

The resolution I would actually propose, which gets both:

  • Keep the global logger for incidental logging — the "what happened" lines that exist for humans reading production output.
  • Inject a narrow interface for logging that is a requirement — an AuditLog with named operations, not a general logger. This is better than a substitutable logger anyway, because auditLog.permissionChanged(user, role) states intent, and the test asserts on a domain event rather than on a string that a formatting change will break.

The general form of that answer: when the same mechanism serves both an incidental need and a specified requirement, split it by requirement rather than arguing about the mechanism.

Key takeaways

  • Singleton bundles a lifecycle claim (exactly one) with an access mechanism (globally reachable). The second causes the damage.
  • Global reach hides dependencies from constructors, makes substitution require static mutation, and leaks state between tests.
  • Create one instance at the composition root and inject it — a singleton in effect, dependency injection in structure.
  • Order-dependent test failures are the signature symptom of shared mutable state, most often a singleton.
  • A module-level instance is fine when it is stateless and nothing needs to substitute it; one mutable field or one test that must control it changes the answer.
  • Construct eagerly at start-up. Lazy initialisation invents a thread-safety problem to save an unmeasured cost.
  • Migrate legacy accessors by inverting ownership first, then forbidding new calls, then converting class by class.

Further reading

  • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
  • Mark Seemann and Steven van Deursen, Dependency Injection Principles, Practices, and Patterns (Manning, 2019).
  • Brian Goetz and others, Java Concurrency in Practice (Addison-Wesley, 2006) — safe publication and lazy initialisation.
  • Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — breaking static dependencies.
  • Steve Freeman and Nat Pryce, Growing Object-Oriented Software, Guided by Tests (Addison-Wesley, 2009).
  • Miško Hevery, "Singletons are Pathological Liars" (Google Testing Blog, 2008).

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonObject Pool
Lessons in this track