Dependency Injection Lifetimes in ASP.NET Core
Dependency injection is baked into ASP.NET Core from the ground up. Every service you register has a lifetime that controls how and when instances are created and disposed. Getting lifetimes wrong leads to memory leaks, stale data, or the dreaded "captive dependency" problem.
The Three Lifetimes
ASP.NET Core's built-in DI container supports three lifetimes:
Transient
A new instance is created every time the service is requested from the container.
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
Use transient for lightweight, stateless services. Each consumer gets its own instance, so there are no thread-safety concerns — but you pay the cost of construction each time.
Scoped
One instance is created per scope. In a web application, a scope is created for each HTTP request, so all components within that request share the same instance.
builder.Services.AddScoped<IShoppingCart, ShoppingCart>();
Scoped is the default choice for most services, particularly those that depend on DbContext (which is itself scoped). The instance is disposed at the end of the request.
Singleton
A single instance is created the first time it is requested and reused for the entire application lifetime.
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
Singletons are ideal for stateless services or services that manage their own thread safety. They must be thread-safe because they are shared across all requests.
Seeing It in Action
Here is a quick way to observe lifetime behaviour:
public class OperationService
{
public Guid OperationId { get; } = Guid.NewGuid();
}
builder.Services.AddTransient<OperationService>(); // new each time
// builder.Services.AddScoped<OperationService>(); // one per request
// builder.Services.AddSingleton<OperationService>(); // one for ever
app.MapGet("/operation", (OperationService op1, OperationService op2) =>
new { op1 = op1.OperationId, op2 = op2.OperationId });
With transient registration, op1 and op2 will have different GUIDs. With scoped, they will share the same GUID within a single request but differ across requests. With singleton, every request returns the same GUID.
The Captive Dependency Problem
This is the single most common DI mistake. A captive dependency occurs when a longer-lived service holds a reference to a shorter-lived one:
// WRONG: Singleton capturing a scoped service
builder.Services.AddSingleton<IReportGenerator, ReportGenerator>();
builder.Services.AddScoped<IDbContext, AppDbContext>();
public class ReportGenerator : IReportGenerator
{
private readonly IDbContext _db; // This is now captive!
public ReportGenerator(IDbContext db)
{
_db = db; // This scoped instance will never be disposed
}
}
The ReportGenerator is a singleton, so it lives for the entire application. It captures AppDbContext, which should be disposed at the end of a request. The captured instance lingers, leading to stale connections and memory leaks.
ASP.NET Core can detect this at runtime. Enable scope validation in development:
var builder = WebApplication.CreateBuilder(args);
// This is enabled by default in Development environment
// It throws InvalidOperationException for captive dependencies
The framework sets ServiceProviderOptions.ValidateScopes = true in the development environment by default since .NET 6.
Resolving the Problem
If a singleton needs a scoped service, inject IServiceScopeFactory instead:
public class ReportGenerator : IReportGenerator
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportGenerator(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<Report> GenerateAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbContext>();
// Use db within this scope — it will be properly disposed
return await db.Reports.FirstAsync();
}
}
Choosing the Right Lifetime
| Lifetime | Instance per... | Best for |
|---|---|---|
| Transient | Resolution | Lightweight, stateless services |
| Scoped | HTTP request (scope) | DbContext, unit-of-work, per-request state |
| Singleton | Application | Caches, configuration, thread-safe utilities |
A simple rule of thumb: start with scoped. Move to singleton if the service is stateless and expensive to create. Use transient when you need guaranteed isolation between consumers within the same scope.
Key Takeaways
Lifetimes are not just a registration detail — they directly affect memory usage, thread safety, and correctness. Always validate scopes in development, watch out for captive dependencies, and default to scoped unless you have a clear reason for another lifetime.