Cart
The Singleton pattern means one instance for the whole app and a global access point to it. Sounds good? It does, though it can also cause hidden coupling and testing pain if used carelessly. This post shows practical C# implementations, the thread‑safety details that matter, and safer alternatives that play nicely with DI and tests.
Good fits: pure services with no per‑request state (formatters, ID generators, config readers, in‑memory lookup tables).
Bad fits: request‑bound work, multi‑tenant data, anything that holds connections per request (e.g., DbContext
).
public sealed class AppSettings
{
private AppSettings() { /* read config files, env vars, etc. */ }
public static readonly AppSettings Instance = new AppSettings();
public string Name { get; init; } = "MyApp";
}
Pros: tiny and thread‑safe (CLR initializes type statics once).
Cons: eager creation; hard to override in tests; no DI.
public sealed class TokenCache
{
private TokenCache() { }
private static readonly Lazy<TokenCache> _lazy =
new(() => new TokenCache(), isThreadSafe: true);
public static TokenCache Instance => _lazy.Value;
private readonly ConcurrentDictionary<string, string> _tokens = new();
public void Set(string key, string token) => _tokens[key] = token;
public bool TryGet(string key, out string token) => _tokens.TryGetValue(key, out token);
}
Pros: creates on first use; handles races for you.
Cons: still global; hard to substitute in tests.
Lazy<T>
)public sealed class Metrics
{
private static Metrics? _instance;
private static readonly object _lock = new();
private Metrics() { }
public static Metrics Instance
{
get
{
if (_instance is null) // first check
{
lock (_lock)
{
_instance ??= new Metrics(); // second check
}
}
return _instance;
}
}
}
Note: use Lazy<T>
instead. The CLR got the hard parts right already.
Let the built‑in container own the lifetime. You keep testability and can swap implementations.
Service and registration
public interface ISystemClock { DateTime UtcNow { get; } }
public sealed class SystemClock : ISystemClock { public DateTime UtcNow => DateTime.UtcNow; }
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ISystemClock, SystemClock>();
var app = builder.Build();
Minimal API endpoint
app.MapGet("/now", (ISystemClock clock) => new { utc = clock.UtcNow });
app.Run();
Benefits: one instance per host, easy to mock in tests, no global statics leaked into business code.
Singleton != thread‑safe by magic. If the object mutates shared state, guard it:
public sealed class MonotonicId
{
private long _value;
public long Next() => Interlocked.Increment(ref _value);
}
Collections: prefer ConcurrentDictionary
/ConcurrentQueue
or protect standard collections with a lock.
Avoid doing I/O or async work in constructors. If you must initialize asynchronously, use an explicit InitAsync()
that runs during startup.
public interface IWarmup { Task InitAsync(CancellationToken ct = default); }
public sealed class HeavyLookup : IWarmup, IAsyncDisposable
{
public async Task InitAsync(CancellationToken ct = default)
{
// load data here
await Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
Register and warm up on start:
builder.Services.AddSingleton<HeavyLookup>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var warm = scope.ServiceProvider.GetRequiredService<HeavyLookup>();
await warm.InitAsync();
}
app.Run();
If your singleton implements IDisposable
/IAsyncDisposable
, and you register it with DI, ASP.NET Core will dispose it when the host shuts down. Don’t forget to release timers, file handles, and sockets there.
public sealed class FakeClock : ISystemClock
{
public DateTime UtcNow { get; set; } = new(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc);
}
[Fact]
public void Endpoint_uses_injected_clock()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<ISystemClock>(new FakeClock());
var app = builder.Build();
// call the handler directly (no server needed)
var clock = app.Services.GetRequiredService<ISystemClock>();
clock.UtcNow.ShouldBe(new DateTime(2025, 1, 1, 12, 0, 0, DateTimeKind.Utc));
}
Statically held instances are hard to replace. If you must, add an internal setter guarded for tests:
public sealed class Config
{
private Config() { }
private static readonly Lazy<Config> _lazy = new(() => new Config());
public static Config Instance { get; internal set; } = _lazy.Value;
}
Now your tests can do Config.Instance = new TestConfig();
. Prefer DI instead.
Use Shouldly for assertions if you want natural syntax (no extra license constraints).
Class.Instance
.DbContext
→ either make it scoped or inject a factory.ISystemClock
), random ID generators.If you feel tempted to put a repository or an ORM context in a singleton, that’s a smell.
Service
public interface IFeatureFlags { bool IsEnabled(string key); }
public sealed class InMemoryFlags(IDictionary<string, bool> flags) : IFeatureFlags
{
private readonly IReadOnlyDictionary<string, bool> _flags = new Dictionary<string, bool>(flags);
public bool IsEnabled(string key) => _flags.TryGetValue(key, out var on) && on;
}
Registration
var flags = new Dictionary<string, bool>
{
["checkout:new-flow"] = true,
["search:beta"] = false
};
builder.Services.AddSingleton<IFeatureFlags>(_ => new InMemoryFlags(flags));
Endpoint
app.MapGet("/flags/{key}", (string key, IFeatureFlags ff) => new { key, enabled = ff.IsEnabled(key) });
One instance shared, easy to replace in tests, no static globals.
Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.
He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.
Nick creates free content on YouTube and is the host of the Keep Coding Podcast.
More courses by Nick Chapsas© 2025 Dometrain. All rights reserved.