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.