Solving the Captive Dependency: Managing Scoped Lifetimes in .NET Core
Learn how to avoid Captive Dependencies in .NET Core by properly managing Scoped lifetimes, especially when integrating database contexts into background services.
17 Dec 2025, 03:04 UTC

The Request-Scope Dilemma
In .NET Core, a common architectural failure occurs when a developer injects a Scoped service into a Singleton service. This creates a "Captive Dependency": the scoped service is effectively promoted to a singleton because it is held by a service that never dies. If that scoped service is an Entity Framework Core DbContext, your application will likely crash with a threading error or leak database connections, as DbContext is not thread-safe and is intended to live only for the duration of a single HTTP request.
Understanding the Lifetime Hierarchy
To avoid these traps, you must align your service lifetimes with the intended usage pattern of the data they handle. .NET Core provides three primary mechanisms via the IServiceProvider:
- Singleton: Created once and lives for the entire application lifetime. Use this for stateless utility classes or global configuration caches.
- Scoped: Created once per client request. This is the gold standard for database contexts and user-session state, ensuring that every component processing a specific request shares the same instance.
- Transient: Created every time they are requested. Use this for lightweight, stateless services to avoid side effects between components.
Implementing a Safe Scope in Background Tasks
A frequent engineering challenge arises when you need to use a scoped service (like a repository) inside a BackgroundService or a IHostedService. Since background tasks are Singletons and operate outside the context of an HTTP request, you cannot inject a scoped service directly into the constructor.
The solution is to inject IServiceScopeFactory, which allows you to manually create a temporary scope that mimics a request lifecycle.
Worked Example: Processing a Queue in the Background
// Run this in a class inheriting from BackgroundService
// Required Permissions: Standard application execution
// Risk: Failing to dispose the scope leads to memory leaks
public class WorkerService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public WorkerService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Create a manual scope to resolve Scoped services
using (IServiceScope scope = _scopeFactory.CreateScope())
{
// Resolve the DbContext or Scoped Service from the scope's provider
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Perform work
var pendingTasks = await dbContext.Tasks.Where(t => !t.IsComplete).ToListAsync();
// ... process tasks ...
}
// The scope is disposed here, and the DbContext is safely released
await Task.Delay(10000, stoppingToken);
}
}
}
Trade-offs and Performance Considerations
While Scoped lifetimes provide consistency, they are not free. Over-reliance on Transient services can increase Garbage Collection (GC) pressure because the runtime must allocate and reclaim objects more frequently. Conversely, moving too much into Singleton to save memory introduces thread-safety risks; any state stored in a Singleton must be handled with ConcurrentDictionary or SemaphoreSlim to prevent race conditions in high-concurrency environments.
Verifying Your Lifetimes
To verify that your services are behaving as expected, you can implement a simple GUID-based check. Create a service that generates a Guid in its constructor and inject it into two different components (e.g., a Middleware and a Controller).
- Expected Result (Scoped): Both components show the same GUID during a single request, but the GUID changes when you refresh the browser.
- Failure Result (Captive): The GUID remains identical across different users and different requests, indicating the service has been accidentally captured by a Singleton.
If you detect a captive dependency, the fix is usually to move the dependency resolution from the constructor to a method-level scope using the IServiceScopeFactory pattern shown above.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.