Singleton Pattern C#: When (and When Not) to Use It
Use the Singleton pattern in C# / .NET 10 the right way: thread-safe Lazy<T>, why AddSingleton<T> usually replaces it, and when you still need it manually.
Table of contents
- What problem does the Singleton pattern solve in C# / .NET?
- How do you write a thread-safe Singleton in modern C#?
- When does the DI container's AddSingleton() replace the pattern?
- When should you NOT use the Singleton pattern?
- How does Singleton differ from a static class?
- What does a real Singleton example look like in .NET 10?
- Where should you read next in this series?
Your shop has 50,000 SKUs. The prices never change during the trading day.
Yet every checkout request hits the Products table with a SELECT — because
the engineer who wrote GetPrice(sku) did not want to think about caching.
By Friday afternoon the database is at 95% CPU and the on-call channel is
on fire.
The fix is obvious: load the price table once at startup, keep it in memory, share that single copy across every request. That single shared object is the Singleton pattern. The intent fits in one line: one instance per process, with a known way to reach it. The interesting part is not the intent — it is everything you have to think about around it. Thread safety, testing, lifetime, lazy initialisation, and (most importantly) when to skip the pattern entirely and let the DI container do the work for you.
This article is the one I wish I had read on day one of my first .NET job.
What problem does the Singleton pattern solve in C# / .NET?
The Singleton pattern earns its place when the same instance must be shared by every caller and creating a second one would be wrong, wasteful, or both. Three concrete shapes of that:
- Expensive read-only resource. A price catalog loaded from the database. A 200 MB ML model loaded into memory. A pre-compiled regex ruleset. Building a second copy would just waste memory and slow down startup.
- Genuinely single OS / hardware resource. A handle to a log file the process owns. The system clipboard adapter. A serial-port wrapper. The OS itself only allows one consumer; modelling that as anything other than a singleton invites bugs.
- Coordination point inside one process. An in-memory rate limiter that all request handlers consult. A circuit-breaker state. An enqueue-only metrics buffer. Every caller must see the same counters or the coordination breaks.
Notice what is missing from the list: "any class I happen to want to reach from anywhere". That is the trap beginners fall into. Reachability is not a reason to use Singleton — it is a reason to use dependency injection.
How do you write a thread-safe Singleton in modern C#?
The textbook implementation looks reasonable and is wrong:
// Don't do this. Race condition between the null check and the assignment.
public sealed class PriceCatalog
{
private static PriceCatalog? _instance;
public static PriceCatalog Instance
{
get
{
if (_instance == null) _instance = new PriceCatalog();
return _instance;
}
}
private PriceCatalog() { /* expensive load */ }
}
Two threads can pass the null check at the same moment, both call the
constructor, and you end up with two instances — defeating the entire
point. Junior developers reach for lock. Mid-level developers reach for
double-checked locking. Senior developers reach for Lazy<T>:
public sealed class PriceCatalog
{
private static readonly Lazy<PriceCatalog> _instance =
new(() => new PriceCatalog());
public static PriceCatalog Instance => _instance.Value;
private readonly IReadOnlyDictionary<string, decimal> _prices;
private PriceCatalog()
{
// Runs exactly once, the first time someone reads Instance.
_prices = LoadFromDatabase();
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
private static IReadOnlyDictionary<string, decimal> LoadFromDatabase()
{
// 50K rows, takes ~400ms. We only ever pay this cost once.
// ...
return new Dictionary<string, decimal>();
}
}
The structure looks like this when several callers use the same instance — notice that no caller can ever invoke the constructor directly:
classDiagram
class PriceCatalog {
-Lazy~PriceCatalog~ instance$
-PriceCatalog()
+Instance$ PriceCatalog
+PriceOf(sku) decimal
}
class CheckoutController
class CartService
class PricingJob
CheckoutController ..> PriceCatalog : reads Instance
CartService ..> PriceCatalog : reads Instance
PricingJob ..> PriceCatalog : reads Instance
note for PriceCatalog "Private ctor + static Lazy field<br>= one instance, lazily created, thread-safe"
Lazy<T> defaults to LazyThreadSafetyMode.ExecutionAndPublication, which
the CLR implements with an internal lock that releases as soon as the value
is created. After the first read, subsequent reads are a single field
fetch — no lock, no branch, no overhead. You will not write a faster
implementation by hand.
Two warnings about Lazy<T> that bite people:
- The factory must not throw. If the constructor throws,
Lazy<T>caches the exception and rethrows it on every subsequent access. Make the constructor robust or useLazyThreadSafetyMode.PublicationOnlyand accept that the factory might run more than once. - The instance lives until process exit. If
LoadFromDatabaseopens a long-lived handle, that handle never closes. Either keep the Singleton's state pure data, or accept that you cannot dispose it.
When does the DI container's AddSingleton() replace the pattern?
In ASP.NET Core, .NET Worker Service, and basically any modern .NET host,
lifetime is configuration, not class structure. The Lazy<T>-backed
class above can be rewritten with a public constructor and a single line
in Program.cs:
public sealed class PriceCatalog : IPriceCatalog
{
private readonly IReadOnlyDictionary<string, decimal> _prices;
public PriceCatalog(ILogger<PriceCatalog> log, AppDbContext db)
{
log.LogInformation("Loading price catalog from database");
_prices = db.Products.ToDictionary(p => p.Sku, p => p.Price);
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
}
// Program.cs
builder.Services.AddSingleton<IPriceCatalog, PriceCatalog>();
The DI container does three things the hand-rolled Singleton cannot:
- Constructor injection.
PriceCatalognow receivesILogger<>, theDbContext, anything else it needs. The hand-rolled version had to call static factories or service locators inside its private constructor, smearing dependencies through the whole class. - Interface substitution. Code depends on
IPriceCatalog, not the concrete class. Tests register a fake; production registers the real one. Same calling code. - Lifetime control. If next year you decide one catalog per tenant
fits better, you change
AddSingletontoAddScopedand inject aITenantContext. The classes that consumeIPriceCatalogdo not notice.
The hand-rolled Instance property still has a niche: library code that
must work without a DI container. Most application code does not. If you
already have IServiceCollection in scope, prefer it.
A short historical note: many docs still call AddSingleton<T>() "the
Singleton pattern in .NET". That is exactly right — the pattern's intent is
preserved, the implementation just lives in the framework instead of in
your class. Microsoft's
Service lifetimes
documentation is the authoritative reference here.
When should you NOT use the Singleton pattern?
The pattern misfires in four very specific situations. If any of these match your case, walk away.
- The state is mutable and shared. Two threads writing to the same
Dictionary<string, int>Singleton is a global variable, not a design pattern. If you genuinely need shared mutable state, you also need explicit synchronisation (ConcurrentDictionary,lock,Channel) and a much louder warning in the class summary. - The class needs different behaviour per test. A Singleton with a
static
Instanceproperty cannot be replaced. Once a test mutates it, every later test runs against the mutated state. The fix is to inject the dependency through an interface — and at that point you are using DI, not the pattern. - The class is a service with dependencies. "I need a Singleton
PaymentGatewaythat uses anHttpClient" is not a Singleton problem; it is a DI problem. Register it asAddSingleton<IPaymentGateway, PaymentGateway>()and let the container wireIHttpClientFactoryfor you. See the Factory Method pattern for the next chapter on how the framework solves the "which payment gateway?" question. - You really wanted to swap implementations at runtime. People reach for Singleton when they want one shared algorithm. What they actually want is the Strategy pattern — pick the algorithm at runtime, not at class definition time.
The general principle: every Singleton you write is one more piece of hidden coupling. Use the pattern only when the coupling is part of the problem, not part of the solution.
How does Singleton differ from a static class?
This is the most common confusion in C# interviews. They look similar —
both give you something you can call without new. They are not the same.
| Aspect | static class |
Singleton |
|---|---|---|
| Implements interfaces? | No | Yes |
| Pass as method parameter? | No | Yes |
| Replace in a unit test? | No (without rewriting calls) | Yes (via the interface) |
| Lazy initialisation? | Yes (CLR runs static ctor on first use) |
Yes (Lazy<T> or DI) |
| Polymorphism? | No | Yes |
| Best for | Pure functions: Math.Abs, string.IsNullOrEmpty |
Stateful services: caches, gateways, registries |
A useful rule of thumb: if the class would be a verb (Math, Convert,
Path), it is probably a static class. If it would be a noun
(PriceCatalog, RateLimiter, Logger), it is probably a Singleton —
and almost certainly should be registered with AddSingleton<T>() rather
than written by hand.
What does a real Singleton example look like in .NET 10?
Here is the full picture from Program.cs to a controller, exactly how a
production .NET 10 service would wire it up. This is the example we will
keep referring back to throughout the Creational
patterns — same domain, different problems.
// Domain ----------------------------------------------------------
public interface IPriceCatalog
{
decimal? PriceOf(string sku);
}
public sealed class PriceCatalog : IPriceCatalog
{
private readonly IReadOnlyDictionary<string, decimal> _prices;
public PriceCatalog(AppDbContext db, ILogger<PriceCatalog> log)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
_prices = db.Products
.AsNoTracking()
.ToDictionary(p => p.Sku, p => p.Price);
log.LogInformation("Loaded {Count} prices in {Ms}ms",
_prices.Count, sw.ElapsedMilliseconds);
}
public decimal? PriceOf(string sku)
=> _prices.TryGetValue(sku, out var p) ? p : null;
}
// Composition root ------------------------------------------------
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(/* ... */);
builder.Services.AddSingleton<IPriceCatalog, PriceCatalog>();
// ^^^^^^^^^ one instance for the whole process
// Consumer --------------------------------------------------------
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly IPriceCatalog _prices;
public CheckoutController(IPriceCatalog prices) => _prices = prices;
[HttpGet("price/{sku}")]
public ActionResult<decimal> Get(string sku)
=> _prices.PriceOf(sku) is { } p
? Ok(p)
: NotFound();
}
// Test ------------------------------------------------------------
public sealed class FakeCatalog : IPriceCatalog
{
public decimal? PriceOf(string sku) => sku == "ABC" ? 9.99m : null;
}
[Fact]
public void Returns_price_when_sku_exists()
{
var sut = new CheckoutController(new FakeCatalog());
var result = sut.Get("ABC").Result as OkObjectResult;
Assert.Equal(9.99m, result!.Value);
}
Three things that the hand-rolled Singleton pattern struggles to give you all at once:
- The production constructor receives
AppDbContextandILogger<>through normal DI — no service locator, no static dependency lookup. - The test swaps the dependency with a one-line
FakeCatalog. No reflection, no resetting static state between tests. - The lifetime decision is a single token (
AddSingleton) you can change without touchingPriceCatalogitself.
This is the version you actually ship. The Lazy<T> version is worth
knowing because it explains what AddSingleton<T> does under the
hood — but the application code you write should look like the snippet
above, not like the pre-DI implementations from the original Gang of Four
book.
Where should you read next in this series?
- Previous: Introduction to design patterns in C#
- Next: Factory Method — same checkout
domain, but now the question is "which
IPaymentProcessordo I create?" rather than "how do I share the one I have?" - Forward: Abstract Factory — the factory itself is almost always registered as a Singleton.
- Cross-reference: Strategy — if you reached for Singleton because you wanted to share one specific algorithm, you probably wanted Strategy plus DI instead.
- Decision tree: How to choose the right design pattern.
A final, slightly opinionated note: in fifteen years of C# I have written
exactly two hand-rolled Singletons and registered hundreds with
AddSingleton<T>(). The pattern is in your toolbox, but most days the
toolbox you actually open is the DI container. Keep that ratio in mind
the next time you reach for it.
Frequently asked questions
Is the Singleton pattern an anti-pattern?
What is the difference between a Singleton and a static class in C#?
Math.Abs. Use a Singleton (preferably via AddSingleton<T>()) for stateful services.How do I make a Singleton thread-safe in C# without a lock?
System.Lazy<T> with the default LazyThreadSafetyMode.ExecutionAndPublication. The CLR guarantees the value factory runs exactly once even under concurrent access. You write static readonly Lazy<MyType> _instance = new(() => new MyType()) and Instance => _instance.Value. No lock, no double-checked locking, no volatile field needed.Should I unit-test code that depends on a Singleton?
PriceCatalog.Instance directly, the test cannot substitute a fake catalog and the static state leaks between tests. The fix is to depend on IPriceCatalog and register the real catalog via services.AddSingleton<IPriceCatalog, PriceCatalog>() — DI then gives the test an injection point.