ASP.NET Core Dependency Injection
How the built-in DI container resolves your services β and the lifetime bugs that will eventually hit production.
ASP.NET Core ships with a built-in DI container (Microsoft.Extensions.DependencyInjection) that supports three service lifetimes: Singleton (one per app), Scoped (one per HTTP request), and Transient (new on every injection). The container builds a resolution graph at startup, validates it in Development, and resolves services lazily on first use. The most dangerous bugs come from lifetime mismatches β specifically, when a longer-lived service captures a reference to a shorter-lived one.
During startup, you call builder.Services.AddScoped<T>() etc. This adds ServiceDescriptor entries to the IServiceCollection. No instances are created yet. Think of it as writing a recipe β the container knows how to make each service, not that it has made them.
When you call builder.Build(), ASP.NET Core compiles all registrations into an IServiceProvider (specifically a ServiceProviderEngine). From this point, the container is immutable β you cannot add more registrations after Build().
For every incoming HTTP request, the framework calls IServiceScopeFactory.CreateScope(), producing a child IServiceProvider. Scoped services are resolved from this child scope. When the request ends, the scope is disposed, which triggers IDisposable.Dispose() on all Scoped services in registration-reverse order.
When you resolve OrdersController, the container inspects its constructor, sees it needs IOrderRepository, resolves that (finds it needs AppDbContext), and recursively resolves the entire graph. This is depth-first. If any dependency is unregistered, you get InvalidOperationException: 'Unable to resolve service for type X while attempting to activate Y.'
In Development, the DI container runs scope validation on startup. If a Singleton captures a Scoped dependency (e.g., Singleton β DbContext), it throws: 'Cannot consume scoped service from singleton'. In Production, scope validation is off by default β the bug is silent and causes shared DbContext state across requests.
The scope disposes all IDisposable services it created, in reverse registration order. Singleton services are disposed when the application shuts down (IHost.StopAsync). Transient services are disposed by the scope if they implement IDisposable β even though they're created fresh each time.
Key Concepts
One instance for the entire application lifetime. Shared across all requests and threads. Must be thread-safe. Good for: caches, configuration snapshots, HttpClient (via IHttpClientFactory).
One instance per request (per DI scope). The most common lifetime for services that touch the database. DbContext, repositories, UoW β always Scoped.
New instance every time it's injected. Good for lightweight, stateless services. Beware: HttpClient as Transient exhausts socket connections (port exhaustion). Use IHttpClientFactory instead.
The safe way to create a child scope from a Singleton or background service. Call CreateScope(), resolve your Scoped services from scope.ServiceProvider, then Dispose() the scope. Don't cache the resolved service.
When a longer-lived service (Singleton) holds a reference to a shorter-lived one (Scoped). The Scoped service is 'captured' and lives as long as the Singleton β defeating the purpose of its lifetime. Classic example: Singleton service with a DbContext field.
Register multiple implementations of the same interface under different keys. Resolve with [FromKeyedServices('key')] in constructors or GetRequiredKeyedService<T>('key').
The correct way to use HttpClient. Manages HttpMessageHandler pooling to prevent socket exhaustion. Supports named and typed clients, Polly retry policies, and DNS refresh.
services.BuildServiceProvider(validateScopes: true) or the default in Development. Checks for captive dependencies and unresolvable services at startup instead of first use.
1// Program.cs β registering services with different lifetimes2builder.Services.AddSingleton<IAppCache, MemoryAppCache>(); // one instance, ever3builder.Services.AddScoped<IOrderRepository, OrderRepository>(); // one per HTTP request4builder.Services.AddTransient<IEmailFormatter, EmailFormatter>(); // new on every inject56// β οΈ this is the footgun β DbContext as Singleton7// builder.Services.AddSingleton<AppDbContext>(); // NEVER DO THIS89// Correct β Scoped DbContext (one per request, disposed after)10builder.Services.AddDbContext<AppDbContext>(opts =>11 opts.UseSqlServer(connectionString)); // AddDbContext registers as Scoped by default1213// .NET 8+ Keyed Services β inject specific implementations by name14builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");15builder.Services.AddKeyedSingleton<IPaymentGateway, PayPalGateway>("paypal");1617public class CheckoutService([FromKeyedServices("stripe")] IPaymentGateway gateway) { }1819// IHttpClientFactory β never new up HttpClient directly20builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>21{22 client.BaseAddress = new Uri("https://api.github.com");23 client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");24});2526// Resolving Scoped services from a background service (IHostedService)27// This pattern avoids the captive dependency trap:28public class DataSyncService(IServiceScopeFactory scopeFactory) : BackgroundService29{30 protected override async Task ExecuteAsync(CancellationToken ct)31 {32 while (!ct.IsCancellationRequested)33 {34 using var scope = scopeFactory.CreateScope();35 // β fresh scope per iteration β DbContext is scoped, not captured36 var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();37 await repo.SyncPendingOrdersAsync(ct);38 await Task.Delay(TimeSpan.FromMinutes(5), ct);39 }40 }41}
The DI container is the backbone of every ASP.NET Core application. Lifetime bugs β especially Singleton-captures-Scoped β are silent in production, cause shared mutable state between requests, and can corrupt data. Understanding how scopes are created, destroyed, and how services are disposed helps you build correct, leak-free services.
Common Pitfalls
1DbContext registered as Singleton caused data corruption in production
Our e-commerce app was running fine in staging. In production under load, customers started seeing each other's shopping carts. Two requests for different users were reading and modifying the same DbContext instance.
A junior dev had added services.AddSingleton<AppDbContext>() instead of AddDbContext<AppDbContext>(). DbContext is NOT thread-safe. Two concurrent requests shared the same EF Core change tracker, which tracks entity state per-instance. Request A's changes were visible to Request B before being saved. EF threw InvalidOperationException: 'A second operation was started on this context instance before a previous operation completed.'
Changed to builder.Services.AddDbContext<AppDbContext>(opts => opts.UseSqlServer(connString)), which registers it as Scoped. In Development, added builder.Services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }) to catch lifetime violations at startup. The scope validator would have caught this immediately as a captive dependency error.
Takeaway: DbContext must always be Scoped. Never Singleton, never Transient (creates a new change tracker per injection, losing tracked entities mid-request). If you're in a background service, use IServiceScopeFactory to create a new scope per unit of work.
2Socket exhaustion from new HttpClient() in a Transient service
Our integration service started failing with 'Only one usage of each socket address is normally permitted' after about 500 requests. The server ran out of available ports. Restarting bought 20 minutes of relief before it happened again.
The service had services.AddTransient<IExternalApiClient, ExternalApiClient>() and ExternalApiClient's constructor called new HttpClient(). Each request created a fresh HttpClient, which created a new HttpMessageHandler with its own TCP connection pool. Even after the client was disposed, the underlying socket stays in TIME_WAIT for 4 minutes. Under load, we exhausted all ~16,000 available ephemeral ports.
Registered using builder.Services.AddHttpClient<IExternalApiClient, ExternalApiClient>(). IHttpClientFactory pools and reuses HttpMessageHandler instances (rotated every 2 minutes for DNS refresh). The typed client is registered as Transient, but the underlying handler is Singleton-pooled. No socket exhaustion, and DNS changes are picked up on rotation.
Takeaway: Never instantiate HttpClient directly in application code. Always use IHttpClientFactory. The typed client pattern (AddHttpClient<TClient, TImpl>()) gives you injection-friendly DI while the factory manages the handler pool lifecycle transparently.
3Background service captured a Scoped DbContext β silent data staleness
Our nightly sync IHostedService was correctly injecting IOrderRepository via constructor injection. In production, it processed new orders fine on the first run but then started skipping newly created orders. The data it saw was frozen at the time the service started.
IHostedService is registered as a Singleton. When it injected IOrderRepository (Scoped) via constructor, the DI container resolved the repository once at startup and cached it for the service's lifetime. The DbContext inside that repository had a stale connection and a stale first-level cache. With ValidateScopes=false in production, no exception was thrown.
Removed the repository from the constructor. Injected IServiceScopeFactory instead (which is Singleton-safe). In ExecuteAsync, for each sync iteration: using var scope = scopeFactory.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>(); β then disposed the scope after each iteration. Each run now gets a fresh DbContext with no stale cache.
Takeaway: Background services (IHostedService, BackgroundService) are Singletons. They must never inject Scoped services directly. The correct pattern is constructor-inject IServiceScopeFactory and create a scope per unit of work inside ExecuteAsync.