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.

Example.cs
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.

Example.cs
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.

Example.cs
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:

Example.cs
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:

Example.cs
// 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:

Example.cs
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:

ReportGenerator.cs
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.