Architecture & Patterns110 min total · 26 parts
ACID, SOLID, and Design Patterns: A Complete Software Design Reference
Part 15 of 26 · ~2 min
Creational Patterns: Singleton and Its Pitfalls
Singleton pins a class down to a single instance for the application's entire lifetime, and hands out one global door to reach it.
public sealed class ShiftDeskConfig
{
private static readonly Lazy<ShiftDeskConfig> _instance = new(() => new ShiftDeskConfig());
public static ShiftDeskConfig Instance => _instance.Value;
public TimeZoneInfo OrgTimeZone { get; }
private ShiftDeskConfig() // private constructor — nobody else can call `new ShiftDeskConfig()`
{
OrgTimeZone = TimeZoneInfo.FindSystemTimeZoneById(Environment.GetEnvironmentVariable("ORG_TZ") ?? "UTC");
}
}
// Used anywhere in the codebase without threading ShiftDeskConfig through every constructor.
var tz = ShiftDeskConfig.Instance.OrgTimeZone;
Nobody had to write a lock statement anywhere in there for this to be safe under concurrent access — Lazy<T> handles that on its own, guaranteeing the delegate that builds ShiftDeskConfig fires exactly once no matter how many requests happen to hit .Instance for the first time simultaneously.
Few patterns get assigned in more tutorials, and few get reached for more inappropriately in real projects, so it's worth naming the actual cost plainly: it smuggles in state that nothing in a class's public surface admits to depending on. The moment some class reaches for ShiftDeskConfig.Instance mid-method, it has acquired a dependency its own constructor never disclosed. Read the signature and there's no way to know this class cares about the org's timezone at all — and a test that needs a different timezone has no clean lever to pull, because nothing was ever handed in for it to swap out.
// A class silently coupled to global state — its dependency on
// ShiftDeskConfig doesn't appear anywhere in its public constructor.
public class LockWindowCalculator
{
public bool IsLocked(DateTime shiftStart)
{
var now = TimeZoneInfo.ConvertTime(DateTime.UtcNow, ShiftDeskConfig.Instance.OrgTimeZone); // hidden dependency
return (shiftStart - now) < TimeSpan.FromHours(24);
}
}
// Preferred in most real applications: let a DI container manage a single
// instance's lifetime (a "singleton service"), but still inject it
// explicitly, so the dependency is visible and swappable in tests.
public class LockWindowCalculator
{
private readonly ShiftDeskConfig _config;
public LockWindowCalculator(ShiftDeskConfig config) => _config = config; // visible, testable
public bool IsLocked(DateTime shiftStart)
{
var now = TimeZoneInfo.ConvertTime(DateTime.UtcNow, _config.OrgTimeZone);
return (shiftStart - now) < TimeSpan.FromHours(24);
}
}
Notice what didn't change: there's still exactly one ShiftDeskConfig object alive for as long as the app runs, so the goal Singleton was chasing is fully met. What's different is how that one-instance guarantee gets delivered — through a container's lifetime policy instead of a hardcoded static field — and that shift alone is what makes LockWindowCalculator honest about what it needs and easy to test with a fake timezone.