DI Container & Service Lifetimes
Singleton vs Scoped vs Transient β and the captive dependency bug that will eventually hit your production app.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
1// Program.cs β service registration2var builder = WebApplication.CreateBuilder(args);34// Singleton: one instance for the entire app lifetime5builder.Services.AddSingleton<IMemoryCache, MemoryCache>();6builder.Services.AddSingleton<IHttpClientFactory>(_ =>7 new DefaultHttpClientFactory()); // β manages socket pooling89// Scoped: one instance per HTTP request (or IServiceScope)10builder.Services.AddScoped<AppDbContext>();11builder.Services.AddScoped<IUserRepository, UserRepository>();12builder.Services.AddScoped<IOrderService, OrderService>();1314// Transient: new instance every time it's resolved15builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();1617// β οΈ this bites everyone eventually β captive dependency18// 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 Singleton22// System.InvalidOperationException: Cannot access a disposed context instance.23// The object was disposed. See inner exception for details.2425// Correct: Singleton that needs Scoped β inject IServiceScopeFactory26builder.Services.AddSingleton<BackgroundJobService>(sp =>27 new BackgroundJobService(sp.GetRequiredService<IServiceScopeFactory>()));2829// In BackgroundJobService:30public class BackgroundJobService : BackgroundService31{32 private readonly IServiceScopeFactory _scopeFactory;3334 public BackgroundJobService(IServiceScopeFactory scopeFactory)35 => _scopeFactory = scopeFactory;3637 protected override async Task ExecuteAsync(CancellationToken stoppingToken)38 {39 while (!stoppingToken.IsCancellationRequested)40 {41 // Create a new scope per job run β gets a fresh DbContext42 using var scope = _scopeFactory.CreateScope();43 var dbContext = scope.ServiceProvider44 .GetRequiredService<AppDbContext>();4546 await ProcessPendingJobsAsync(dbContext, stoppingToken);47 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);48 }49 }50}5152// IDisposable β the container calls Dispose() when scope ends53public class UserRepository : IUserRepository, IDisposable54{55 private readonly AppDbContext _dbContext;56 private bool _disposed;5758 public UserRepository(AppDbContext dbContext)59 => _dbContext = dbContext; // injected β don't dispose it here6061 public async Task<User?> FindByIdAsync(Guid userId, CancellationToken ct)62 {63 ObjectDisposedException.ThrowIf(_disposed, this);64 return await _dbContext.Users65 .AsNoTracking()66 .FirstOrDefaultAsync(u => u.Id == userId, ct);67 }6869 public void Dispose()70 {71 _disposed = true;72 // Note: don't dispose _dbContext here β DI container owns it73 }74}
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
1The Captive DbContext That Ate Production
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.
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.
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
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.
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.
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.