Avoiding Captive Dependencies in .NET Dependency Injection
Learn how to identify and fix captive dependencies in .NET to prevent memory leaks and state corruption when mixing Singleton and Scoped services.
29 Sept 2025, 23:52 UTC

The Danger of the 'Forever' Service
A common failure point in .NET applications is the Captive Dependency. This happens when a service with a long lifetime—typically a Singleton—depends on a service with a shorter lifetime, such as a Scoped service. Because the Singleton is only instantiated once, it "captures" the Scoped service, forcing it to live as long as the application does.
The result is often silent but destructive: database connections that never close, stale user session data, or memory leaks that eventually crash the process. The takeaway is simple: Never inject a shorter-lived service into a longer-lived one.
Understanding the Three Lifetimes
To prevent these leaks, you must align your service lifetimes with how the data they handle is used. In .NET (Core 3.1 through .NET 8+), the IServiceCollection provides three primary options:
- Transient: Created every time they are requested. Use these for lightweight, stateless components.
- Scoped: Created once per client request (e.g., a single HTTP request). This is the standard for
DbContextand user-specific state. - Singleton: Created once and shared globally. Use these for caching services or configuration objects that never change.
The Captive Dependency Scenario
Consider a scenario where you have a UserSessionService (Scoped) that tracks the current logged-in user, and a GlobalCacheService (Singleton) that logs activity. If the GlobalCacheService injects the UserSessionService in its constructor, the first user to trigger the cache will have their session "stuck" in the Singleton for the remainder of the application's life. Every subsequent user will then be logged as the first user.
Correcting the Architecture with IServiceScopeFactory
If a Singleton truly needs access to a Scoped service, you cannot use constructor injection. Instead, you must manually create a scope. This ensures the Scoped service is disposed of immediately after the task is complete.
// Run this in your Singleton service implementation
public class GlobalCacheService : IGlobalCacheService
{
private readonly IServiceScopeFactory _scopeFactory;
public GlobalCacheService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task ProcessScopedWorkAsync()
{
// Create a manual scope to resolve Scoped services safely
using (IServiceScope scope = _scopeFactory.CreateScope())
{
var scopedService = scope.ServiceProvider.GetRequiredService<IUserSessionService>();
await scopedService.DoWorkAsync();
}
// The scopedService is disposed here, preventing a memory leak
}
}
Validation and Guardrails
You don't have to rely on manual code reviews to catch these errors. .NET has a built-in validation check that prevents Scoped services from being injected into Singletons during development.
In your Program.cs, ensure the DefaultServiceProvider is configured to validate scopes. This is typically enabled by default in the Development environment:
var builder = WebApplication.CreateBuilder(args);
// This ensures the app throws an InvalidOperationException if a
// captive dependency is detected at startup
builder.Host.UseDefaultServiceProvider(options >= options.ValidateScopes = true);
Risk Assessment: The Performance Trade-off
| Lifetime | Primary Risk | Performance Impact |
|---|---|---|
| Transient | GC Pressure | High allocation rate if objects are heavy. |
| Scoped | Captive Dependency | Moderate; tied to request lifecycle. |
| Singleton | Thread Safety | Low allocation, but requires ConcurrentDictionary or locks. |
Practical Verification
To verify your lifetimes are working as intended, create a temporary service that injects a Guid. Inject this service into two different controllers or classes. If the Guid remains the same across different HTTP requests, you have a Singleton. If it changes every request, it is Scoped. If it changes every time you call the service within the same request, it is Transient.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.