Avoiding the Captive Dependency: Mastering .NET Core DI Lifetimes
Learn how to identify and fix 'Captive Dependencies' in .NET Core by correctly implementing Transient, Scoped, and Singleton lifetimes to prevent memory leaks.
20 Sept 2025, 01:59 UTC

The Silent Bug: When Scoped Services Never Die
You have a DbContext registered as Scoped, meaning it should be disposed of at the end of every HTTP request. However, you notice your database connections are leaking, or you're seeing "ObjectDisposedException" errors in random threads. The culprit is often a Captive Dependency: a Singleton service that has a Scoped service injected into its constructor.
Because a Singleton is created only once for the life of the application, any Scoped service injected into it is "captured." That Scoped service will never be disposed of until the application shuts down, effectively turning it into a Singleton and breaking its intended lifecycle.
Choosing the Right Lifetime
To avoid these leaks, you must align your service registration with how the state is used. .NET Core provides three primary lifetimes via IServiceCollection:
- Transient: Created every time they are requested. Use these for lightweight, stateless services that perform a quick operation and exit.
- Scoped: Created once per client request (e.g., one HTTP request). Use these for services that need to maintain state across a single operation, such as a database context or a user session.
- Singleton: Created the first time they are requested and live until the process ends. Use these for caching services, configuration wrappers, or shared state engines.
Solving the Captive Dependency Problem
If a Singleton must use a Scoped service, you cannot inject the Scoped service directly into the constructor. Instead, you must inject IServiceScopeFactory. This allows the Singleton to create a temporary, manual scope, resolve the service, and dispose of it immediately after use.
Implementation Example
Consider a BackgroundWorker (Singleton) that needs to log data to a database using a AppDbContext (Scoped).
// Registration in Program.cs
builder.Services.AddDbContext<AppDbContext>(options => { /* config */ });
builder.Services.AddSingleton<IBackgroundWorker, BackgroundWorker>();
// The Correct Implementation
public class BackgroundWorker : IBackgroundWorker
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task DoWorkAsync()
{
// Create a manual scope to resolve Scoped services
using (IServiceScope scope = _scopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Perform database operations here
await context.SaveChangesAsync();
}
// The scope is disposed here, and the DbContext is released
}
}
Trade-offs and Performance Risks
While IServiceScopeFactory solves the captive dependency, be mindful of the following:
- GC Pressure: Over-using
Transientregistrations for heavy objects (like large buffers or complex clients) increases the frequency of Garbage Collection (GC) cycles, which can spike CPU usage. - Thread Safety: Singletons are shared across all concurrent requests. If a Singleton maintains mutable state (e.g., a
List<T>), you must implement locking mechanisms or useConcurrentCollectionsto avoid race conditions. - Memory Leaks: If you create a manual scope using
CreateScope()but forget theusingblock, the resolved services will remain in memory, creating a leak identical to the captive dependency problem.
Verifying Your Lifetimes
To verify if your services are behaving as expected, you can inject a unique identifier into your service constructor:
- Create a service that generates a
Guidin its constructor and stores it in a public property. - Inject this service into two different components (e.g., a Controller and a Middleware) within the same HTTP request.
- If the
Guidis identical in both, it is correctly Scoped. If they differ, it is Transient. If theGuidremains the same across different browser refreshes, it is a Singleton.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.