What is the Singleton pattern, and what are its downsides?
Quick Answer
Singleton ensures a class has exactly one instance with a global access point. The classic implementation is a private constructor plus a static instance. Its downsides — hidden global state, hard to unit test, hides dependencies, and thread-safety pitfalls — are why a DI-container singleton (register once, inject the interface) is almost always better.
Singleton guarantees one instance of a class with a global access point — private constructor, static Instance property. The reason it's often called an anti-pattern is the global state: code calls Thing.Instance from anywhere, so the class's dependencies are hidden, and in tests you can't swap it out and tests end up sharing mutable state. Plus the lazy initialisation has thread-safety traps. The better approach in .NET is to register the type as a singleton in the DI container and inject it as an interface — you still get one instance, but it's explicit and testable. If I really must hand-roll one, I use Lazy<T>.
Problems with the hand-rolled Singleton:
- Hidden dependency —
Logger.Instancecalled deep inside a method; the class's real dependencies aren't visible in its constructor. - Testability — you can't substitute a fake; tests share mutable global state and interfere with each other.
- Thread safety — naive lazy init has a race; you need
Lazy<T>, a static initialiser, or double-checked locking. - Lifetime — lives for the whole process; a leak or stale cache never clears.
The DI alternative: services.AddSingleton<IClock, SystemClock>() — one instance, but injected through an interface, so it's testable and its use is explicit. You still need it to be thread-safe. If you truly need a hand-rolled singleton, use private static readonly Lazy<T>.
- Naive lazy init (
if (_instance == null) _instance = new()) with no locking — a race under concurrency. - Using a Singleton to hold per-request or mutable state — it is shared across every thread.
- Reaching for
Instanceinside methods instead of injecting the dependency, which hides it from tests and from the constructor. - Confusing the Singleton pattern with a DI-container singleton lifetime — the latter still supports interfaces and testing.