Mert Özen Further With Every Line
Software Design Design Patterns — #02

Singleton Pattern: Thread-Safe Lazy<T>, Static Fields and Why the DI Singleton Lifetime Is Better

Mert Özen Nov 7, 2026 14 min 5 views
Singleton Pattern: Thread-Safe Lazy<T>, Static Fields and Why the DI Singleton Lifetime Is Better

If you need a single instance, the cleanest route is not the classic Singleton but registering with AddSingleton in the DI container: the single-instance guarantee stays, while global access and hidden dependencies go away. The race in a naive Singleton, the history of double-checked locking, static readonly and beforefieldinit, Lazy<T>'s exception caching, captive dependencies and shared state inside a singleton, with real output.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The Bookstore price list is loaded from a CSV (Comma-Separated Values) file, and loading it is expensive. Someone said "let's load it once and have everyone use the same one" and wrote a classic Singleton: a private constructor, a static field, creation on first access. As the application starts, eight requests hit PriceCatalog.Instance at the same time. How many times does the constructor run, and how many different price lists end up in memory?

using Bookstore.Pricing;

Task<PriceCatalog>[] requests = Enumerable.Range(0, 8)
    .Select(_ => Task.Run(() => PriceCatalog.Instance))
    .ToArray();

PriceCatalog[] catalogs = await Task.WhenAll(requests);

Console.WriteLine($"Constructor calls: {PriceCatalog.ConstructorCalls}");
Console.WriteLine($"Distinct instances: {catalogs.Distinct().Count()}");

namespace Bookstore.Pricing
{
    public sealed class PriceCatalog
    {
        private static PriceCatalog? _instance;
        public static int ConstructorCalls;

        private PriceCatalog()
        {
            Interlocked.Increment(ref ConstructorCalls);
            Thread.Sleep(50); // as if loading the price list from a file
        }

        public static PriceCatalog Instance
        {
            get
            {
                if (_instance is null)
                    _instance = new PriceCatalog();
                return _instance;
            }
        }
    }
}

Eight Threads, Eight Price Lists

Output:

Constructor calls: 8
Distinct instances: 8

Eight requests, eight separate instances. Every thread checked _instance is null before any of the others had left the constructor, and they all saw null. The 50 millisecond sleep exaggerates the situation but does not create it; a real file read opens the same window. Worse, this code works flawlessly in a single-threaded unit test, and the problem only shows up in production, under load, now and then. Eight price lists loaded at eight different moments means the same book can be sold at two different prices in two requests.

The GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) defines Singleton's intent in a two-part sentence: ensure a class has only one instance, and provide a global point of access to it. That sentence holds two separate promises, and the rest of this post is really about pulling them apart. The first promise, a single instance, is often a legitimate need: an expensive cache, a connection pool, a price list. The second, global access, is the source of most of the trouble. First we look at ways to keep the first promise correctly, then at the price of the second.

Double-Checked Locking: The Pattern Written Wrong for Years

The first fix that comes to mind is wrapping the getter in a lock. It works, but it takes the lock on every access, even though the lock is pointless once the instance exists. The fix that became popular in the 1990s to reduce that cost is double-checked locking: check without the lock first, and if there is no instance, take the lock and check again inside.

using Bookstore.Pricing;

PriceCatalog[] catalogs = await Task.WhenAll(
    Enumerable.Range(0, 8).Select(_ => Task.Run(() => PriceCatalog.Instance)));

Console.WriteLine($"Constructor calls: {PriceCatalog.ConstructorCalls}");
Console.WriteLine($"Distinct instances: {catalogs.Distinct().Count()}");

namespace Bookstore.Pricing
{
    public sealed class PriceCatalog
    {
        private static volatile PriceCatalog? _instance;
        private static readonly Lock Sync = new();
        public static int ConstructorCalls;

        private PriceCatalog()
        {
            Interlocked.Increment(ref ConstructorCalls);
            Thread.Sleep(50);
        }

        public static PriceCatalog Instance
        {
            get
            {
                if (_instance is null)              // 1st check: the fast path without a lock
                {
                    lock (Sync)
                    {
                        if (_instance is null)      // 2nd check: for threads that waited on the lock
                            _instance = new PriceCatalog();
                    }
                }
                return _instance;
            }
        }
    }
}
Constructor calls: 1
Distinct instances: 1

The history of this code is a good example of how much patterns depend on language and runtime details. In the Java world double-checked locking was broken for years: Java's old memory model did not guarantee that the writes inside the constructor would become visible to other threads before the reference was assigned to the field. A thread could see an object that was not null but only half constructed. A group of researchers, including Joshua Bloch (author of Effective Java) and Doug Lea (architect of Java's concurrency library), published a statement titled "The Double-Checked Locking is Broken" in the early 2000s. The problem was fixed in 2004 by the new memory model in Java 5, provided the field is declared volatile.

In .NET the story is a little different. The memory model described by ECMA-335, the CLI (Common Language Infrastructure) standard published by ECMA (European Computer Manufacturers Association, today Ecma International), is weak and carries the same problem on paper. Microsoft's runtime gives stronger guarantees: the memory model document in the dotnet/runtime repository states plainly that writing the reference of a newly created object to a shared location is a release, so the object's fields become visible before the reference does. So on today's .NET, double-checked locking without volatile works in practice. Jon Skeet (author of C# in Depth) still marks this version as "don't use" in his Singleton article: it is easy to get wrong, it asks the reader to know the memory model, and it is neither faster nor more readable than the two options below. I showed the code above only for its history; the volatile keyword documents intent, nothing more.

static readonly and the beforefieldinit Detail

The CLR (Common Language Runtime) already guarantees that the code initialising a type's static fields, the type initializer, runs thread-safely and only once. So the shortest thread-safe Singleton is a single line: public static readonly PriceCatalog Instance = new();. No lock, no check. The only question is when that line runs:

using System.Reflection;
using Bookstore.Pricing;

Console.WriteLine($"EagerCatalog  beforefieldinit: {typeof(EagerCatalog).Attributes.HasFlag(TypeAttributes.BeforeFieldInit)}");
Console.WriteLine($"StrictCatalog beforefieldinit: {typeof(StrictCatalog).Attributes.HasFlag(TypeAttributes.BeforeFieldInit)}");

Console.WriteLine(EagerCatalog.Describe());
Console.WriteLine(StrictCatalog.Describe());

Console.WriteLine("Touching Instance...");
_ = EagerCatalog.Instance;
_ = StrictCatalog.Instance;

namespace Bookstore.Pricing
{
    public sealed class EagerCatalog
    {
        public static readonly EagerCatalog Instance = new();

        private EagerCatalog() => Console.WriteLine("  EagerCatalog created");

        public static string Describe() => "EagerCatalog.Describe called";
    }

    public sealed class StrictCatalog
    {
        public static readonly StrictCatalog Instance = new();

        // An empty static constructor: the compiler omits the beforefieldinit flag
        static StrictCatalog() { }

        private StrictCatalog() => Console.WriteLine("  StrictCatalog created");

        public static string Describe() => "StrictCatalog.Describe called";
    }
}
EagerCatalog  beforefieldinit: True
StrictCatalog beforefieldinit: False
EagerCatalog.Describe called
  StrictCatalog created
StrictCatalog.Describe called
Touching Instance...
  EagerCatalog created

The only difference between the two classes is an empty static constructor. For a type without a static constructor, the C# compiler sets the beforefieldinit flag at the IL (Intermediate Language) level. The flag tells the runtime "you may initialise the static fields at any point before their first access"; it leaves the timing to the JIT (Just-In-Time) compiler. Without the flag the rule is strict: exactly at the first touch of any static member of the type. The output shows the difference clearly: StrictCatalog.Describe() triggered the constructor even though it has nothing to do with the price list, while EagerCatalog was created only when Instance was touched.

This is a detail that can change between versions. Before .NET Framework 4.0, beforefieldinit types were usually initialised earlier, on first entry to a method; today's JIT is fairly lazy, as this run shows. The only thing to rely on is the contract: with the flag, the moment of initialisation is not guaranteed; without it, it is. If the moment your Singleton is created matters (you log in the constructor, read a file, or can throw), an empty static constructor pins it down. If it does not matter, beforefieldinit is better; the JIT does not have to check "has it been initialised?" on every access.

A static initializer has one more unpleasant trait: if the constructor throws, the type is broken for good. Every later access to it throws a TypeInitializationException wrapping the original error, and it does not recover without restarting the process.

Lazy<T>: The Shortest Correct Answer, and Exception Caching

Lazy<T>, introduced in .NET Framework 4.0, reduces lazy, thread-safe creation to one line: private static readonly Lazy<PriceCatalog> Lazy = new(() => new PriceCatalog()); and Instance => Lazy.Value. The default mode is LazyThreadSafetyMode.ExecutionAndPublication; only one thread runs the factory, the others wait. But the static initializer's breakage problem is here too, in a sneakier form:

string path = Path.Combine(Path.GetTempPath(), "bookstore-prices.csv");
File.Delete(path);

Lazy<string[]> cached = new(() => File.ReadAllLines(path));
Lazy<string[]> retrying = new(() => File.ReadAllLines(path), LazyThreadSafetyMode.PublicationOnly);

Console.WriteLine("1) File missing:");
Try("ExecutionAndPublication", cached);
Try("PublicationOnly", retrying);

File.WriteAllLines(path, ["978-0441013593;12.99", "978-0141439518;7.50"]);

Console.WriteLine("2) File created, same Lazy instances:");
Try("ExecutionAndPublication", cached);
Try("PublicationOnly", retrying);

File.Delete(path);

static void Try(string mode, Lazy<string[]> lazy)
{
    try
    {
        Console.WriteLine($"  {mode}: {lazy.Value.Length} prices");
    }
    catch (FileNotFoundException)
    {
        Console.WriteLine($"  {mode}: FileNotFoundException (IsValueCreated={lazy.IsValueCreated})");
    }
}
1) File missing:
  ExecutionAndPublication: FileNotFoundException (IsValueCreated=False)
  PublicationOnly: FileNotFoundException (IsValueCreated=False)
2) File created, same Lazy instances:
  ExecutionAndPublication: FileNotFoundException (IsValueCreated=False)
  PublicationOnly: 2 prices

Even after the file was created, the Lazy in default mode kept throwing the same FileNotFoundException. This is not a bug but documented behaviour: with constructors that take a factory, the first exception is cached and the same one is thrown on every later access to Value. The rationale on Microsoft Learn is to stop different threads from seeing different results. In practice it means that if the price file is unreachable for one second while the application starts, there is no price list until the process restarts.

In PublicationOnly mode exceptions are not cached, and the second attempt read the file. The price is that if several threads arrive at once, the factory may run more than once; only one result wins and the rest are discarded. That is acceptable if the factory is expensive but free of side effects. The documentation's own advice is usually better: put retry logic for transient failures inside the factory, rather than changing Lazy's mode.

Why It Is Close to an Anti-Pattern: Hidden Dependencies and Leaking Tests

So far we have tried to keep the first promise, a single instance, correctly. The price of the second promise, global access, is less visible but higher. Picture a registry that holds campaigns in the Bookstore; the order total uses the best campaign rate:

using System.Globalization;
using Bookstore.Orders;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// Two "tests" running one after the other in the same process (as test runners do)
RunTest("Student campaign gives 15% off", () =>
{
    CampaignRegistry.Instance.Activate("STUDENT", 0.15m);
    return new OrderService().Total([12.99m, 7.50m]) == 17.42m;
});

RunTest("No campaign means full price", () =>
    new OrderService().Total([12.99m, 7.50m]) == 20.49m);

static void RunTest(string name, Func<bool> test) =>
    Console.WriteLine($"{(test() ? "PASS" : "FAIL")}  {name}");

namespace Bookstore.Orders
{
    public sealed class CampaignRegistry
    {
        public static CampaignRegistry Instance { get; } = new();

        private readonly Dictionary<string, decimal> _active = [];

        private CampaignRegistry() { }

        public void Activate(string code, decimal rate) => _active[code] = rate;
        public decimal BestRate() => _active.Count == 0 ? 0m : _active.Values.Max();
    }

    public sealed class OrderService
    {
        // The dependency is not visible in the constructor; it hides inside the method
        public decimal Total(IEnumerable<decimal> prices) =>
            Math.Round(prices.Sum() * (1 - CampaignRegistry.Instance.BestRate()), 2);
    }
}
PASS  Student campaign gives 15% off
FAIL  No campaign means full price

The second test would pass on its own. The student campaign activated by the first test stayed in the singleton, which lives for the whole process, and changed the second test's result. Change the order the tests run in and the result changes too; when test runners like xUnit run tests in different classes in parallel, the failure sometimes appears and sometimes does not. It is one of the classic sources of "it passes on my machine but fails in CI (Continuous Integration)".

The real problem is not the test but what the test exposes: someone looking at OrderService's constructor cannot see that it depends on campaigns. The dependency is hidden in the middle of the method, inside the CampaignRegistry.Instance call. Mark Seemann (author of Dependency Injection in .NET) describes the same problem in the post where he calls Service Locator an anti-pattern: the class's visible API (Application Programming Interface) does not tell you what it needs; you have to read its code to find out. Global access is a feature that comes free with the single-instance need, and nobody asked for it.

Getting the Same Guarantee from DI: AddSingleton

The modern way to separate the two promises is dependency injection (DI). The "single instance" decision moves out of the class into the container registration, and "access" becomes a constructor parameter instead of a global property:

using System.Globalization;
using Bookstore.Orders;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();
services.AddSingleton<CampaignRegistry>();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,
    ValidateOnBuild = true
});

OrderService first, second;
using (IServiceScope scope = provider.CreateScope())
    first = scope.ServiceProvider.GetRequiredService<OrderService>();
using (IServiceScope scope = provider.CreateScope())
    second = scope.ServiceProvider.GetRequiredService<OrderService>();

Console.WriteLine($"Same OrderService:     {ReferenceEquals(first, second)}");
Console.WriteLine($"Same CampaignRegistry: {ReferenceEquals(first.Campaigns, second.Campaigns)}");

// No container in tests: each test news up its own registry
CampaignRegistry studentOnly = new();
studentOnly.Activate("STUDENT", 0.15m);
Console.WriteLine(new OrderService(studentOnly).Total([12.99m, 7.50m]));
Console.WriteLine(new OrderService(new CampaignRegistry()).Total([12.99m, 7.50m]));

namespace Bookstore.Orders
{
    // Guaranteeing a single instance is now the container's job, not the class's
    public sealed class CampaignRegistry
    {
        private readonly Lock _sync = new();
        private readonly Dictionary<string, decimal> _active = [];

        public void Activate(string code, decimal rate)
        {
            lock (_sync)
                _active[code] = rate;
        }

        public decimal BestRate()
        {
            lock (_sync)
                return _active.Count == 0 ? 0m : _active.Values.Max();
        }
    }

    public sealed class OrderService(CampaignRegistry campaigns)
    {
        public CampaignRegistry Campaigns => campaigns;

        public decimal Total(IEnumerable<decimal> prices) =>
            Math.Round(prices.Sum() * (1 - campaigns.BestRate()), 2);
    }
}
Same OrderService:     False
Same CampaignRegistry: True
17.42
20.49

The two OrderService instances resolved from two different scopes are different, but both hold the same CampaignRegistry instance. The guarantee of the classic Singleton is still there, and CampaignRegistry has no private constructor, static field or locked getter. The tests use no container: each test creates its own registry with new, and they cannot leak into each other. The DI guidelines on Microsoft Learn state explicitly that the container also takes care of thread safety during creation: a singleton factory is called once, by a single thread. For the details of lifetimes, see the Dependency Injection Basics post in the C# Advanced series.

Looking at .NET's own library you see the same split. EqualityComparer<T>.Default, StringComparer.Ordinal, ArrayPool<T>.Shared, Encoding.UTF8 and TimeProvider.System are static, well-known instances, but none of them holds mutable business data. They are either completely stateless or designed to be thread-safe, and each is the default implementation of an abstraction. TimeProvider is especially instructive: before .NET 8, time was a global access point called DateTime.UtcNow and could not be frozen in tests. .NET 8 introduced the abstract TimeProvider class: in production you inject the TimeProvider.System singleton, in tests a fake provider. The singleton is still there, but it is no longer hidden.

Captive Dependencies and Shared Mutable State

The DI singleton does not automatically solve two of the classic Singleton's problems; it only makes them visible. The first is a captive dependency: a singleton that lives for the whole application taking a dependency that should live per request (such as a DbContext) in its constructor and holding on to it forever. The second is shared state inside the singleton:

using System.Globalization;
using Bookstore.Pricing;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();
services.AddScoped<StoreSession>();      // per request: like a DbContext
services.AddSingleton<PriceCache>();     // one for the whole application

try
{
    using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
    {
        ValidateScopes = true,
        ValidateOnBuild = true
    });
}
catch (AggregateException ex)
{
    foreach (Exception inner in ex.InnerExceptions)
        Console.WriteLine(inner.Message);
}

// A shared counter in a singleton: 8 threads, 1,000,000 increments
HitCounter counter = new();
Parallel.For(0, 1_000_000, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ => counter.UnsafeHit());
Parallel.For(0, 1_000_000, new ParallelOptions { MaxDegreeOfParallelism = 8 }, _ => counter.SafeHit());
Console.WriteLine($"count++:           {counter.Unsafe:N0}");
Console.WriteLine($"Interlocked:       {counter.Safe:N0}");

namespace Bookstore.Pricing
{
    public sealed class StoreSession : IDisposable
    {
        public void Dispose() { }
    }

    public sealed class PriceCache(StoreSession session)
    {
        public StoreSession Session => session;
    }

    public sealed class HitCounter
    {
        private int _unsafe;
        private int _safe;

        public int Unsafe => _unsafe;
        public int Safe => Volatile.Read(ref _safe);

        public void UnsafeHit() => _unsafe++;
        public void SafeHit() => Interlocked.Increment(ref _safe);
    }
}
Error while validating the service descriptor 'ServiceType: Bookstore.Pricing.PriceCache Lifetime: Singleton ImplementationType: Bookstore.Pricing.PriceCache': Cannot consume scoped service 'Bookstore.Pricing.StoreSession' from singleton 'Bookstore.Pricing.PriceCache'.
count++:           153,805
Interlocked:       1,000,000

With ValidateScopes and ValidateOnBuild turned on, the container catches the error while the application is still starting, and the message says exactly what is wrong. ASP.NET Core turns these validations on by default in the Development environment; the Dependency Injection post in the Web API series showed in detail what a captive dependency leads to in production.

The second line is a problem of the singleton's nature, not its lifetime. Only 153,805 of the million increments survived in this run; the number changes on every run but does not even come close to a million. _unsafe++ is not a single operation: read, add one, write. When eight threads read the same value and write the same result, the increments overwrite each other. A singleton is by definition the object every request shares; if anything inside it changes, you have to protect it with Interlocked, lock or a concurrent collection. The Synchronization post in the Async series covers these tools one by one. The best option is often to keep the singleton immutable: if the price list changes, build a new list and swap the reference in one step.

Common Pitfalls

Thinking a private constructor is an absolute lock. Activator.CreateInstance(typeof(PriceCatalog), nonPublic: true) calls the private constructor through reflection and produces a second instance. It is not a security hole, but it reminds you that what you thought was "impossible in the language" is only an agreement. Serializers and some mapping libraries can take the same route.

Forgetting that a single instance is per process. If your application runs in three pods or on two servers, there are three or two "single" price lists. When one cache is updated, the others do not know. If you truly need one thing system-wide, that is a distributed systems problem; the Singleton pattern does not solve it.

Thinking a static field in a generic type is unique. The static readonly field in Registry<T> is separate for each closed type: Registry<Book> and Registry<Order> hold two different instances. Sometimes that is what you want, sometimes it is a surprise.

Turning the Singleton into a settings store. Opening an AppSettings.Instance "so it can be reached from anywhere" and putting configuration there solves, again and worse, the problem the Options pattern already solves. In ASP.NET Core, IOptions<T> and IOptionsMonitor<T> do the same job in a testable and reloadable way.

Combining the classic Singleton and the DI singleton in the same class. If you have both services.AddSingleton<PriceCatalog>() and PriceCatalog.Instance, there are two separate instances: one owned by the container and one by the static field. Finding out which code uses which is a separate kind of fun.

When Not to Use It

You almost never need to write the classic Singleton, a private constructor plus a static Instance property, in application code. In every .NET application that uses DI, the "single instance" need is met by AddSingleton and the dependency stays explicit. The classic form remains legitimate in small corners where DI is absent or meaningless: the default instance of a stateless, cheap comparer, a cache a library uses internally and never exposes, code produced by a source generator. What they have in common is that they hold no changing business data.

Do not pick the DI singleton casually either. Microsoft's guidance recommends transient rather than singleton for services without state: creating a stateless service each time is cheap, while a singleton keeps its dependency tree in memory for the life of the application and couples unrelated requests. Keep singleton for state that is genuinely expensive to create or genuinely needs to be shared.

A Small Experiment

★☆☆ Rewrite the PriceCatalog from the first example with Lazy<T> and show from the output that the eight requests result in a single constructor call.

★★☆ Add throw new InvalidOperationException("Price file missing") to StrictCatalog's constructor. Call Describe() twice and print which exception type arrives each time and what its InnerException is. Do the same with Lazy<T> and compare the two behaviours.

★★★ Make CampaignRegistry immutable: Activate should return a new registry, and write a small CampaignStore that swaps the reference held in DI with a single atomic operation (Interlocked.Exchange or a volatile field). Prove that no campaign is lost while eight threads add campaigns at the same time.

The next post looks at creation itself: the difference between the GoF's Factory Method and the static factory methods you see everywhere, why calling a virtual method from a base class constructor is dangerous, and how the ownership question appears when you produce an IReportExporter with a factory delegate in DI.