Creational Beginner 9 min read

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
  1. What problem does the Singleton pattern solve in C# / .NET?
  2. How do you write a thread-safe Singleton in modern C#?
  3. When does the DI container's AddSingleton() replace the pattern?
  4. When should you NOT use the Singleton pattern?
  5. How does Singleton differ from a static class?
  6. What does a real Singleton example look like in .NET 10?
  7. 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:

  1. 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.
  2. 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.
  3. 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:

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:

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 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:

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.

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?
Not by itself. The pattern becomes an anti-pattern when it stores mutable state, when it has dependencies that should be injected, or when it makes the caller untestable. A thread-safe, read-only, immutable-after-init Singleton (like a price catalog loaded once at startup) is fine. A Singleton that other code mutates is a global variable wearing a tuxedo.
What is the difference between a Singleton and a static class in C#?
A static class cannot implement an interface, cannot be passed as a parameter, and cannot be replaced in a unit test. A Singleton can do all three because it is still a regular instance — it just guarantees there is one of them. Use a static class for pure functions like 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?
Use 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?
Only if the Singleton is exposed through an interface and injected. If your code calls 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.