Look at this snippet I found on day three of a job a few years ago.
It is a textbook Singleton. It is also the line of code that made the whole test suite feel slow, because every integration test spun up a real database connection through Config.getInstance().db and there was no way to swap it out without monkey-patching the static field. The code was not wrong. It was just unaware that someone, eventually, would need to inject a fake.
I have spent enough time in legacy codebases to form opinions about both Singleton and Factory Method, the two creational patterns that get the most over-application and the most under-application respectively. My stance for this whole article: Singleton is right surprisingly rarely (one in fifty cases I would call it correct), and Factory Method is right surprisingly often (one in five). Most teams have those numbers reversed, and that mismatch is the single biggest source of avoidable coupling I see in code reviews.
What Singleton actually is, stripped down
Singleton says: there is exactly one instance of this class, and there is a globally accessible way to get it. Two clauses, both load-bearing.
The "exactly one" clause is the one most introductions emphasize. It is the easy half. A private constructor and a static accessor get you there in any OO language. The "globally accessible" clause is the one that quietly does the damage. Global access means any file, anywhere, can reach the instance without declaring a dependency. That is convenience at first and chains of pain later: you cannot test in isolation, you cannot run two configurations side by side, you cannot move a piece of the code to a different process without dragging the whole singleton graph along.
The canonical defenses for Singleton are: a database connection pool, a logger, a configuration object, a feature-flag client. I will tell you what I do with each of those instead.
- Database connection pool: yes, the pool object is a singleton in the runtime sense (you really do want one connection pool per process), but I make that a property of an explicitly constructed
ApporContainerobject that I pass into request handlers. The lifetime is still process-wide, but the access is not global. In Node, that often means the pool is constructed inindex.ts, attached to a request-scoped context object, and reached through that context. - Logger: a module-level export from a
logger.tsfile with a top-level instance. Same effect, no class, nogetInstance(), trivially mockable in tests because module imports can be stubbed by Jest, vitest, or any equivalent. I have never regretted picking a module-level logger over a Singleton class. - Configuration: parse it once at startup into a
Configobject and pass it into the things that need it. Tests construct their ownConfigwith whatever overrides matter. The whole point of having aConfigvalue is that it is a value, and values are passed. - Feature-flag client: a real injected dependency. The whole point of feature flags is that the implementation behind them might change (local file, LaunchDarkly, an in-memory map for tests, a fake that returns a deterministic flag set in a unit test). Hiding it behind a singleton makes the swap painful.
The pattern I am pushing back on is not "there is one of these per process". That is fine and often necessary. The pattern I am pushing back on is the getInstance() accessor that lets every line of code grab the thing without anyone tracking who depends on it. Once that habit is in the codebase, every new class that someone considers "infrastructure" gets the same treatment, and the implicit dependency graph balloons. Within a year you have a test suite that takes five minutes to start because every test transitively constructs the world.
When Singleton actually earns its keep
There are real cases. The two I have lived with that I would still defend in a code review:
- A multi-tenant in-process cache where the cache identity itself is the contract. If two pieces of code hold different
Cachereferences, the cache silently splits and you ship a bug where one writer caches a value and another reader never sees it. Forcing one accessor protects the invariant. The Singleton here is doing real correctness work. - A registry that other code must mutate at import time. The classic example is a route registry where decorators or annotations push routes onto a global list. There has to be exactly one list, and the import time of the modules registering routes is fixed by Python or JS module loading, not by your test setup. Trying to inject this is awkward and tends to lose entries.
Notice both cases share a property: there is something correctness-critical about identity, not just convenience. If your candidate Singleton is convenience-flavored ("it would be annoying to pass the logger everywhere"), pass the logger anyway, or use a module-level export. Save the pattern for the cases where two instances would be a real bug.
One nuance for languages with multiple isolates or workers: even a "correct" Singleton is only one-per-isolate. Web workers, Node worker_threads, and Python multiprocessing each get their own copy. Code that depends on a process-wide identity invariant breaks the moment someone introduces concurrency. That is a separate failure mode worth noting in any decision to use the pattern.
Factory Method: the one I reach for more often than I expected
Factory Method says: instead of calling new Foo(...) in your business logic, call a method (createFoo or similar) that decides which concrete class to instantiate. The caller does not know whether it gets a LocalFoo, a RemoteFoo, or a MockFoo. It just gets a Foo.
The shortest version that earns its keep:
Three things I want to call out about that snippet, because they are exactly where teams I have worked with got it wrong.
First, the switch is fine. Some people will tell you a Factory Method has to use polymorphism and a class hierarchy of factories. That is the GoF book version, and it is overkill for almost every real codebase. A function that returns the right concrete type is a factory. Do not let textbook fidelity push you into building four extra classes you do not need; in dynamic-typed languages especially, the function form is the idiomatic one.
Second, the function is the only place that knows about Stripe specifically, or Paddle specifically. The rest of the codebase only sees the PaymentProvider interface. That is the actual win. When the third payment provider arrives next quarter, exactly one file changes; everything else recompiles and keeps running.
Third, the test environment branch is the same shape as the production branch. There is no if (process.env.NODE_ENV === 'test') scattered through your business code. The factory absorbs that branching into a single, well-named decision. I would call this the most underrated benefit: factories give you a single seam where environment-specific behavior is allowed to live.
A second flavor I use almost as often is the registry-backed factory, which scales when the set of concrete types is open-ended:
This is still Factory Method. The difference is that adding a fourth or tenth job handler does not require editing createJobHandler; it just calls registerJob from its own module. The cost is that the wiring is no longer statically inspectable, so I usually pair it with a startup-time assertion that all expected job names are registered.
The honest comparison
Here is how I tell my team to choose between the two patterns. The shape of the question matters more than the names.
If I had to summarize the trade-off in one line each: Singleton trades testability for ergonomic access; Factory Method trades a small amount of ceremony for a one-place-to-edit guarantee. Those are not the same kind of trade. Singleton's win shrinks every time the team grows because the implicit access becomes a discoverability problem. Factory Method's win grows every time a new concrete type lands.
Where the Factory Method goes wrong
The pattern can rot too. The three failure modes I see most often:
- The factory grows a parameter for every concrete class. When
createPaymentProvider(env, region, currency, isHighRisk, retryPolicy)shows up in a code review, the factory is no longer a factory. It has become a tangled constructor with switching logic. The fix is to push the choice up: have one factory that picks the broad family, then let each provider take its own narrow config object. - The interface becomes a least-common-denominator. Every method on
PaymentProviderhas to be implementable by every provider, including the fake. If you find yourself adding a method that only Stripe supports (say, an exotic dispute API), that is a sign your interface is now wrong. Either widen the abstraction (chargereturns a richer result type that some providers populate more fully than others) or accept that some operations live outside the interface and call Stripe directly with a runtime typecheck. - The factory and the consumer are in the same module. When that happens the abstraction stops doing its job, because the consumer can see the concrete types and starts importing them directly. The factory is supposed to be the only file in the codebase that names
StripeProvider. If five other files name it too, the seam is gone.
Rules I actually apply on PRs
When I review code that introduces a Singleton or a Factory Method, here is what I check:
- For a proposed Singleton: would a module-level
constplus a normal function export do the same job? If yes, prefer that. The only exception is when identity correctness is at stake (the registry / shared-cache cases above). - For a proposed Factory Method: is there at least one alternative concrete class today, or a planned one in the next quarter? If neither, do not introduce the abstraction. Refactor to a factory the day the second implementation lands. The first implementation does not need an interface; the YAGNI principle applies even to creational patterns.
- For both: does the test code construct the dependency directly, or does it have to reach through a static accessor or a global registry? If it has to reach through a static accessor, the design is not testable, regardless of which pattern you call it.
A field-tested rule of thumb
When I sense that I am about to write getInstance(), I pause and ask: am I doing this because there genuinely must be one instance, or because passing it as an argument feels tedious right now? Nine times out of ten the answer is the second one, and the right move is to construct the thing in main() (or the framework equivalent) and pass it down. The keystrokes I save by typing Config.getInstance() are real, but they are paid back at testing time with interest.
When I sense that I am about to write if (provider === 'stripe') { ... } else if (provider === 'paddle') { ... } in business logic, I pause and ask: should this branching live somewhere else? Almost always yes, and that somewhere else is a factory function in a single file. If the branching is allowed to spread, every new provider becomes a fifteen-file change.
Those two pauses are most of the value of these patterns. The names matter less than the discipline of stopping at those two moments and asking the question. The next time you find yourself reaching for either one, the question to answer is not "which pattern fits" but "is there a real correctness or extensibility reason here, or am I just optimizing for typing comfort". The pattern follows from the answer.
