ASP.NET Core DI — Captive Dependencies That Exhaust Pools
A Singleton caching a Scoped DbContext exhausted the connection pool in 30 minutes.
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
- Built-in DI container manages service creation and disposal
- Three lifetimes: Transient (new each time), Scoped (per request), Singleton (app lifetime)
- Captive dependency bug: injecting Scoped into Singleton silently breaks state isolation
- ASP.NET Core throws InvalidOperationException in development if it detects captive dependencies
- Always depend on interfaces, not concrete classes — keeps code testable and swappable
Imagine you run a coffee shop. Instead of training every barista to grow their own coffee beans, roast them, and grind them fresh each morning, you just have a supplier deliver exactly what they need. The barista doesn't care where the beans came from — they just make coffee. Dependency Injection works the same way: instead of a class creating its own tools (dependencies), someone else hands those tools to it. That 'someone else' is ASP.NET Core's built-in DI container.
Dependency Injection (DI) is one of those patterns that sounds academic until the day a tight-coupled codebase bites you in production — a change to one class breaks five others, unit tests require real databases, and swapping a third-party library means rewriting half the service layer. ASP.NET Core bakes DI into its very foundation, making it the cleanest implementation of the pattern in the .NET ecosystem.
The core idea is straightforward: instead of a class creating its own dependencies with new, those dependencies are handed to it from the outside — by the framework's IoC container. This inverts control, breaks tight coupling, and makes every class independently testable. ASP.NET Core's built-in container handles three lifetime scopes — Singleton, Scoped, and Transient — each with distinct behaviour that determines when objects are created and destroyed.
By the end of this guide you will be able to register services in Program.cs, inject them via constructors and primary constructors (C# 12+), choose the right lifetime for each service, implement advanced patterns like keyed services and factory delegates, and write unit tests that mock dependencies cleanly without touching the DI container directly.
Why ASP.NET Core DI Can Starve Your Connection Pool
Dependency injection in ASP.NET Core is a built-in container that manages object lifetimes and wiring. The core mechanic: you register types with a lifetime — Singleton, Scoped, or Transient — and the container resolves them automatically, disposing Scoped and Transient instances when their scope ends. This eliminates manual factory code but introduces a subtle trap: captive dependencies.
A captive dependency occurs when a long-lived service (Singleton) holds a reference to a shorter-lived service (Scoped or Transient). The container resolves the short-lived service once and caches it for the Singleton's lifetime, never releasing it. In practice, this means a DbContext registered as Scoped but injected into a Singleton service lives forever, accumulating connections and exhausting the pool.
Use this pattern in any ASP.NET Core application that requires structured service composition. It matters because a misconfigured lifetime silently degrades throughput: a single captive DbContext can hold a connection open for hours, causing timeouts under load. The rule: never inject a Scoped or Transient service into a Singleton — always verify lifetimes at registration time.
Why Tight Coupling Is a Silent Killer (and What DI Does About It)
Before you can appreciate DI, you need to feel the pain it eliminates. Consider an OrderService that sends a confirmation email. The naive approach is to write var emailSender = new right inside the service. It works — until you try to unit test SmtpEmailSender()OrderService and your test runner starts firing real emails. Or until your company switches from SMTP to SendGrid. Or until you need to log emails differently in staging vs production.
The root issue is that OrderService is now responsible for two things: processing orders AND knowing exactly how to construct an email sender. That violates the Single Responsibility Principle and creates a dependency on a concrete implementation rather than an abstraction.
DI fixes this by flipping the script. You define an interface — IEmailSender — that describes what an email sender does without specifying how it does it. Then OrderService declares that it needs something that implements IEmailSender, and the DI container figures out what to hand it at runtime. Your service becomes blissfully ignorant of the plumbing, and you gain the freedom to swap, mock, or extend that plumbing without touching the service at all.
new SomeConcreteService() inside another service, stop — that's a tight coupling alarm going off. Register it with the container and inject the interface instead.new SmtpEmailSender() was a hidden dependency.new for anything that's not a value object, you're probably doing it wrong.Registering Services in ASP.NET Core — Program.cs Is Your Wiring Diagram
In ASP.NET Core, all DI configuration lives in Program.cs (or Startup.cs in older projects). The builder.Services property gives you an IServiceCollection — think of it as a recipe book that tells the container 'when someone asks for X, hand them Y'.
There are three lifetime options when registering a service, and choosing the wrong one is one of the most common sources of subtle bugs in .NET apps:
Transient — a brand new instance every single time. Perfect for lightweight, stateless services like validators or formatters.
Scoped — one instance per HTTP request. This is the sweet spot for most business logic services and database contexts (like Entity Framework's DbContext). Every class that participates in the same request shares the same instance.
Singleton — one instance for the entire lifetime of the application. Use this for expensive-to-create, thread-safe services like configuration helpers, HTTP clients, or in-memory caches.
The container resolves the full dependency chain automatically. If OrderService needs IEmailSender which needs ILogger, you just register all three and the container figures out the construction order.
InvalidOperationException at startup if it detects this in development mode. The fix: either bump the Singleton down to Scoped, or use IServiceScopeFactory to manually create a scope when you genuinely need short-lived services inside a long-lived one.Service Lifetimes in Action — Seeing the Difference With Real Output
Reading about lifetimes is one thing. Watching them behave differently in a running app is what makes it truly stick. The best way to see lifetime differences is to inject a service into multiple places within the same request and check whether the instances are the same object or different ones.
A unique ID generated at construction time is a perfect diagnostic tool for this. If two classes receive the same ID, they got the same instance. Different IDs mean different instances.
This also reveals a critical real-world implication: if you use a Transient service that holds some state and you expect state changes in one part of the request to be visible in another, you'll be silently surprised — Transient creates a new instance each time, so that state doesn't travel. Scoped is usually what you actually want for request-level state sharing.
Advanced Patterns — Keyed Services, Factory Injection and IServiceScopeFactory
## IServiceScopeFactory: The Correct Pattern for Scoped Dependencies in Singletons
When you need a scoped service (like DbContext) inside a singleton or background service, never inject the scoped service directly — that would make it a captive dependency, effectively turning it into a singleton and causing stale data or concurrency issues. Instead, inject IServiceScopeFactory and create a scope per operation.
```csharp public class DataCleanupService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<DataCleanupService> _logger;
public DataCleanupService(IServiceScopeFactory scopeFactory, ILogger<DataCleanupService> logger) { _scopeFactory = scopeFactory; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using (var scope = _scopeFactory.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var staleRecords = await dbContext.Records .Where(r => r.ExpiresAt < DateTime.UtcNow) .ToListAsync(stoppingToken);
dbContext.Records.RemoveRange(staleRecords); await dbContext.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Cleaned {Count} stale records", staleRecords.Count); }
await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } } ```
Key points: - Always dispose the scope after use (the using block ensures this). - Each iteration gets a fresh DbContext — no stale tracking, no concurrency nightmares. - Do not cache the scope or the scoped service across iterations.
## Factory Delegate: Runtime Parameters the Container Can't Know
When you need to pass parameters that are only known at runtime (e.g., tenant ID, connection string, user context), register a factory delegate that takes IServiceProvider and returns your service.
```csharp // Registration services.AddScoped<ITenantService>(sp => { var tenantId = sp.GetRequiredService<ITenantContext>().TenantId; return new TenantService(tenantId, sp.GetRequiredService<ILogger<TenantService>>()); });
// Or as a named factory services.AddSingleton<Func<string, ITenantService>>(sp => tenantId => { var logger = sp.GetRequiredService<ILogger<TenantService>>(); return new TenantService(tenantId, logger); }); ```
When to use: - The service needs a value that changes per request or per operation. - You want to avoid injecting IServiceProvider into your business logic. - The factory itself is registered in DI, so it can resolve other dependencies.
## ActivatorUtilities.CreateInstance: Hybrid Construction for Plugins
When you need to instantiate a class that has some dependencies from DI and some parameters you provide at runtime (e.g., plugin activation, dynamic dispatch), use ActivatorUtilities.CreateInstance.
```csharp public class PluginHost { private readonly IServiceProvider _serviceProvider;
public PluginHost(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }
public IPlugin ActivatePlugin(Type pluginType, string configData) { // configData is passed directly; other deps come from DI return (IPlugin)ActivatorUtilities.CreateInstance( _serviceProvider, pluginType, new object[] { configData }); } }
// Plugin constructor public class EmailPlugin : IPlugin { public EmailPlugin(string configData, IEmailSender sender, ILogger<EmailPlugin> logger) { // configData from caller, sender and logger from DI } } ```
Rules: - The container resolves all parameters it can; your provided arguments fill the rest. - If a parameter type matches both a provided argument and a registered service, your argument wins. - Great for plugin systems, strategy pattern, or any dynamic instantiation.
## Named/Keyed Services (.NET 8+): Multiple Implementations Without Service Locator
Before .NET 8, you had to use IEnumerable<T> or a service locator to choose between multiple implementations. Keyed services give you clean, compile-time-safe selection.
```csharp // Registration services.AddKeyedSingleton<IPaymentProcessor, StripeProcessor>("Stripe"); services.AddKeyedSingleton<IPaymentProcessor, PayPalProcessor>("PayPal"); services.AddKeyedSingleton<IPaymentProcessor, SquareProcessor>("Square");
// Consumption via [FromKeyedServices] public class CheckoutService { private readonly IPaymentProcessor _processor;
public CheckoutService([FromKeyedServices("Stripe")] IPaymentProcessor processor) { _processor = processor; } }
// Or resolve at runtime with a factory services.AddSingleton<IPaymentProcessorFactory>(sp => processorType => { return sp.GetRequiredKeyedService<IPaymentProcessor>(processorType); }); ```
Benefits: - No manual switch or if-else chains. - The container manages the mapping; your code just declares what it needs. - Works with AddKeyedSingleton, AddKeyedScoped, AddKeyedTransient.
## The Service Locator Anti-Pattern: When It's Actually OK
Injecting IServiceProvider into your service class is almost always a code smell. It hides dependencies, makes testing harder, and can lead to runtime errors that would otherwise be caught at startup.
Red flags: - You're using GetRequiredService inside a method to resolve services on the fly. - Your constructor takes only IServiceProvider. - You're building a "smart" service that decides which implementation to use based on runtime data.
When it's acceptable: - Generic dispatchers: A message router that resolves handlers by message type. - Plugin hosts: Where the set of implementations is dynamic and loaded from assemblies. - Lazy initialization: When a dependency is expensive to create and may not be needed. - Framework infrastructure: The DI container itself, or libraries like IMediator.
```csharp // Acceptable use: generic dispatcher public class CommandDispatcher { private readonly IServiceProvider _serviceProvider;
public CommandDispatcher(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }
public async Task Dispatch<TCommand>(TCommand command) where TCommand : ICommand { var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand>>(); await handler.Handle(command); } } ```
Rule of thumb: If you can replace IServiceProvider with a specific interface (like ICommandHandlerFactory), do it. If the number of possible resolutions is unbounded or dynamic, IServiceProvider is the pragmatic choice.
IServiceScopeFactory. For keyed services, they're a lifesaver in multi-tenant apps — just don't overuse them; if you have 50 keys, reconsider your architecture.IServiceScopeFactory for scoped services in singletons, factory delegates for runtime parameters, ActivatorUtilities.CreateInstance for hybrid construction, and keyed services (.NET 8+) for multiple implementations. Avoid IServiceProvider injection except in generic dispatchers or plugin hosts.Testing with Dependency Injection — Mocking, Integration Tests, and Validation
DI's biggest win is testability. When a service declares its dependencies via interfaces in constructor, you can swap real implementations for mocks or stubs in tests. This section covers three testing strategies that every senior engineer uses.
Unit Testing: Use a mocking framework like Moq to inject fake dependencies. Since your service depends only on interfaces, you can verify it calls the right methods with the right parameters without setting up databases or network calls.
Integration Testing with WebApplicationFactory: ASP.NET Core provides WebApplicationFactory<T> that creates an in-memory test server with a configurable DI container. You can replace specific services with test doubles (e.g., fake email sender) while keeping the rest of the real pipeline. This is the recommended approach for testing controllers and middleware.
Container Validation: In development, enable scoped validation by calling builder.Services.BuildServiceProvider(validateScopes: true). This throws at startup if it detects a Singleton with Scoped dependencies — catching captive dependencies before they reach production. For integration tests, you can also validate the container's configuration by resolving each service and verifying no exceptions.
validateScopes: true in development and CI, and write at least one integration test per endpoint.Constructor Injection: The Only Pattern You Need — Here's Why
You've seen classes with five or six services injected via constructor. That's fine — don't let anyone tell you it's a code smell. Constructor injection is the simplest and most reliable way to see your dependencies at compile time. The moment you switch to property injection or method injection, you lose that compile-time guarantee. If a constructor parameter is missing, your app fails fast at startup, not at 3 AM when a user triggers a specific code path. ASP.NET Core resolves all constructor dependencies automatically when it activates the class. If it can't resolve one, you get an InvalidOperationException during container resolution, not a null reference later. Always favor constructor injection over any other pattern. Your unit tests will thank you, and your production logs will stop hiding missing dependencies.
Scope Validation Doesn't Lie — Stop Ignoring It
You deployed to staging and everything worked. Then production starts throwing exceptions about resolved scoped services from singleton consumers. That's because development mode enables scope validation by default, and your staging environment likely didn't have it. ASP.NET Core validates scopes when ValidateOnBuild is enabled. It checks that no scoped service flows into a singleton — a violation called captive dependency. When that happens, the singleton holds the scoped service for its entire lifetime, which means you get stale data, incorrect state, or outright NullReferenceExceptions. Always enable ValidateScopes in your production service collection. Add it to Program.cs with builder.Services.BuildServiceProvider( new ServiceProviderOptions { ValidateScopes = true }). Your singleton will scream at you at startup rather than silently corrupting your application state.
ValidateOnBuild and Scope Validation — Stop Captive Dependencies Reaching Production
## The Default: Silent Leaks in Production
By default, ASP.NET Core only validates captive dependencies (Scoped injected into Singleton) in the Development environment. In Production, the same misconfiguration silently leaks — your Singleton holds a Scoped instance forever, breaking per-request isolation.
```csharp // This works in Development (throws at startup), but silently leaks in Production var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<MySingleton>(); builder.Services.AddScoped<IScopedService, ScopedService>();
public class MySingleton { public MySingleton(IScopedService scoped) { } // Captive dependency! } ```
## ValidateOnBuild(): Catch Captive Dependencies at Startup
ValidateOnBuild throws an InvalidOperationException during host. if any service cannot be constructed due to a captive dependency. The exact exception message:Build()
> "Cannot resolve 'MySingleton' from root provider because it requires scoped service 'IScopedService'."
``csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<MySingleton>(); builder.Services.AddScoped<IScopedService, ScopedService>(); builder.Services.Configure<HostOptions>(opts => { opts.ValidateOnBuild = true; }); var host = builder.``Build(); // Throws InvalidOperationException
## ValidateScopes(): Separate Scope Violations
ValidateScopes catches scope violations at resolution time (e.g., resolving a Scoped service from a Singleton scope). You need both:
ValidateOnBuild: catches captive dependencies during service graph constructionValidateScopes: catches runtime scope violations (e.g., resolving Scoped from root scope)
``csharp builder.Services.Configure<HostOptions>(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }); ``
## Enable Both in All Environments
``csharp var builder = Host.CreateDefaultBuilder(args) .UseDefaultServiceProvider((context, opts) => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }); ``
Why you should: This catches DI misconfigurations in every environment — Development, Staging, Production. The performance cost is negligible (only at startup/resolution), and the safety gain is enormous.
## Integration Test Pattern: Catch DI Errors in CI
``csharp [Fact] public void ``BuildHost_ShouldNotThrow() { var host = Program.CreateHostBuilder(Array.Empty<string>()) .UseDefaultServiceProvider(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }) .Build(); // If this doesn't throw, DI is valid }
## Named/Keyed Services (.NET 8+)
Use AddKeyedSingleton, AddKeyedScoped with IKeyedServiceProvider to register multiple implementations of the same interface without factory delegates:
```csharp builder.Services.AddKeyedSingleton<ICache, RedisCache>("redis"); builder.Services.AddKeyedSingleton<ICache, MemoryCache>("memory");
public class CacheManager { public CacheManager(IKeyedServiceProvider keyedProvider) { var redis = keyedProvider.GetRequiredKeyedService<ICache>("redis"); var memory = keyedProvider.GetRequiredKeyedService<ICache>("memory"); } } ```
## Common Captive Dependency: IHttpContextAccessor in Background Service
IHttpContextAccessor is Scoped. Injecting it into a Singleton (e.g., BackgroundService) is a classic captive dependency:
```csharp public class MyBackgroundService : BackgroundService { private readonly IHttpContextAccessor _httpContextAccessor; // Captive!
public MyBackgroundService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // _httpContextAccessor.HttpContext is always null here } } ```
Exception: InvalidOperationException: Cannot resolve scoped service 'IHttpContextAccessor' from root provider.
Fix: Use IServiceScopeFactory to create a scope per operation:
```csharp public class MyBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory;
public MyBackgroundService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var scope = _scopeFactory.CreateScope(); var httpContextAccessor = scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>(); // Now httpContextAccessor.HttpContext is valid } } ```
DbContext caused stale data reads for 48 hours before detection. The fix was enabling ValidateOnBuild — it would have thrown at deployment. Always validate DI in CI with integration tests.ValidateOnBuild and ValidateScopes in all environments. Use IServiceScopeFactory for background services. Test DI configuration in CI. For multiple implementations, use keyed services (.NET 8+).The Singleton That Held an Open Database Connection Forever
BackgroundService injected IServiceScopeFactory, but inside the service they always resolved the same IOrderRepository instance by storing it in a field. That IOrderRepository was registered as Scoped and depended on Entity Framework's DbContext (also Scoped). The Singleton held a reference to a single Scoped instance, preventing disposal. The DbContext's underlying database connection was never returned to the pool.IServiceScopeFactory.CreateScope(), resolve the Scoped services within that scope, and let the scope dispose automatically. This ensures fresh DbContext instances that get properly returned to the pool.- Scoped services must be resolved and used within the same scope — never cache them in Singletons.
- Always inject
IServiceScopeFactoryinto Singletons, not the Scoped services themselves. - Use ASP.NET Core's built-in development-time validation by calling
builder.Services.BuildServiceProvider(validateScopes: true)in development to catch captive dependencies at startup.
IServiceScopeFactory instead. In development, enable scope validation in Program.cs: builder.Services.BuildServiceProvider(validateScopes: true) to catch this earlier.builder.Services.AddScoped and not AddSingleton. If it's a Singleton accidentally, Scoped services resolve from the root provider once and reuse the same instance. Use the InstanceId trick from the code samples to confirm.AddScoped<IService, Implementation>() without a factory if you need automatic disposal.Add `builder.Services.BuildServiceProvider(validateScopes: true)` in development to get detailed error messages at startup.Search the codebase for `AddSingleton` and review each one's constructor parameters.IServiceScopeFactory and create a scope inside the Singleton's methods.Key takeaways
BuildServiceProvider(validateScopes: true).Common mistakes to avoid
4 patternsCaptive Dependency: Injecting Scoped service into Singleton
IServiceScopeFactory and create a new scope whenever you need to resolve Scoped services. Alternatively, change the Singleton's lifetime to Scoped if its dependencies are Scoped.Service Locator Anti-Pattern: Accessing the container directly via `HttpContext.RequestServices` or `IServiceProvider` in application code
serviceProvider.GetService<T>() inside business logic is hard to test, hides dependencies, and can lead to runtime resolution failures. It also couples your code to the DI container implementation.IObjectFactory that itself is injected. The IServiceProvider should only be used in infrastructure code (like middleware or custom factories).Registering a service under multiple interfaces without matching lifetimes
AddScoped<IFoo, Service>() and AddScoped<IBar, Service>(). A class that injects both IFoo and IBar gets two different instances of Service because the container treats them as independent registrations. Shared state is not shared.AddScoped<Service>() and then add forwarders: AddScoped<IFoo>(sp => sp.GetRequiredService<Service>()) and AddScoped<IBar>(sp => sp.GetRequiredService<Service>()). Or use TryAdd with a singleton instance registration.Not disposing manually created scopes in background services
IServiceScope but forget to wrap it in using block cause memory leaks and never dispose the scoped services (and their connections). Eventually the app throws OutOfMemoryException.using or call .Dispose() in a finally block. For BackgroundService, wrap the scope creation inside the ExecuteAsync loop and dispose after each iteration.Interview Questions on This Topic
What are the three service lifetimes in ASP.NET Core DI, and when would you use each?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
That's ASP.NET. Mark it forged?
12 min read · try the examples if you haven't