Design Patterns Tutorial 0/70 lessons ~6 min read Lesson 17

    Singleton Pitfalls & Alternatives

    The problem with Singleton isn't always "one instance" — that's often correct.

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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:

    text
    Bootstrap
    ┌──────────────┐
    │ Container │ registers Logger, Config, PaymentClient (singleton)
    └──────┬───────┘
    │ injects
    ┌──────────────┐ ┌──────────────┐
    │ OrderService │ ──▶ │ PaymentClient│
    └──────────────┘ └──────────────┘

    Visual explanation

    Three diagrams map pitfalls to alternatives:

    Static vs explicit dependency
    OrderService
    Business logic
    getInstance()
    Hidden inside method
    Constructor inject
    Logger in signature
    Test swaps fake
    No bytecode magic
    Visibility beats cleverness — signatures document the graph.
    Migration path
    Extract interface
    Singleton implements it
    Add ctor param
    Default to singleton
    Remove static calls
    One file per sprint
    Delete getInstance()
    When grep is clean
    Strangler migration — production unchanged until last static call dies.
    Alternative picker
    Need one instance?
    Yes
    Framework app?
    DI singleton scope
    Node module?
    Module export
    Tiny script?
    Pass params
    Service Locator and Monostate are recognition patterns — not defaults.

    Informative example

    Before / after — 14 singletons to DI graph:

    ts
    // ❌ Static web — circular deps invisible
    class CheckoutService {
    complete() {
    TaxCalculator.getInstance().apply();
    PaymentGateway.getInstance().charge();
    AuditLog.getInstance().write();
    }
    }
    // ✅ Explicit graph — container detects cycles at boot
    class 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.ts
    export const paymentGateway = createStripeClient(config);

    Execution workflow

    1Replace static access with DI
    1 / 4

    Register

    Composition root binds interface → implementation with scope.

    Singleton for infra, request for per-call state.

    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.

    4 questions
    1IntermediateQuestionWhy is DI 'Singleton done right'?+

    Answer

    Both produce one shared instance, but DI keeps the dependency in the constructor — testable, refactorable, visible.

    Follow-up

    What does container give that static field cannot?
    2AdvancedQuestionWhen is Service Locator an antipattern?+

    Answer

    When consumers look up deps instead of declaring them — same hidden dependency as getInstance().

    Follow-up

    How does constructor injection fix this?
    3IntermediateQuestionModule-level singleton in TypeScript vs class Singleton?+

    Answer

    Node caches modules per process — export const x = new X() is already one instance without ceremony. Wrap in factory for DI when tests need swaps.

    Follow-up

    Serverless cold starts?
    4AdvancedQuestionMigration path from static Singleton?+

    Answer

    Extract interface → add ctor param defaulting to singleton → remove static calls file by file → delete getInstance when grep clean.

    Follow-up

    Tool to find call sites?

    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.

    Ready to mark this lesson complete?Track your journey across the entire course.