
Design Patterns in C#: Singleton
Learn everything there is to know about the Singleton Design Pattern
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.
© 2025 Dometrain. All rights reserved.