Singleton overuse anti-pattern
Learn why overusing singletons creates hidden global state, makes code untestable, introduces threading hazards, and how dependency injection replaces them cleanly.
TL;DR
- A singleton guarantees one instance and global access. For a logger or connection pool, that is reasonable. For anything with mutable state or varying lifecycle, it creates hidden coupling that poisons your entire codebase.
- Every
getInstance()call is a hidden dependency. Your class signature says "I need nothing," but at runtime it reaches into global state that no caller can see, control, or replace. - Test isolation is destroyed because parallel tests share the same singleton state. Test A writes to the singleton, Test B reads it, and failures depend on execution order.
- The fix is constructor injection: declare dependencies in the constructor, let the caller provide real or mock implementations. No global state, no hidden coupling, no test isolation problems.
- The Service Locator pattern (
ServiceLocator.get(DatabasePool.class)) is singletons in disguise. It hides dependencies just as effectively.
The Problem
Your payment system has four singletons: DatabasePool, CacheManager, PaymentGateway, and ConfigStore. Every service accesses them directly:
public class OrderService {
public Order placeOrder(Cart cart) {
DatabasePool db = DatabasePool.getInstance();
CacheManager cache = CacheManager.getInstance();
PaymentGateway gateway = PaymentGateway.getInstance();
ConfigStore config = ConfigStore.getInstance();
if (config.getBoolean("payments.enabled")) {
gateway.charge(cart.userId(), cart.total());
}
db.execute("INSERT INTO orders ...", cart.toParams());
cache.invalidate("user-orders:" + cart.userId());
return new Order(cart);
}
}
Writing a unit test for placeOrder requires all four singletons to be initialized, configured, and in the right state. There is no way to inject a fake PaymentGateway because the code calls PaymentGateway.getInstance() internally. To suppress real payments in tests, you must reach into the singleton and swap its internal state, then remember to restore it afterward.
I've debugged a production incident where two teams independently added state to ConfigStore.getInstance(). One team cached feature flags. The other cached rate limits. A flag reload cleared the rate limit cache, and the rate limiter failed open for 14 minutes.
Two test suites running in parallel share the same CacheManager instance. Test A warms the cache. Test B expects a cache miss. On CI, tests pass 80% of the time and fail 20%, depending on JVM thread scheduling.
None of these dependencies appear in any constructor signature. A developer reading OrderService has no idea it talks to a database, a cache, a payment gateway, and a config store.
Why It Happens
Singletons are the path of least resistance. Each decision to use one is locally reasonable.
- "I just need one instance." The developer conflates "I want one instance" with "I need to enforce that exactly one instance exists globally." In most cases, you want one instance, but the enforcement mechanism (global access, private constructor) is the source of the damage.
- Construction convenience.
getInstance()works from anywhere without passing objects through layers. Constructor injection requires threading parameters through 3-4 call layers, which feels like boilerplate. - Framework influence. Many tutorials teach singletons as the "correct" way to manage shared resources. Spring beans are singletons by default, which reinforces the mental model.
- Double-checked locking as a ritual. Teams copy-paste the double-checked locking pattern without understanding it. The
volatilekeyword, memory barriers, and instruction reordering are subtle. I've reviewed five different "thread-safe singleton" implementations in one codebase, three of which were subtly broken.
Service Locator is a singleton in disguise
ServiceLocator.get(PaymentGateway.class) looks cleaner than PaymentGateway.getInstance(), but the effect is identical: hidden dependencies, untestable code, and global mutable state. If your class calls a static method to obtain a dependency, the dependency is hidden regardless of the naming pattern.
How to Detect It
| Signal | Threshold | How to Check |
|---|---|---|
getInstance() calls | 5+ call sites across different classes | grep -rn "getInstance()" src/ |
| Static mutable state | Any static field that is not final and immutable | Review singleton classes for mutable fields |
Test @DirtiesContext or state reset hooks | 3+ tests resetting singleton state | Search test classes for reset patterns |
| Test ordering sensitivity | Tests pass alone, fail in suite | Run tests in random order with --tests.order=random |
| Zero constructor parameters | Class does real work but declares no dependencies | Review classes with empty constructors that access external systems |
| Thread-safety bugs in init | Race conditions in getInstance() | Run tests with -Xcheck:concurrency or stress tools |
| Service Locator calls | ServiceLocator.get(...) usage | grep -rn "ServiceLocator" src/ |
If a class has zero constructor parameters but accesses a database, cache, or external API, it is hiding dependencies through singletons or service locators.
The Fix
Replace every getInstance() call with a constructor parameter. The caller decides which implementation to provide. In production, wire real implementations. In tests, inject fakes.
Each dependency is an interface (except AppConfig, which is a simple immutable value holder). The constructor declares exactly what the class needs.
public class OrderService {
private final DatabasePool db;
private final CacheClient cache;
private final PaymentGateway gateway;
private final AppConfig config;
public OrderService(DatabasePool db, CacheClient cache,
PaymentGateway gateway, AppConfig config) {
this.db = db;
this.cache = cache;
this.gateway = gateway;
this.config = config;
}
public Order placeOrder(Cart cart) {
if (config.getBoolean("payments.enabled")) {
gateway.charge(cart.userId(), cart.total());
}
db.execute("INSERT INTO orders (id, user_id, total, status) VALUES (?, ?, ?, ?)",
new Object[]{cart.orderId(), cart.userId(), cart.total(), "PENDING"});
cache.invalidate("user-orders:" + cart.userId());
return new Order(cart.orderId(), cart.userId(), cart.total());
}
}
The design principles at work:
- Dependency Inversion Principle:
OrderServicedepends on interfaces (PaymentGateway,DatabasePool), not concrete singletons. - Constructor injection: all dependencies are visible in the constructor signature. No hidden state.
- Lifecycle management: the DI container (Spring, Guice, or manual wiring) decides whether to create one instance or many. The class itself does not enforce "exactly one."
Test setup comparison
With singletons: 40 lines of state manipulation, reset hooks, and @DirtiesContext. With DI: new OrderService(fakeDb, fakeCache, fakeGateway, testConfig) in one line. The test difference alone justifies the migration.
Severity and Blast Radius
| Dimension | Impact |
|---|---|
| Test reliability | Flickering tests from shared state. CI becomes untrustworthy, and developers stop running tests locally. |
| Test speed | @DirtiesContext forces full container reload between tests. Suites that should take 40s take 4+ minutes. |
| Thread safety | Race conditions in getInstance() or shared mutable state. Bugs surface only under load or in CI. |
| Dependency clarity | No developer can tell what a class depends on without reading the implementation. Code reviews miss coupling. |
| Lifecycle control | Singletons live for the process lifetime. Request-scoped or test-scoped instances are impossible without hacking. |
| Refactoring cost | Replacing a singleton touches every call site. A 500-file search-and-replace is the migration path. |
The compounding effect: untestable code leads to fewer tests, which leads to more production bugs, which leads to "let's add more singletons to share state between error-handling code." I've seen this cycle twice.
When It's Actually OK
- Connection pools with fixed configuration. A database connection pool initialized once at startup with immutable configuration is a legitimate singleton. The pool itself is thread-safe, and creating multiple pools wastes connections.
- Logger instances.
Logger.getLogger(ClassName.class)is a singleton per class name, but loggers are stateless output channels. They create no hidden coupling or test isolation issues. - Immutable configuration loaded at startup. If the configuration is read once and never changes, a singleton holder avoids repeated file I/O. The key constraint: immutable after construction.
The rule: a singleton is safe when it is either stateless or immutable after construction. If any part of the singleton's state can change during the application lifecycle, constructor injection is safer.
How This Shows Up in Interviews
Scenario 1: "I'll use a singleton for the cache." Red flag if the candidate does not mention testability. Green flag if they say "I'll have the DI container manage a single instance of the cache client, and inject it through the constructor." The distinction: the container controls lifecycle, not the class.
Scenario 2: Design a payment service. The candidate writes PaymentGateway.getInstance(). Ask "how would you unit test this?" A strong candidate immediately recognizes the problem and switches to constructor injection. A weak candidate says "I'll mock the static method" (which is fragile and framework-dependent).
Scenario 3: Thread safety discussion. The interviewer asks about double-checked locking. A strong answer: "The holder idiom (inner static class) is simpler and correct without volatile. But in practice, I avoid enforcing singleton lifecycle in the class itself and let the DI container handle it."
My recommendation: in any interview, if you mention a singleton, immediately follow with "but I would inject it as a dependency rather than calling getInstance() directly." This signals awareness of testability and SOLID.
The DI pivot
When an interviewer asks about singletons, the best answer is not about double-checked locking or enum singletons. It is: "I rarely enforce singleton lifecycle in the class itself. I let the DI container manage a single instance and inject it where needed. That gives me the same 'one instance' guarantee with full testability."
Common Mistakes
| Mistake | Why It Fails | Better Approach |
|---|---|---|
Mocking getInstance() with PowerMock or static mocks | Fragile, slow, couples tests to implementation. Breaks when the singleton changes structure. | Inject the dependency. Test with a simple interface fake. |
Double-checked locking without volatile | The JVM can reorder instructions, returning a partially constructed object. The bug is invisible on most hardware. | Use the holder idiom (inner static class) or let the DI container manage lifecycle. |
"Singleton scope in Spring means getInstance() is OK" | Spring's singleton scope is DI-managed. Writing getInstance() bypasses Spring entirely. | Use @Component with constructor injection. Never combine DI with manual getInstance(). |
| Replacing singletons with a Service Locator | ServiceLocator.get(Cache.class) still hides the dependency. The class signature still shows zero parameters. | Constructor injection is the only pattern that makes dependencies visible in the signature. |
| Using enum singleton for mutable state | Enum singletons are elegant for immutable cases, but adding mutable fields creates the same shared-state problem. | Enum singleton for truly immutable objects only. DI for everything else. |
Test Your Understanding
Quick Recap
- Every
getInstance()call is a hidden dependency invisible to callers, code reviewers, and test frameworks. - Singletons destroy test isolation because parallel tests share the same mutable state, causing ordering-dependent failures.
- Double-checked locking is error-prone. The holder idiom is simpler and correct. Letting a DI container manage lifecycle is better than both.
- The fix is constructor injection: declare dependencies as constructor parameters, inject real or fake implementations.
- Service Locator (
ServiceLocator.get(...)) is a singleton in disguise. It hides dependencies just as effectively asgetInstance(). - Legitimate singletons exist: connection pools, loggers, and immutable configuration. The rule is "stateless or immutable after construction."
- In interviews, always pair "singleton" with "but I would inject it through the constructor." This signals testability awareness and the Dependency Inversion Principle.