CodeOath
← All posts
Architecture & Patterns80 min total · 26 parts

ACID, SOLID, and Common Design Patterns: A Software Design Reference

Contents — Part 15 of 26: Creational Patterns: Singleton and Its Pitfalls
Part 15 of 26 · ~2 min

Creational Patterns: Singleton and Its Pitfalls

Singleton guarantees a class has exactly one instance, with one global access point to it.

public sealed class AppConfig
{
    private static readonly Lazy<AppConfig> _instance = new(() => new AppConfig());
    public static AppConfig Instance => _instance.Value;

    public string ConnectionString { get; }

    private AppConfig() // private constructor — nobody else can call `new AppConfig()`
    {
        ConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION") ?? "";
    }
}

// Used anywhere in the codebase without passing AppConfig around explicitly.
var connStr = AppConfig.Instance.ConnectionString;

Lazy<T> makes this thread-safe without manual locking — the underlying Lazy<AppConfig> guarantees the factory delegate runs exactly once even under concurrent first access.

Singleton is simultaneously one of the most-taught and most over-applied patterns, and it's worth being explicit about why: it introduces hidden global state. Any class that reaches for AppConfig.Instance directly has an invisible dependency that doesn't show up in its constructor signature — you can't tell what a class actually needs just by reading how it's constructed, and unit tests can't substitute a fake configuration without some kind of static-state workaround, because the dependency was never injected in the first place.

// A class silently coupled to global state — its dependency on AppConfig
// doesn't appear anywhere in its public constructor.
public class ReportService
{
    public void Generate()
    {
        var connStr = AppConfig.Instance.ConnectionString; // hidden dependency
        // ...
    }
}

// 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 ReportService
{
    private readonly AppConfig _config;
    public ReportService(AppConfig config) => _config = config; // visible, testable
    public void Generate()
    {
        var connStr = _config.ConnectionString;
    }
}

The second version still has exactly one instance of AppConfig for the app's lifetime — that part of the Singleton pattern's intent is preserved — but achieves it through the DI container's lifetime management rather than a static accessor, keeping the dependency explicit and the class unit-testable.