Imagine that every class that needs to log creates its own logger. Dozens of instances, all writing to the same file: conflicts, corruption, wasted memory. The Singleton pattern exists for exactly this: a class has one and only one instance, with a global point of access to it.

The intent is simple. Getting it right, especially with many threads, is where it gets interesting.

The naive version and its trap

public class Logger { private static Logger? _instance; private Logger() { } public static Logger Instance { get { if (_instance == null) { _instance = new Logger(); } return _instance; } } public void Log(string message) => Console.WriteLine($"[{DateTime.Now}] {message}"); } 

It works with one thread. But when two threads call Logger.Instance at the same time, both can see null and both create an instance. Your Singleton isn't single anymore.

A lock fixes it, but then every access pays for the lock. Double-checked locking tries to avoid that cost and is notoriously easy to get wrong. In modern .NET you need neither.

Thread-safe, the easy way

Lazy<T> takes care of thread safety and lazy creation:

public sealed class LazyLogger { private static readonly Lazy<LazyLogger> _lazy = new(() => new LazyLogger()); private LazyLogger() { } public static LazyLogger Instance => _lazy.Value; public void Log(string message) => Console.WriteLine($"[{DateTime.Now}] {message}"); } 

A static read-only property is even shorter: the runtime guarantees that static initialization runs once, safely.

public sealed class StaticLogger { public static StaticLogger Instance { get; } = new(); private StaticLogger() { } } 

The difference: Lazy<T> creates the instance the first time someone reads Instance, while the static version creates it when the type is initialized. And mark the class sealed, so no derived class can create more instances.

In modern .NET, let the container own it

With the built-in DI container, "only one instance" is just a lifetime:

public interface IPriceCalculator { decimal Total(decimal net, decimal vatRate); } public class PriceCalculator : IPriceCalculator { public decimal Total(decimal net, decimal vatRate) => net * (1 + vatRate); } 
builder.Services.AddSingleton<IPriceCalculator, PriceCalculator>(); 

The class has no static state and a normal constructor, and in tests you can replace it with a fake. The container creates it once, shares it, and disposes it at shutdown if it's disposable.

One trap to remember: a singleton must not depend on a scoped service, such as a DbContext. It would keep that scoped instance alive for the whole application. In the Development environment, the default host validates scopes and throws an exception when it finds this.

When to use it, and when to avoid it

Use a single shared instance (preferably registered in the container) for:

  • expensive, shareable resources;
  • logging and metrics;
  • read-only configuration.

Keep the classic static Singleton for the cases where there's no container, such as libraries or legacy code.

Avoid it:

  • for objects with mutable state: shared mutable state plus many threads means hard-to-reproduce bugs;
  • when testability matters: a static Instance is a hidden global dependency.

Want the full story?

This is the short version. The complete article on Level Up Coding follows the Singleton through three decades: the Gang of Four definition, the multi-threading problems, double-checked locking and volatile, Lazy<T> in .NET Framework 4.0, the arrival of Dependency Injection, singletons in cloud-native and distributed systems, and a recommended modern implementation.

Read it here: The Singleton Pattern in .NET C#: Evolution, challenges, and modern best practices.

Thanks for reading.