- A Singleton is supposed to provide controlled global access to a single instance, but it is actually a hidden dependency that breaks testability and makes state changes invisible
- Singletons hide dependencies โ code that uses a Singleton has an implicit dependency that is not visible in the function signature or constructor
- In testing, Singletons create cascade failures โ a test that mutates Singleton state breaks all subsequent tests because the state persists between test runs
- The pattern feels safe because it is simple, but simplicity is the opposite of clarity in production code where you need to understand what state is being modified and when
I watched a test pass locally and fail in CI. Then I watched it fail every other run. Then I watched the same test pass sometimes and fail sometimes, completely randomly.
The test was not flaky. The code was not flaky. The Singleton was.
A logger Singleton that was created once in test A, modified in test B, was still modified when test C ran. Test C expected the logger in its default state. But it was still in the state that test B left it in. Test C failed.
Sometimes test B ran before test C and failed. Sometimes test C ran first and passed. The order of tests was the bug. The Singleton was the cause.
I spent three hours debugging this. The problem was not in the test. The problem was not in the code under test. The problem was that a Singleton from one test was bleeding into another test.
That is the Singleton trap. It feels elegant when you define it. It becomes a nightmare when you use it.
What a Singleton Is (And What It Pretends to Be)
A Singleton pattern is simple. One instance. Global access. Lazy initialization.
class Logger {
static instance;
static getInstance() {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message) {
console.log(message);
}
}
// Everywhere in your code:
Logger.getInstance().log("Something happened");
The pattern promises: "You have one logger. Anywhere you need it, just get the instance. No coupling. No dependencies."
The pattern delivers that. But it also delivers something else: hidden global state.
Every time you call Logger.getInstance().log(), you are accessing a global object. You are mutating it (the log state might change). You are depending on it being in a specific state.
None of this is explicit. There is no parameter. There is no constructor dependency. The dependency is implicit.
Why Hidden Dependencies Are Poison
In good code, dependencies are explicit:
class UserService {
constructor(logger) {
this.logger = logger;
}
createUser(user) {
this.logger.log(`Creating user: ${user.id}`);
// ...
}
}
You can see that UserService depends on a logger. You can trace where the logger comes from. You can test UserService by passing in a mock logger. The dependency is clear.
With Singletons:
class UserService {
createUser(user) {
Logger.getInstance().log(`Creating user: ${user.id}`);
// ...
}
}
You have no idea that UserService depends on Logger. The dependency is hidden. It is only visible if you read the function body carefully. If you refactor, you might remove the logger and not realize it. If you test, you might forget that the logger is mutating global state.
The hidden dependency is worse than visible dependency. At least with visible dependencies, you can see them and reason about them.
The Testing Catastrophe
Singletons are testing disasters. Not because the pattern itself is bad. Because they hide state.
Imagine a test:
test("logger should log messages", () => {
const logger = Logger.getInstance();
logger.log("Test message");
expect(logger.messages).toContain("Test message");
});
This test passes. The Singleton logged the message. Great.
Now another test:
test("UserService should create user", () => {
const service = new UserService();
service.createUser({ id: 1, name: "Alice" });
// Don't assert anything about logging
});
This test creates a user. The UserService logs "Creating user: 1". The logger has now logged two messages across two tests.
Now a third test:
test("logger should be empty", () => {
const logger = Logger.getInstance();
expect(logger.messages.length).toBe(0); // FAILS
});
The test expects the logger to be empty. But the logger has two messages from the previous tests. The test fails even though the code is correct.
Why did it fail? Because the Singleton is global. It persists across tests. The state from test 1 and test 2 leaked into test 3.
The solution is to reset the Singleton between tests:
beforeEach(() => {
Logger.instance = null; // Reset the Singleton
});
But now you have to remember this reset for every test file. If you forget, the test fails randomly. If someone adds a new Singleton, they have to remember to reset it too.
Singletons make testing a minefield.
The Invisible State Problem
Singletons hide state mutation. You have no idea when or where state is changing.
class Config {
static instance;
constructor() {
this.settings = {};
}
static getInstance() {
if (!Config.instance) {
Config.instance = new Config();
}
return Config.instance;
}
set(key, value) {
this.settings[key] = value;
}
get(key) {
return this.settings[key];
}
}
// Somewhere in module A:
Config.getInstance().set("debugMode", true);
// Somewhere in module B:
if (Config.getInstance().get("debugMode")) {
// ...
}
// Somewhere in module C:
Config.getInstance().set("debugMode", false);
// Now module B's behavior changed without module B knowing.
Module B depends on a configuration value. But the dependency is not explicit. Module C can change it. Module A can set it. Module B has no idea what the current value is until it reads it.
The state is hidden. The mutations are hidden. The dependencies are hidden.
In production, this creates bugs that are impossible to debug. You set a config value. Somewhere else in the codebase, something else sets it to a different value. Your code breaks. You have no idea why.
You look at the code path. Everything looks correct. Then you search for all places where the Singleton is accessed. You find five places. One of them is changing the state at a time you did not expect.
That is the Singleton nightmare.
Why We Keep Using Them Anyway
Singletons are everywhere in production code. If they are so bad, why?
Reason 1: Convenience
A Singleton is convenient. You do not have to pass it around. You do not have to construct it. You do not have to manage its lifecycle.
// With Singleton: simple
Logger.getInstance().log("Something");
// With Dependency Injection: verbose
constructor(logger) {
this.logger = logger;
}
this.logger.log("Something");
The Singleton version is shorter. The DI version requires the dependency to be wired up.
In a small codebase, the convenience wins. In a large codebase, the convenience is outweighed by the debugging nightmare.
Reason 2: Legacy Code
A codebase has Singletons. Adding a new service is easier if you make it a Singleton too. The pattern is established. The pattern is familiar. The pattern is everywhere.
Consistency wins over correctness. The system uses Singletons, so you use Singletons.
Reason 3: Laziness
Building a Singleton is lazy. You do not have to think about dependency graphs. You do not have to wire up dependencies. You define the Singleton and access it from anywhere.
Building a proper dependency injection container is work. Building a factory. Wiring up the graph. Testing that the graph is correct.
The Singleton is simpler. It feels right. Until it is not.
The Hidden Cost
A Singleton saves you time writing code. It costs you time debugging code.
You save 10 minutes not wiring up dependencies. You spend 3 hours debugging a test that fails randomly because a Singleton from another test is mutating state.
You save 5 minutes accessing the logger from anywhere without injecting it. You spend 2 hours tracing a bug where a config value changed in an unexpected place because five different modules are accessing the same Singleton.
The trade is not worth it.
When a Singleton Might Actually Be OK
Singletons are not always wrong. But they are wrong most of the time.
A Singleton might be acceptable if:
- It is immutable. A Singleton that never changes is fine. A logger that only appends? Fine. A config object that is set once at startup and never changes? Fine.
- It has no state. A Singleton that is purely stateless (just methods, no instance variables) is fine. A utility library? Fine.
- It is internal to a module. A Singleton that is private to a package and not exposed globally is fine. The hidden dependency is contained.
- There is genuinely only one instance. A database connection pool. A thread pool. A cache. These are genuinely singletons because creating more than one would be wasteful or incorrect.
In all other cases, Singletons are a liability. Use dependency injection. Pass dependencies explicitly. Make the code harder to read but easier to understand. Make it harder to write but easier to debug.
The Better Pattern
Instead of:
class UserService {
createUser(user) {
Logger.getInstance().log(`Creating user`);
// ...
}
}
Do this:
class UserService {
constructor(logger) {
this.logger = logger;
}
createUser(user) {
this.logger.log(`Creating user`);
// ...
}
}
The dependency is explicit. The testing is easy. The state is visible. The cost is that you have to pass the logger around.
That cost is worth it. The visibility is worth it. The debuggability is worth it.
The Singleton pattern feels elegant because it hides the complexity of dependency management. But hiding complexity is not elegance. It is deception. The complexity is still there. It is just hidden until production breaks.