skip to content

You inherit a large codebase where dozens of classes call static Singleton accessors, and the test suite is slow and order-dependent as a result. How do you plan and sequence the removal of that coupling without a big-bang rewrite?

level: principalimportance: nice to knowfreq 28%

answer

  1. seams, not purity
  2. interface + instance methods; accessor becomes an adapter
  3. static call migrates outward to the composition root
  4. clock/random/env first — biggest determinism payoff
  5. delete accessor, then enforce with an arch/lint rule

basics

~20 s

Don't rewrite everything. Give each singleton an interface and instance methods, keep the static accessor as a thin wrapper that delegates to one injected instance, then move call sites to constructor injection module by module, starting with the ones causing test pain. Delete the accessor last.

solid answer

~50 s

Treat it as an incremental seam-introduction programme, prioritised by pain rather than purity. Step 1: inventory the singletons and rank them by how much they hurt — mutable state, cross-test leakage, forcing integration tests, blocking parallelism. Step 2: for each, extract an interface and convert the logic to instance methods, leaving `getInstance()` as a delegating adapter over a single instance held in one place. That is a behaviour-preserving change that compiles everywhere and unblocks substitution immediately. Step 3: build a composition root if none exists, and register the instance there with singleton lifetime. Step 4: migrate call sites to constructor injection outward from the leaves, one module per change, adding characterization tests first where behaviour is unclear. Step 5: once the last caller is gone, delete the accessor and enforce the rule with an automated check so new statics cannot appear. Track progress with a measurable signal — unit-test share, suite runtime, parallel-safe test count — so the work stays justifiable.

code

pseudocode · 18 lines
pseudocode
// BEFORE: static call buried in the method
class Order { total() { return net * TaxPolicy.getInstance().rate() } }

// STEP 2: seam — interface + instance impl; accessor becomes a delegating bridge
interface TaxPolicy { rate() }
class TaxPolicyHolder { static TaxPolicy current; static getInstance() { return current } }

// STEP 4: dependency becomes explicit; unmigrated callers pass the bridge
class Order {
    Order(net, TaxPolicy policy)
    total() { return net * policy.rate() }
}
new Order(net, TaxPolicyHolder.getInstance())   // static call has moved OUT one level

// ...repeat until only the composition root constructs it:
policy = new DefaultTaxPolicy(config)           // exactly one instance, created once
app    = new App(policy)
// STEP 5: delete TaxPolicyHolder; add a build check banning new static accessors

go deeper

for a junior

Say: don't rewrite everything at once; add an interface, keep the old accessor working, and change call sites gradually.

for a middle

Describe the concrete seam — interface plus instance methods with the accessor delegating — and migrating call sites to constructor injection module by module.

for a senior

Add triage by pain, the composition root, passing the bridge at unmigrated call sites so the static moves outward, characterization tests, and deleting the accessor at the end.

for a principal

Own the programme: baseline metrics that justify the work, prioritise clock/randomness/context first, plan for initialization-order changes at deploy, name what you deliberately will not migrate, and close with automated enforcement plus a shrinking allow-list.

## Framing This is a legacy-code problem, not a pattern-purity problem. The goal is not "no singletons"; it is **restoring seams** — places where you can substitute behaviour without editing the code under test — so the suite becomes fast, isolated, and parallelisable. Sequence the work so every intermediate state is shippable. ## Step 0 — measure, so the effort is defensible Baseline a few numbers: total suite wall-clock, ratio of unit to integration tests, count of tests that must run serially, number of known order-dependent flakes, and the list of types that call a static accessor. These become the progress signal and the argument for continued investment. ## Step 1 — inventory and triage List every singleton and score it: | Signal | Why it matters | |---|---| | Holds mutable state | Source of cross-test leakage and non-local reasoning | | Touches I/O (DB, network, clock, filesystem, randomness) | Forces slow integration tests | | Called from many modules | High blast radius, high payoff | | Read-only/immutable | Low priority — often fine to leave alone | **Clock, randomness, environment, and current-user singletons are usually the highest-value first targets**: they are small, they poison determinism everywhere, and replacing them converts a large set of tests from integration to unit in one move. ## Step 2 — introduce the seam (behaviour-preserving) For each target: 1. Extract an **interface** describing what callers actually use (often much smaller than the class). 2. Convert the static methods to **instance methods** on an implementation class with a normal public constructor. 3. Keep the static accessor, now a **thin delegating adapter**: ``` interface TaxPolicy { rate() } class DefaultTaxPolicy implements TaxPolicy { ... } // public constructor class TaxPolicyHolder { // temporary bridge private static TaxPolicy current = new DefaultTaxPolicy(config) static getInstance() { return current } static setForTest(p) { current = p } // clearly marked, temporary } ``` Nothing else changes; the whole codebase still compiles. You have already bought substitutability. The setter is a deliberate, temporary, clearly-labelled crutch with a deletion date — not a permanent API. ## Step 3 — establish a composition root Many legacy systems have no single wiring place. Create one at the entry point (or per entry point: main, request handler, job runner, test fixture). Register the instance there with singleton lifetime — either manually or in a container. This is where uniqueness now lives: **exactly one instance, created once, injected everywhere**. ## Step 4 — migrate call sites outward Work **leaf-first** (types with few dependents) or **vertical slice** (one feature end-to-end) — whichever yields shippable increments. For each type: 1. Add the collaborator as a constructor parameter. 2. Update its direct constructors; for callers not yet migrated, pass `Holder.getInstance()` at the call site. The static call moves *outward* one level each time — this is the whole trick, and it terminates at the composition root. 3. Convert its tests to construct the type directly with a fake. Delete any reliance on global reset. Where behaviour is unclear, write **characterization tests** first: capture current behaviour, even if it looks wrong, so you can tell refactoring damage from pre-existing bugs. Fix the bugs in a separate change. ## Step 5 — close the door When the last caller is migrated, delete the accessor and the test setter. Then **enforce it mechanically**: an architecture test, dependency-rule check, or lint rule that fails the build when new code calls a static accessor or adds a private-constructor-plus-static-instance shape. Without enforcement the population regrows. Add an explicit allow-list for the handful of legitimate exceptions. ## What to *not* migrate - Immutable, stateless singletons — no benefit. - Pre-container **bootstrap facilities** (logging facade, crash reporter, process metrics) that must exist before wiring. Keep the static as a facade delegating to an injectable implementation, so the static holds no logic. - Third-party libraries whose API is static: wrap them in a thin adapter you own and inject *that*. This is the cheapest high-value move against untestable dependencies. ## Risks and how to hold them down - **Constructor parameter explosion** while migrating. It is a symptom the class had too many hidden dependencies; treat it as a signal to split the class, and resist "solving" it by injecting a service locator, which recreates the original problem. - **Initialization-order surprises** when creation moves from lazy-on-first-use to eager-at-root. Expect config validation to now fail at startup — which is the improvement, but plan the deploy for it. - **Merge conflicts** on long-running branches. Keep changes small, mechanical, and frequently merged. - **Half-migrated ambiguity**: some code injects, some calls statically. Make the direction visible in review rules ("new code must inject") so the mixed state is a transition, not a permanent style. ## Definition of done Not "zero singletons" but: tests run in parallel without interference, the majority of behaviour is covered by fast tests, dependencies of each class are readable from its constructor, and a build check prevents regression. Publish the before/after numbers from Step 0.

  • Migrating one class to constructor injection gave it seven parameters. Is that a sign the refactoring is wrong?
    No — it revealed dependencies that were always there but hidden behind static calls. The long parameter list is a legitimate design signal: the class has too many responsibilities and should be split, or a genuinely cohesive cluster of collaborators should be grouped into one role interface. Injecting a service locator to shorten the list just restores the hidden coupling you were removing.
  • How do you stop new static singletons from appearing while the migration is in progress?
    Mechanical enforcement plus a review rule. Add an architecture or lint check that fails the build on the private-constructor-plus-static-instance shape and on calls to the remaining bridge accessors from new packages, with a shrinking allow-list of known exceptions. Manual review alone does not hold across a large team over months.
  • Which singleton would you attack first in a legacy system, and why?
    Usually the clock, and its relatives — randomness, environment lookups, current-user/context. They are small, mechanical to replace, and they poison determinism across the whole suite, so injecting them converts many slow, flaky tests into fast deterministic ones and delivers a visible win that funds the rest of the work.

Replacing the plumbing in an occupied building. You don't shut off the water and gut every floor; you fit a valve and a short adapter behind each existing tap so old pipe and new pipe both work, then re-run one riser at a time while the building stays open. The last step is capping the old main so nobody can quietly reconnect to it.

saying these in an interview costs you the question

  • Proposing a big-bang rewrite of all singletons in one branch — long-lived, conflict-prone, and unshippable in between.
  • Introducing a service locator as the "modern" replacement; dependencies stay hidden and testability barely improves.
  • Keeping the temporary static test-setter permanently, so global mutable state is preserved under a new name.
  • Refactoring code whose behaviour nobody understands without writing characterization tests first.
  • Declaring victory without a build-level check, so new static accessors reappear within a quarter.
  • Migrating immutable, stateless singletons for consistency while the harmful stateful ones remain.
  • Treating growing constructor parameter lists as a failure of injection rather than as evidence of an overloaded class.

context