Singleton combines two decisions: keep one instance within a defined boundary and provide access to it. The boundary is easy to miss. A classical static implementation owns a process-level access point, while .AddSingleton<T>() reuses one instance for subsequent resolutions from a particular root service provider and registration. A second provider or direct construction can still create another instance.

Application services normally need container-managed lifetime rather than global static access. Constructor injection keeps the dependency visible, lets the container dispose it, and exposes lifetime mistakes during validation. The classical form remains relevant when a library genuinely owns a single access point without a DI container.

flowchart TD
    subgraph Classical Singleton
        PrivateCtor["private constructor"] --> StaticInstance["static instance field"]
        StaticInstance --> GlobalAccess["global access point"]
    end
    subgraph Modern DI Singleton
        Registration["AddSingleton of T"] --> Container["DI Container"]
        Container -->|manages lifetime| SingleInstance["single instance"]
        Container -->|injects into| ServiceA["Service A"]
        Container -->|injects into| ServiceB["Service B"]
    end

Problem

The classical form below enforces access through AppConfig.Instance, but it also hides the dependency from every consumer:

// Classical Singleton — the pattern most tutorials show
public class AppConfig
{
    private static AppConfig? _instance;
    private static readonly object _lock = new();
 
    // ⚠️ Private constructor prevents DI container from creating instances
    private AppConfig()
    {
        ConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION")!;
        MaxOrdersPerHour = int.Parse(Environment.GetEnvironmentVariable("MAX_ORDERS") ?? "100");
    }
 
    // ⚠️ Double-checked locking — easy to get wrong, unnecessary with Lazy<T>
    public static AppConfig Instance
    {
        get
        {
            if (_instance is null)
            {
                lock (_lock)
                {
                    _instance ??= new AppConfig();
                }
            }
            return _instance;
        }
    }
 
    public string ConnectionString { get; }
    public int MaxOrdersPerHour { get; }
}
 
public class OrderService
{
    public async Task PlaceOrderAsync(Order order)
    {
        // ⚠️ Hidden dependency — not visible in constructor, can't be mocked in tests
        var config = AppConfig.Instance;
        if (await GetOrderCountLastHourAsync(order.Customer.Id) >= config.MaxOrdersPerHour)
            throw new RateLimitException("Order rate limit exceeded");
        // ...
    }
}

OrderService can no longer declare or replace the configuration it uses. Tests inherit environment access and shared state, and lifetime policy is fixed inside the dependency instead of at the composition root.

Solution

Register a normal service with singleton lifetime and inject it. The container controls creation and disposal. The consumer only knows its contract.

// ✅ Plain class — no static members, no private constructor
public interface IAppConfig
{
    string ConnectionString { get; }
    int MaxOrdersPerHour { get; }
}
 
public class AppConfig : IAppConfig
{
    public string ConnectionString { get; init; }
    public int MaxOrdersPerHour { get; init; }
 
    public AppConfig(IConfiguration configuration)
    {
        ConnectionString = configuration.GetConnectionString("Default")
            ?? throw new InvalidOperationException("DB connection string not configured");
        MaxOrdersPerHour = configuration.GetValue<int>("RateLimiting:MaxOrdersPerHour", 100);
    }
}
 
// ✅ Register as singleton in DI — one instance for this service contract
builder.Services.AddSingleton<IAppConfig, AppConfig>();
 
// ✅ OrderService declares its dependency explicitly
public class OrderService(IAppConfig config, IOrderRepository repository)
{
    public async Task PlaceOrderAsync(Order order)
    {
        // ✅ config is injected — can be mocked in tests
        if (await repository.GetOrderCountLastHourAsync(order.Customer.Id) >= config.MaxOrdersPerHour)
            throw new RateLimitException("Order rate limit exceeded");
        await repository.SaveAsync(order);
    }
}
 
// ✅ Test: inject a mock config with controlled values
[Fact]
public async Task PlaceOrder_ExceedsRateLimit_Throws()
{
    var config = Substitute.For<IAppConfig>();
    config.MaxOrdersPerHour.Returns(5);
    var repository = Substitute.For<IOrderRepository>();
    repository.GetOrderCountLastHourAsync(Arg.Any<Guid>()).Returns(5);
 
    var service = new OrderService(config, repository);
    await Assert.ThrowsAsync<RateLimitException>(() =>
        service.PlaceOrderAsync(new Order { Customer = new Customer { Id = Guid.NewGuid() } }));
}
 
// When you genuinely need lazy initialization (e.g., expensive resource):
public class ExpensiveConnectionPool
{
    // ✅ Lazy<T> is thread-safe by default, no manual locking needed
    private static readonly Lazy<ExpensiveConnectionPool> _instance =
        new(() => new ExpensiveConnectionPool());
 
    public static ExpensiveConnectionPool Instance => _instance.Value;
    private ExpensiveConnectionPool() { /* expensive initialization */ }
}

Singleton Lifetime in .NET

services.AddSingleton<T>() caches one service instance in the root provider and returns it for later resolutions. This is a lifetime rule, not proof that no other instance can exist.

Lazy<T> supplies thread-safe deferred initialization for a classical implementation. It removes hand-written double-checked locking, but it does not remove global state or hidden dependencies.

Singleton services may be stateless or hold shared state. Either way, their implementations and any mutable dependencies must be safe for concurrent callers.

Pitfalls

Captive dependency. A singleton that constructor-injects a scoped service keeps that instance beyond its intended scope. Scope validation rejects this graph. When work genuinely needs scoped state, create and dispose an explicit scope for the operation or move the operation to a scoped service.

Shared mutable state. One instance may serve concurrent requests. The container makes resolution thread-safe. It does not make the service’s fields or dependencies thread-safe.

Oversized lifetime. A singleton retains its dependency graph until the provider is disposed. Large caches, failed state, or request-specific data can then survive far longer than intended.

Multiple roots. Calling BuildServiceProvider during registration creates another container and therefore another singleton set. Keep one composition root and avoid static service locators.

Questions

References