AI WisdomArchitecture & guides β†—
HT
How Things Work

DI Container & Service Lifetimes

Singleton vs Scoped vs Transient β€” and the captive dependency bug that will eventually hit your production app.

How It Works

ASP.NET Core's built-in DI container (Microsoft.Extensions.DependencyInjection) manages object creation and lifetime through three primitives: Singleton (one for the app), Scoped (one per request), and Transient (one per injection). Getting lifetimes wrong causes subtle, hard-to-reproduce bugs β€” a captured DbContext causes stale queries, a Transient HttpClient causes socket exhaustion. Understanding how the container resolves dependencies and disposes them is foundational to building reliable .NET services.

1
IServiceCollection: the registration phase

At startup, you register service descriptors β€” (interface, implementation, lifetime) tuples β€” into IServiceCollection. No instances are created yet. This is a blueprint. The collection is then used to build an IServiceProvider.

2
IServiceProvider: the resolution phase

When you request a service (GetRequiredService<T>() or constructor injection), the provider walks the dependency graph, creates instances in dependency order, and wires them together. Singletons are cached in the root provider; Scoped services are cached in a scope; Transients are created fresh each time.

3
Scopes and the request lifetime

ASP.NET Core creates a new IServiceScope for each HTTP request and disposes it when the request ends. All Scoped services created in that scope are disposed together. This ensures DbContext connections are returned to the pool after each request, not held for the app's lifetime.

4
The captive dependency problem

A Singleton holding a Scoped service is a captive dependency. The Singleton lives forever, so it captures the Scoped service's first instance and holds it past its intended lifetime. For DbContext, this means stale change tracking, connection pool exhaustion, and eventual ObjectDisposedException.

5
IDisposable and the disposal chain

The DI container tracks IDisposable services and calls Dispose() when their scope ends. For Singletons, Dispose() is called at app shutdown. For Scoped, at scope end. Transients are tracked by the scope that resolved them β€” another reason to avoid Transient IDisposable services in hot paths.

Key Concepts

πŸ”·Singleton

One instance for the entire app lifetime. Shared across all requests and threads. Must be thread-safe. Appropriate for: IMemoryCache, IHttpClientFactory, configuration singletons, in-memory state.

πŸ”ΉScoped

One instance per IServiceScope (one per HTTP request in ASP.NET Core). Safe for non-thread-safe resources like DbContext. Disposed when the scope ends β€” connections returned to pool.

πŸ”ΈTransient

New instance every time it's resolved. Safest option β€” no shared state. But expensive if the service holds resources. Avoid IDisposable Transients in high-throughput paths.

⚠️Captive Dependency

When a longer-lived service (Singleton) captures a shorter-lived one (Scoped or Transient). The inner service is never disposed properly and may hold stale state. The runtime detects some cases and throws InvalidOperationException.

🏭IServiceScopeFactory

Inject this into Singletons that need Scoped services. Call CreateScope() to create a new scope, resolve services within it, and dispose the scope when done. Canonical pattern for hosted services / background workers.

πŸ—οΈConstructor Injection

The preferred injection point in .NET DI. Dependencies declared in the constructor are resolved by the container. Avoid property injection and service locator (GetRequiredService inside classes) β€” both obscure dependencies and complicate testing.

Service Registration & Lifetime Management
tsx
1// Program.cs β€” service registration
2var builder = WebApplication.CreateBuilder(args);
3
4// Singleton: one instance for the entire app lifetime
5builder.Services.AddSingleton<IMemoryCache, MemoryCache>();
6builder.Services.AddSingleton<IHttpClientFactory>(_ =>
7 new DefaultHttpClientFactory()); // ← manages socket pooling
8
9// Scoped: one instance per HTTP request (or IServiceScope)
10builder.Services.AddScoped<AppDbContext>();
11builder.Services.AddScoped<IUserRepository, UserRepository>();
12builder.Services.AddScoped<IOrderService, OrderService>();
13
14// Transient: new instance every time it's resolved
15builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
16
17// ⚠️ this bites everyone eventually β€” captive dependency
18// DON'T register a Scoped service as Singleton:
19// builder.Services.AddSingleton<IOrderService, OrderService>();
20// ^ OrderService depends on AppDbContext (Scoped)
21// The first request's DbContext is captured forever in the Singleton
22// System.InvalidOperationException: Cannot access a disposed context instance.
23// The object was disposed. See inner exception for details.
24
25// Correct: Singleton that needs Scoped β†’ inject IServiceScopeFactory
26builder.Services.AddSingleton<BackgroundJobService>(sp =>
27 new BackgroundJobService(sp.GetRequiredService<IServiceScopeFactory>()));
28
29// In BackgroundJobService:
30public class BackgroundJobService : BackgroundService
31{
32 private readonly IServiceScopeFactory _scopeFactory;
33
34 public BackgroundJobService(IServiceScopeFactory scopeFactory)
35 => _scopeFactory = scopeFactory;
36
37 protected override async Task ExecuteAsync(CancellationToken stoppingToken)
38 {
39 while (!stoppingToken.IsCancellationRequested)
40 {
41 // Create a new scope per job run β€” gets a fresh DbContext
42 using var scope = _scopeFactory.CreateScope();
43 var dbContext = scope.ServiceProvider
44 .GetRequiredService<AppDbContext>();
45
46 await ProcessPendingJobsAsync(dbContext, stoppingToken);
47 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
48 }
49 }
50}
51
52// IDisposable β€” the container calls Dispose() when scope ends
53public class UserRepository : IUserRepository, IDisposable
54{
55 private readonly AppDbContext _dbContext;
56 private bool _disposed;
57
58 public UserRepository(AppDbContext dbContext)
59 => _dbContext = dbContext; // injected β€” don't dispose it here
60
61 public async Task<User?> FindByIdAsync(Guid userId, CancellationToken ct)
62 {
63 ObjectDisposedException.ThrowIf(_disposed, this);
64 return await _dbContext.Users
65 .AsNoTracking()
66 .FirstOrDefaultAsync(u => u.Id == userId, ct);
67 }
68
69 public void Dispose()
70 {
71 _disposed = true;
72 // Note: don't dispose _dbContext here β€” DI container owns it
73 }
74}
πŸ’‘
Why This Matters

Dependency injection is the central plumbing of every modern .NET application. Every controller, Razor component, minimal API handler, background service, and middleware runs inside the DI container's scope graph. Understanding lifetime semantics is the difference between an app that runs for weeks without issues and one that develops subtle state corruption, memory leaks, or ObjectDisposedException under load.

Common Pitfalls

⚠Captive dependency: Singleton holding Scoped β†’ Scoped service is never properly disposed, holds stale state, may hold database connections open. The runtime detects this in development but not always in production. Use ValidateScopes: true in all environments.
⚠Resolving from the root scope: calling app.Services.GetRequiredService<T>() for a Scoped service resolves from the root provider β€” effectively making it a Singleton for the rest of the app lifetime. Always resolve Scoped services within an explicit IServiceScope.
⚠Transient IDisposable in hot paths: the scope tracks all IDisposable transients and disposes them when the scope ends. If you create many transients that implement IDisposable per request, they accumulate in the scope until request end β€” memory pressure builds up.
⚠HttpClient as Transient: creating and disposing HttpClient frequently exhausts the socket port range due to TIME_WAIT. Use IHttpClientFactory (AddHttpClient<T>()) which manages connection handler pools with automatic refresh.
⚠Not implementing IDisposable when holding resources: if your Scoped service opens a file handle, database connection, or semaphore but doesn't implement IDisposable, those resources leak β€” the container can't clean up what it doesn't know about.
Real-World Use Cases

1The Captive DbContext That Ate Production

Scenario

Our ASP.NET Core 8 API was running fine for 30 minutes after deployment, then started throwing System.InvalidOperationException: 'Cannot access a disposed context instance' on every request to /api/orders. Rollback didn't help β€” the same issue appeared after the previous deploy too.

Problem

A well-intentioned developer had changed OrderService from Scoped to Singleton to 'improve performance.' OrderService depended on AppDbContext (Scoped). The first request's DbContext was captured in the Singleton and disposed after that request ended. All subsequent requests used the same OrderService instance, which held a reference to a disposed DbContext.

Solution

Reverted OrderService to Scoped. For the parts of OrderService that genuinely needed to be long-lived (a background queue), extracted them into a separate Singleton that used IServiceScopeFactory. Enabled DI validation at startup: builder.Services.BuildServiceProvider(validateScopes: true) catches captive dependencies at app startup rather than in production.

πŸ’‘

Takeaway: Enable ValidateScopes: true and ValidateOnBuild: true in your DI configuration. These settings cause the container to throw at startup if any Singleton captures a Scoped service β€” turning a production outage into a startup failure caught in CI.

2HttpClient Registered as Transient β€” Socket Exhaustion

Scenario

Our payment microservice started throwing SocketException: 'Only one usage of each socket address is normally permitted' under moderate load (~200 req/sec). The dev environment had never seen this. The service worked perfectly for the first 100 seconds, then fell over.

Problem

HttpClient was registered as Transient, creating a new instance per injection. HttpClient manages a connection pool, but when created and disposed rapidly, it doesn't release TCP sockets immediately β€” they enter TIME_WAIT state for ~240 seconds. Under load, we exhausted the port range (65535 ports) within seconds.

Solution

Replaced manual HttpClient registration with IHttpClientFactory: builder.Services.AddHttpClient<PaymentApiClient>(). The factory manages the handler pool internally, reuses connections, and handles DNS refresh. Alternatively, a single Singleton HttpClient works fine for a single service with stable DNS.

πŸ’‘

Takeaway: HttpClient implements IDisposable but is designed to be long-lived. Never new it up per request. Use IHttpClientFactory (AddHttpClient<T>()) or a shared Singleton. This is one of the most common production issues with .NET microservices.