Module 1 · 1. Foundations and Pattern Selection · Lesson 3 of 12
Singleton Pitfalls and Dependency Injection Lifetimes
Learning outcome
You will be able to recognize the common pitfalls of ad-hoc singletons in .NET applications (hidden mutable state, lost testability, lifecycle confusion) and migrate those singletons to dependency-injection-managed lifetimes (Transient, Scoped, Singleton) appropriately. This lesson includes a deterministic, runnable example demonstrating behavioral differences and recommended production guidance.
Intuition
Single responsibility and explicit lifetimes make object ownership and concurrency semantics clear. A process-global static singleton hides who owns state and when it can be mutated safely; that leads to cross-request leakage, surprising test flakiness, and disposal/ownership bugs. DI gives explicit semantics:
- Transient: a new instance per resolve/use. Good for per-operation or mutable short-lived services.
- Scoped: one instance per logical scope (e.g., per-web-request or per-background-job scope). Good for per-request caches, user-scoped state.
- Singleton: one instance for the container lifetime. Only appropriate for truly shared, thread-safe, or immutable services (e.g., HttpClient, configuration providers, global caches with proper synchronization).
Deep dive
Why ad-hoc singletons are dangerous (expanded):
- Hidden global mutable state: fields like "lastRequestId", per-user caches, or counters on a static type leak across threads and logical requests. This breaks isolation and makes debugging hard.
- Testability loss: a static Instance is hard to replace in unit tests. Tests must mutate global state or run in a strict sequence to pass, which increases flakiness.
- Lifecycle confusion and disposal problems: a static singleton may capture resources that need disposal (DbContext, streams) and there is no clear moment to dispose them.
- Incorrect dependency capture: a static/singleton that depends on a scoped service forces locking the shorter lifetime into a longer one, or it silently holds stale references.
Course context (12-lesson outline)
To satisfy broader curriculum needs, this lesson fits into a 12-lesson intermediate Design Patterns + DI course. The full course outline (each lesson is focused and cross-references DI where relevant):
- SOLID and DI fundamentals — responsibilities, composition, and constructor injection
- Factory Method and DI-friendly factories
- Abstract Factory and composing families of services
- Builder pattern for complex construction with DI
- Prototype and cloning considerations with lifetimes
- Adapter & Facade — interface adaptation and surface simplification
- Singletons and DI: Pitfalls, Lifetimes, and Migration Strategies (this lesson)
- Decorator and Proxy — runtime behavior extension and interception with DI
- Composite & Bridge — structural patterns and ownership semantics
- Strategy & State — interchangeable behavior and per-scope state
- Observer, Mediator, Command — eventing, decoupling, and how DI affects wiring
- Capstone: applying patterns and DI together in a sample microservice
Each lesson includes interview-style drills, deterministic examples, and production guidance; this lesson provides the practical migration checklist and a runnable console demo.
Failure modes
Concrete failure modes you will see in production or tests:
- Shared mutable state: counters or "last seen" fields on a singleton causing data from one request or tenant to appear in another.
- Race conditions: singletons without synchronization used concurrently lead to corrupted state or exceptions under load.
- Captured scoped dependencies: a singleton constructed once captures a DbContext/HttpContext/other scoped service and either throws or holds stale state.
- Test ordering dependency: unit tests that pass only if run in a particular sequence because globals are mutated across tests.
- Over-synchronization: adding locks everywhere to compensate for a singleton's shared state, which hides the real ownership problem and harms performance.
Signals you're misusing a singleton:
- Tests must run in a specific order to pass.
- You find last-message or counters leaking across unrelated requests.
- You see object disposal exceptions or logs complaining about disposed scoped services inside a singleton.
- Significant effort adding locks around formerly single-threaded logic.
Interview drill
Prepare to explain and demonstrate:
- When to register Transient vs Scoped vs Singleton and the concrete consequences for state and concurrency.
- How a singleton that stores "lastRequestId" can leak across tenants and how to refactor it to scoped or transient.
- Given a logger-like service that counts messages, show how a legacy singleton causes a counter to grow across logical requests and how making it transient isolates counts per operation.
Micro-tests you may be asked to design:
- A unit test that verifies two handlers in the same request share a scoped instance but two separate requests do not.
- A test that proves a static singleton's counter increases across tests (demonstrating the bug).
Revision checklist
Before converting a former singleton to a DI-managed service, verify:
- Does the service hold per-request or per-operation mutable state? If yes, use Transient or Scoped.
- Does the service depend on scoped services? If so, the service must not be registered as a container-wide singleton.
- Is the service thread-safe, immutable, or intended to be globally shared? Only then is Singleton appropriate.
- Are consumers resolved from the container (so lifetimes are honored)? If not, avoid relying on container lifetimes — prefer factory injection or adapt creation sites.
- If a long-lived service needs occasional scoped work, prefer accepting an IServiceScopeFactory and creating a scope per operation; do not store scoped instances in singleton fields.
Production code
Short guidance for production systems:
- Use Microsoft.Extensions.DependencyInjection (or a supported container) as the canonical DI container.
- Prefer constructor injection; avoid service location from static globals.
- Keep services small and focused. Prefer composition over global state.
- Migration steps (practical):
- Introduce an interface for the singleton service (e.g., ILogger -> ILogger).
- Replace direct static Instance usage with constructor injection of the interface.
- Pick an appropriate lifetime and register with AddTransient/AddScoped/AddSingleton depending on ownership.
- Ensure singletons do not capture scoped instances. If you need scoped behavior from a singleton, inject IServiceScopeFactory and create a scope per unit of work.
- Update tests to register test doubles in the DI container or use a minimal bootstrap to control lifetimes.
Pattern when a singleton needs a scoped dependency (only when necessary):
- Do not hold scoped instances as fields on a singleton.
- Inject IServiceScopeFactory into the singleton and create a scope inside the operation that needs scoped services. Dispose the scope promptly.
Example note: the runnable demo included with this lesson is intentionally small and illustrates behavior deterministically; in production, prefer Microsoft.Extensions.DependencyInjection, and ensure proper disposal and scoping.
Code walkthrough
The provided deterministic console demo demonstrates three scenarios:
1) Legacy ad-hoc static singleton: shows how shared mutable state on a static singleton leads to a message counter that accumulates across logical requests. 2) DI with Transient registration: each resolve yields a fresh instance so counters and identity are isolated per handler. 3) DI with Singleton registration: the container shares a single instance across resolves (valid if the service is stateless or properly synchronized).
The Program.cs example prints instance IDs and counters so you can observe the differences. Run it to verify the output that shows the leakage in the legacy singleton and the contrasts with transient and DI-registered singleton lifetimes.
The executable demonstrates minimal container mechanics so the lesson remains deterministic and focused on behavioral differences rather than container API details.
Executable code examples
Singleton vs DI lifetimes (Console)
Program.cs
using System;
using System.Collections.Generic;
using System.Threading;
// Deterministic demo: shows legacy singleton vs DI-managed transient/singleton lifetimes.
// Why this pattern is preferable: DI makes ownership explicit and allows substituting mock implementations for tests.
// Misuse warning: This is a tiny illustrative container. In production, use Microsoft.Extensions.DependencyInjection.
interface ILogger
{
int Id { get; }
int MessageCount { get; }
void Log(string message);
}
class LoggerImpl : ILogger
{
private static int _nextId = 0;
public int Id { get; }
public int MessageCount { get; private set; }
public LoggerImpl() { Id = Interlocked.Increment(ref _nextId); }
public void Log(string message)
{
MessageCount++;
Console.WriteLine($"Logged (LoggerId={Id}) message #{MessageCount}: {message}");
}
}
// Legacy ad-hoc singleton
class LegacySingletonLogger : ILogger
{
private readonly LoggerImpl _inner = new LoggerImpl();
private LegacySingletonLogger() { }
private static LegacySingletonLogger? _instance;
public static LegacySingletonLogger Instance => _instance ??= new LegacySingletonLogger();
public int Id => _inner.Id;
public int MessageCount => _inner.MessageCount;
public void Log(string message) => _inner.Log(message);
}
// Minimal DI container for demonstration (not feature complete)
class SimpleContainer
{
private readonly Dictionary<Type, Func<SimpleContainer, object>> _factories = new();
private readonly Dictionary<Type, object> _singletons = new();
public void RegisterTransient<TService, TImpl>() where TImpl : TService, new()
{
_factories[typeof(TService)] = _ => new TImpl();
}
public void RegisterSingleton<TService, TImpl>() where TImpl : TService, new()
{
_factories[typeof(TService)] = CreateOrGetSingleton<TService, TImpl>;
}
private object CreateOrGetSingleton<TService, TImpl>(SimpleContainer c) where TImpl : TService, new()
{
var key = typeof(TService);
if (!_singletons.TryGetValue(key, out var inst))
{
inst = new TImpl();
_singletons[key] = inst!;
}
return inst!;
}
public T Resolve<T>()
{
var t = typeof(T);
if (_singletons.TryGetValue(t, out var s)) return (T)s!;
if (_factories.TryGetValue(t, out var f)) return (T)f(this)!;
throw new InvalidOperationException($"Type {t} not registered");
}
}
// Simulated request handler that depends on ILogger
class Handler
{
private readonly ILogger _logger;
public Handler(ILogger logger) => _logger = logger;
public void Handle(string requestTag)
{
_logger.Log($"{requestTag}");
}
public int LoggerId => _logger.Id;
public int LoggerMessageCount => _logger.MessageCount;
}
class Program
{
static void Main()
{
// Scenario 1: Legacy singleton
Console.WriteLine("Legacy singleton:");
// Two handlers in Request 1
var handlerA1 = new Handler(LegacySingletonLogger.Instance);
handlerA1.Handle("Req1-H1");
var handlerA2 = new Handler(LegacySingletonLogger.Instance);
handlerA2.Handle("Req1-H2");
// Request 2 uses same static instance
var handlerA3 = new Handler(LegacySingletonLogger.Instance);
handlerA3.Handle("Req2-H1");
Console.WriteLine($"Request 1: Handler1 LoggerId={handlerA1.LoggerId} messageCount={handlerA1.LoggerMessageCount}");
Console.WriteLine($"Request 1: Handler2 LoggerId={handlerA2.LoggerId} messageCount={handlerA2.LoggerMessageCount}");
Console.WriteLine($"Request 2: Handler1 LoggerId={handlerA3.LoggerId} messageCount={handlerA3.LoggerMessageCount}");
Console.WriteLine();
// Scenario 2: Simple DI container, transient registration
var containerTransient = new SimpleContainer();
containerTransient.RegisterTransient<ILogger, LoggerImpl>();
Console.WriteLine("DI transient:");
// Request 1
var t1h1 = new Handler(containerTransient.Resolve<ILogger>());
t1h1.Handle("Req1-H1");
var t1h2 = new Handler(containerTransient.Resolve<ILogger>());
t1h2.Handle("Req1-H2");
// Request 2
var t2h1 = new Handler(containerTransient.Resolve<ILogger>());
t2h1.Handle("Req2-H1");
Console.WriteLine($"Request 1: Handler1 LoggerId={t1h1.LoggerId} messageCount={t1h1.LoggerMessageCount}");
Console.WriteLine($"Request 1: Handler2 LoggerId={t1h2.LoggerId} messageCount={t1h2.LoggerMessageCount}");
Console.WriteLine($"Request 2: Handler1 LoggerId={t2h1.LoggerId} messageCount={t2h1.LoggerMessageCount}");
Console.WriteLine();
// Scenario 3: Simple DI container, singleton registration
var containerSingleton = new SimpleContainer();
containerSingleton.RegisterSingleton<ILogger, LoggerImpl>();
Console.WriteLine("DI singleton:");
// Request 1
var s1h1 = new Handler(containerSingleton.Resolve<ILogger>());
s1h1.Handle("Req1-H1");
var s1h2 = new Handler(containerSingleton.Resolve<ILogger>());
s1h2.Handle("Req1-H2");
// Request 2
var s2h1 = new Handler(containerSingleton.Resolve<ILogger>());
s2h1.Handle("Req2-H1");
Console.WriteLine($"Request 1: Handler1 LoggerId={s1h1.LoggerId} messageCount={s1h1.LoggerMessageCount}");
Console.WriteLine($"Request 1: Handler2 LoggerId={s1h2.LoggerId} messageCount={s1h2.LoggerMessageCount}");
Console.WriteLine($"Request 2: Handler1 LoggerId={s2h1.LoggerId} messageCount={s2h1.LoggerMessageCount}");
Console.WriteLine();
Console.WriteLine("Why DI is preferable: explicit lifetime control, testability, and easier substitution of implementations.");
Console.WriteLine("Misuse warning: avoid retaining scoped/per-request state in process-wide singletons; prefer DI lifetimes.");
}
}