Managing Service Lifetimes and Trust Boundaries in ASP.NET Core DI
Avoid captive dependencies and state leaks in C# by correctly implementing ASP.NET Core DI lifetimes, enforcing trust boundaries, and using scope validation.
28 Apr 2026, 11:43 UTC

The Problem: Captive Dependencies and State Leaks
In complex C# applications, incorrectly managing service lifetimes often leads to "captive dependencies"—where a service with a long lifetime (Singleton) holds a reference to a service with a short lifetime (Scoped). This results in the scoped service living longer than intended, causing memory leaks, stale data, or ObjectDisposedException when the scoped service attempts to access a disposed resource, such as a database context.
Smallest Suitable Design
The most resilient design utilizes constructor injection with abstractions (interfaces) and explicit lifetime registrations. This ensures components are loosely coupled and their dependencies are transparent.
Lifetime Selection
- Transient: Created every time they are requested. Best for lightweight, stateless services.
- Scoped: Created once per client request (connection). Ideal for
DbContextor user-session state. - Singleton: Created once for the application lifetime. Used for caching services or configuration wrappers.
Implementation Example
Run these registrations in Program.cs using the IServiceCollection:
// Registration phase
builder.Services.AddTransient<IMessageService, EmailService>();
builder.Services.AddScoped<IUserContext, UserContext>();
builder.Services.AddSingleton<ICacheProvider, RedisCacheProvider>();
// Consumer phase
public class OrderProcessor
{
private readonly IMessageService _messageService;
private readonly IUserContext _userContext;
public OrderProcessor(IMessageService messageService, IUserContext userContext)
{
_messageService = messageService;
_userContext = userContext;
}
}
Trust and Data Boundaries
The DI container enforces boundaries by constructing the object graph based strictly on constructor signatures. A component cannot access services it does not explicitly request. To maintain this boundary, avoid the Service Locator anti-pattern: never inject IServiceProvider into a class to manually call GetService<T>(). Doing so hides dependencies and bypasses the container's ability to validate the object graph.
Operational Checks
To prevent runtime failures, validate the container configuration during the application startup sequence.
Scope Validation
In development environments, ASP.NET Core enables scope validation by default. You can explicitly ensure this is active when building the provider to catch captive dependencies before they hit production:
// Required permissions: Application startup/configuration context
var serviceProvider = services.BuildServiceProvider(validateScopes: true);
Expected Result: If a Singleton service declares a Scoped service in its constructor, the application will throw an InvalidOperationException immediately upon startup.
Verifying Request Isolation
To verify that Scoped services are unique per request but shared within a single request, implement a diagnostic endpoint:
app.MapGet("/debug-di", (IUserContext context1, IUserContext context2) =>
{
// context1 and context2 should be the same instance in one request
bool isSameInstance = ReferenceEquals(context1, context2);
return Results.Ok(new { IsSameInstance = isSameInstance, Hash = context1.GetHashCode() });
});
Run the app and call the endpoint twice. The IsSameInstance should be true for both calls, but the Hash should differ between the two separate HTTP requests.
Failure Modes
| Failure | Cause | Result |
|---|---|---|
| Missing Registration | Type not added to IServiceCollection |
InvalidOperationException at resolution |
| Circular Dependency | Class A requires B, and B requires A | StackOverflowException during instantiation |
| Captive Dependency | Singleton injecting a Scoped service | Stale state or ObjectDisposedException |
Conditions for Design Change
The built-in container is a "conformance" container, not a full-featured framework. You should migrate to a third-party container (e.g., Autofac) if the following requirements emerge:
- Dynamic Resolution: Need for named or keyed services (though .NET 8+ introduced basic keyed services).
- Interception: Requirement for Aspect-Oriented Programming (AOP) to inject logic (like logging) around method calls without modifying the class.
- Property Injection: When constructor injection is impossible due to legacy framework constraints.
Limitations and Verification
The built-in container does not support automatic disposal of transient services if they are captured by a singleton. To verify your current setup is safe:
- Run the application with
validateScopes: true. - Check logs for
InvalidOperationExceptionduring the startup phase. - Use the
ReferenceEqualscheck in a test endpoint to confirm Scoped services are not leaking across requests.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.