How to Implement Singleton Pattern in C#

Introduction

If you've ever needed to ensure only one instance of a class exists throughout your application, you've encountered the problem the Singleton pattern solves. Whether it's a logger that all components share, a configuration manager that loads settings once, or a connection pool that manages database connections, some objects should exist exactly once.

The Singleton pattern guarantees a class has only one instance and provides a global access point to it. This prevents multiple instances from consuming unnecessary resources or creating inconsistent state. Instead of creating new objects repeatedly, your application reuses the same instance everywhere.

You'll learn multiple ways to implement Singletons in C#, starting with basic approaches and progressing to thread-safe implementations using locks, eager initialization, and the Lazy class. You'll also see how modern dependency injection provides better alternatives for many scenarios.

Basic Singleton Implementation

The simplest Singleton uses a private constructor to prevent external instantiation and a static property to provide access to the single instance. This version works fine in single-threaded applications but fails when multiple threads try to access the instance simultaneously.

The private static field holds the single instance. The public static property checks if the instance exists, creates it if necessary, and returns it. The private constructor ensures no other code can create instances using the new keyword.

BasicSingleton.cs
public class Logger
{
    private static Logger? instance;

    private Logger()
    {
        // Private constructor prevents instantiation
    }

    public static Logger Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new Logger();
            }
            return instance;
        }
    }

    public void Log(string message)
    {
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}");
    }
}

// Usage
Logger.Instance.Log("Application started");
Logger.Instance.Log("Processing data");

var logger1 = Logger.Instance;
var logger2 = Logger.Instance;

Console.WriteLine($"Same instance? {ReferenceEquals(logger1, logger2)}");

This implementation is not thread-safe. If two threads check the instance at the same time when it's null, both might create separate instances. The first thread might be halfway through constructing the object when the second thread checks and sees instance is still null.

Thread-Safe Singleton with Double-Check Locking

To make the Singleton thread-safe, you need to prevent multiple threads from creating instances simultaneously. The double-check locking pattern uses a lock to ensure only one thread can create the instance, but checks the instance twice to avoid locking on every access.

The pattern checks if the instance is null before acquiring the lock. If it's not null, it returns immediately without locking. If it is null, it acquires the lock, checks again inside the lock, and creates the instance if still null. The second check is necessary because another thread might have created the instance while this thread was waiting for the lock.

ThreadSafeSingleton.cs
public class ConfigurationManager
{
    private static ConfigurationManager? instance;
    private static readonly object lockObject = new object();

    private readonly Dictionary<string, string> settings;

    private ConfigurationManager()
    {
        // Load configuration from file or database
        settings = new Dictionary<string, string>
        {
            ["AppName"] = "MyApplication",
            ["Version"] = "1.0.0",
            ["MaxConnections"] = "100"
        };
    }

    public static ConfigurationManager Instance
    {
        get
        {
            if (instance == null)
            {
                lock (lockObject)
                {
                    if (instance == null)
                    {
                        instance = new ConfigurationManager();
                    }
                }
            }
            return instance;
        }
    }

    public string GetSetting(string key)
    {
        return settings.TryGetValue(key, out var value) ? value : string.Empty;
    }

    public void SetSetting(string key, string value)
    {
        settings[key] = value;
    }
}

// Usage
var config = ConfigurationManager.Instance;
Console.WriteLine($"App Name: {config.GetSetting("AppName")}");
Console.WriteLine($"Version: {config.GetSetting("Version")}");

The lock statement ensures mutual exclusion, meaning only one thread can be inside the locked section at a time. The second null check inside the lock prevents a race condition where multiple threads pass the first check, then queue up at the lock. Without the second check, each waiting thread would create a new instance when it acquires the lock.

Eager Initialization Singleton

Instead of creating the instance on first access, you can create it when the class loads. The CLR guarantees static constructors run only once and are thread-safe, so this approach is inherently safe without explicit locking.

The downside is that the instance is created even if you never use it. For expensive objects or objects that depend on runtime configuration, lazy initialization is better. For simple objects, eager initialization eliminates the complexity of thread-safe lazy initialization.

EagerSingleton.cs
public class DatabaseConnectionPool
{
    private static readonly DatabaseConnectionPool instance =
        new DatabaseConnectionPool();

    private readonly List<string> availableConnections;

    private DatabaseConnectionPool()
    {
        availableConnections = new List<string>();
        for (int i = 0; i < 10; i++)
        {
            availableConnections.Add($"Connection_{i}");
        }
        Console.WriteLine("Connection pool initialized with 10 connections");
    }

    public static DatabaseConnectionPool Instance => instance;

    public string AcquireConnection()
    {
        if (availableConnections.Count > 0)
        {
            var conn = availableConnections[0];
            availableConnections.RemoveAt(0);
            return conn;
        }
        throw new InvalidOperationException("No connections available");
    }

    public void ReleaseConnection(string connection)
    {
        availableConnections.Add(connection);
    }

    public int AvailableCount => availableConnections.Count;
}

// Usage
var pool = DatabaseConnectionPool.Instance;
var conn1 = pool.AcquireConnection();
Console.WriteLine($"Acquired: {conn1}");
Console.WriteLine($"Available connections: {pool.AvailableCount}");
pool.ReleaseConnection(conn1);
Console.WriteLine($"After release: {pool.AvailableCount}");

The static readonly field is initialized when the class is first referenced. The C# compiler and CLR handle thread safety automatically for static initialization, making this the simplest thread-safe Singleton pattern. The expression-bodied property provides clean access to the instance.

Lazy Initialization with Lazy<T>

The Lazy<T> class provides built-in thread-safe lazy initialization. It handles all the complexity of double-check locking and ensures the value factory runs exactly once, even when multiple threads try to access the value simultaneously.

This is the recommended approach for most modern C# applications. It combines the benefits of lazy initialization with guaranteed thread safety, and the code is cleaner than manual locking implementations.

LazySingleton.cs
public class CacheManager
{
    private static readonly Lazy<CacheManager> instance =
        new Lazy<CacheManager>(() => new CacheManager());

    private readonly Dictionary<string, object> cache;

    private CacheManager()
    {
        cache = new Dictionary<string, object>();
        Console.WriteLine("Cache manager initialized");
    }

    public static CacheManager Instance => instance.Value;

    public void Set(string key, object value)
    {
        cache[key] = value;
    }

    public T Get<T>(string key)
    {
        if (cache.TryGetValue(key, out var value) && value is T typedValue)
        {
            return typedValue;
        }
        throw new KeyNotFoundException($"Key '{key}' not found in cache");
    }

    public bool TryGet<T>(string key, out T value)
    {
        if (cache.TryGetValue(key, out var obj) && obj is T typedValue)
        {
            value = typedValue;
            return true;
        }
        value = default!;
        return false;
    }

    public void Clear()
    {
        cache.Clear();
    }
}

// Usage
var cache = CacheManager.Instance;
cache.Set("user_123", "John Doe");
cache.Set("timeout", 3600);

if (cache.TryGet<string>("user_123", out var username))
{
    Console.WriteLine($"Found user: {username}");
}

Console.WriteLine($"Timeout: {cache.Get<int>("timeout")} seconds");

The Lazy<T> constructor takes a factory function that creates the instance. Accessing the Value property triggers initialization if it hasn't happened yet. Subsequent accesses return the cached value immediately. The default thread safety mode ensures proper synchronization across threads.

Modern Alternative with Dependency Injection

In modern ASP.NET Core and .NET applications, dependency injection provides a better approach than implementing Singletons manually. You register your service as a singleton with the DI container, and the container manages the lifetime and provides the instance where needed.

This approach offers better testability because you can easily replace the service with a mock implementation. It also makes dependencies explicit through constructor injection, improving code clarity and maintainability.

Program.cs
using Microsoft.Extensions.DependencyInjection;

// Define the service interface
public interface ILogger
{
    void Log(string message);
}

public class ConsoleLogger : ILogger
{
    private readonly int instanceId;

    public ConsoleLogger()
    {
        instanceId = Random.Shared.Next(1000, 9999);
        Console.WriteLine($"Logger instance {instanceId} created");
    }

    public void Log(string message)
    {
        Console.WriteLine($"[Logger {instanceId}] {message}");
    }
}

// Configure DI container
var services = new ServiceCollection();
services.AddSingleton<ILogger, ConsoleLogger>();

var provider = services.BuildServiceProvider();

// Get instances
var logger1 = provider.GetRequiredService<ILogger>();
var logger2 = provider.GetRequiredService<ILogger>();

logger1.Log("First message");
logger2.Log("Second message");

Console.WriteLine($"Same instance? {ReferenceEquals(logger1, logger2)}");

The AddSingleton method registers the service with singleton lifetime. The container creates one instance on first request and reuses it for all subsequent requests. This gives you Singleton behavior without implementing the pattern manually, and you gain all the benefits of dependency injection.

Try It Yourself

Here's a complete example demonstrating the Lazy<T> approach with a practical application settings manager.

Program.cs
using System;

public class AppSettings
{
    private static readonly Lazy<AppSettings> instance =
        new Lazy<AppSettings>(() => new AppSettings());

    public static AppSettings Instance => instance.Value;

    public string DatabaseConnection { get; set; }
    public int MaxRetries { get; set; }
    public bool DebugMode { get; set; }

    private AppSettings()
    {
        DatabaseConnection = "Server=localhost;Database=MyApp";
        MaxRetries = 3;
        DebugMode = true;
        Console.WriteLine("Settings loaded from configuration");
    }
}

// Test the singleton
var settings1 = AppSettings.Instance;
var settings2 = AppSettings.Instance;

Console.WriteLine($"\nAre both references same? {ReferenceEquals(settings1, settings2)}");
Console.WriteLine($"Database: {settings1.DatabaseConnection}");
Console.WriteLine($"Max Retries: {settings1.MaxRetries}");
Console.WriteLine($"Debug Mode: {settings1.DebugMode}");

settings1.MaxRetries = 5;
Console.WriteLine($"\nAfter change, settings2.MaxRetries = {settings2.MaxRetries}");
Project.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output:

Settings loaded from configuration

Are both references same? True
Database: Server=localhost;Database=MyApp
Max Retries: 3
Debug Mode: True

After change, settings2.MaxRetries = 5

Run with dotnet run. The output confirms both variables reference the same instance, and changes through one variable affect the other. The initialization message appears only once, proving lazy initialization works correctly.

When Not to Use Singletons

Singletons introduce global state, which can make testing difficult and hide dependencies. If a class uses a Singleton directly, you can't easily replace it with a mock during tests. This tight coupling reduces flexibility and makes unit tests harder to write.

Avoid Singletons when dependency injection can solve the problem. In modern applications with DI containers, register your service as a singleton in the container instead of implementing the Singleton pattern. This gives you the same single-instance behavior with better testability and looser coupling.

Don't use Singletons to avoid passing parameters. If you're tempted to make something a Singleton just to avoid threading an object through multiple method calls, you're probably hiding a design problem. Consider restructuring your code or using dependency injection to make dependencies explicit.

Singletons can cause problems in multi-tenant applications where different tenants need isolated instances. What seems like a single shared resource might actually need per-tenant instances. In these scenarios, scoped or transient lifetimes in a DI container provide better solutions.

Frequently Asked Questions (FAQ)

When should I use the Singleton pattern?

Use Singleton for truly shared resources like logging systems, configuration managers, or database connection pools. Avoid it when dependency injection can provide better testability and flexibility. In modern .NET applications, prefer registering services as singletons in the DI container rather than implementing the pattern manually.

Is the Lazy<T> approach thread-safe?

Yes, Lazy<T> is thread-safe by default using LazyThreadSafetyMode.ExecutionAndPublication. This ensures only one thread creates the instance and all other threads wait for completion. You can change the thread safety mode if needed, but the default behavior handles concurrent access correctly.

How do I test code that uses Singletons?

Testing Singletons is challenging because you can't easily replace them with mocks. Extract an interface from your Singleton and use that interface throughout your code. In tests, provide mock implementations instead of the real Singleton. Better yet, use dependency injection which makes replacing implementations trivial during testing.

Back to Articles