Singleton Pitfalls & Alternatives
The problem with Singleton isn't always "one instance" — that's often correct.
Introduction
The problem with Singleton isn't always "one instance" — that's often correct. The problem is the static access path: dependencies become invisible, tests unreliable, and refactors dangerous. This lesson catalogs what senior engineers reach for instead: DI containers, module-scoped instances, factory functions, parameter passing, and why Monostate and Service Locator rarely earn their keep.
The story
A consulting team rescued a checkout codebase with 14 singletons reaching into each other. Migrating to a DI container surfaced four circular dependencies masked by static access. Uniqueness didn't change — Spring still kept one bean per type. Dependencies became visible.
The business problem
Static Singletons scale poorly across teams and test suites:
- Invisible graph:
X.getInstance()inside methods hides deps from signatures and tests. - Cyclic init: singletons initializing singletons — failures at first call, not startup.
- Test reset proliferation: every suite needs reset hooks; someone always forgets.
- Mocking cost: PowerMock/bytecode tricks instead of constructor injection.
The problem teams faced
Symptoms that static access — not uniqueness — is the real problem:
- Cannot unit-test a class without the real singleton's side effects.
- Refactoring requires grep for getInstance() across the entire repo.
- Startup order bugs appear only under load or in specific test order.
- New engineers can't draw the dependency graph from code.
Understanding the topic
Intent: Preserve "one instance" where needed while replacing hidden global access with explicit wiring.
- Composition root — single bootstrap place where the object graph is built.
- Container — Spring, NestJS, manual factory — enforces lifecycle scopes.
- Module scope (TS/Node) —
export const client = new Client()— idiomatic one-instance without class ceremony. - Constructor injection — consumers declare dependencies; never reach into globals.
Internal architecture
DI container wiring:
Bootstrap│▼┌──────────────┐│ Container │ registers Logger, Config, PaymentClient (singleton)└──────┬───────┘│ injects▼┌──────────────┐ ┌──────────────┐│ OrderService │ ──▶ │ PaymentClient│└──────────────┘ └──────────────┘
Visual explanation
Three diagrams map pitfalls to alternatives:
Informative example
Before / after — 14 singletons to DI graph:
// ❌ Static web — circular deps invisibleclass CheckoutService {complete() {TaxCalculator.getInstance().apply();PaymentGateway.getInstance().charge();AuditLog.getInstance().write();}}// ✅ Explicit graph — container detects cycles at bootclass CheckoutService {constructor(private tax: TaxCalculator,private payments: PaymentGateway,private audit: AuditLog,) {}async complete(cart: Cart) {const total = this.tax.apply(cart);await this.payments.charge(total);await this.audit.write({ cartId: cart.id, total });}}// Module-scoped (TS) — same uniqueness, no getInstance()// payment-gateway.tsexport const paymentGateway = createStripeClient(config);
Execution workflow
Register
Composition root binds interface → implementation with scope.
Real-world use
Spring @Component, NestJS @Injectable, .NET built-in DI, Angular hierarchical injector, Guice. Each preserves one instance where configured while keeping access explicit. Node's module cache is the idiomatic "singleton" for scripts and libraries.
Production case study
Payments monolith DI migration:
- Scenario: 23 hand-rolled singletons, 18s startup, 9% flaky integration tests.
- Decision: Wrap each in Spring @Configuration; one module per sprint.
- Outcome: Startup 4s, flakiness under 1%, four circular deps fixed at boot.
- Lesson: DI surfaces complexity that static access hid — it doesn't add it.
Trade-offs
- Pro: explicit deps, easy mocking, cycle detection, no test pollution.
- Con: composition root discipline required; unfamiliar teams feel slower initially.
- Con: tiny scripts may not justify container ceremony.
Decision framework
- Use DI when any class has 2+ collaborators or any tests exist.
- Use module scope in Node when one export serves the whole process.
- Avoid Service Locator — lookup hides dependencies like statics do.
- Avoid Monostate unless you can defend the surprise to every new hire.
Best practices
- Constructor injection only in production code — dependencies visible and immutable.
- One composition root per entry point (web, CLI, worker).
- Strangler migration: interface → ctor param with default → remove static.
Anti-patterns to avoid
- Service Locator: locator.get(Payments.class) — hidden lookup, same test pain.
- Monostate: new X() behaves like Singleton via shared static state — spooky mutations.
- Reset hooks on every singleton instead of fixing the access path.
Common mistakes
- Migrating all singletons in one big bang — use one module per sprint.
- Keeping getInstance() as thin delegate forever — finish the migration.
- Field injection in Spring — hides dependencies; prefer constructor.
Debugging tips
- Grep getInstance() count trending down — track migration progress.
- Container startup failure with cycle message → draw the graph on a whiteboard.
- Flaky tests → list singletons with mutable state; inject fakes first.
Optimization strategies
- Lazy bean init when startup time matters — container resolves on first use.
- Compile-time DI (Dagger, Spring AOT) for hot paths that can't tolerate reflection.
Common misconceptions
- DI ≠ no singletons. DI gives singleton scope without static access.
- Module export ≠ anti-pattern. In Node it's the idiomatic one-instance mechanism.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1IntermediateQuestionWhy is DI 'Singleton done right'?+
Answer
Follow-up
2AdvancedQuestionWhen is Service Locator an antipattern?+
Answer
Follow-up
3IntermediateQuestionModule-level singleton in TypeScript vs class Singleton?+
Answer
Follow-up
4AdvancedQuestionMigration path from static Singleton?+
Answer
Follow-up
Summary
You can name Singleton pitfalls, pick DI/module-scope alternatives, and describe a strangler migration. Tell the 14-singleton checkout rescue — if you can do that, you own this lesson.