← Back to all posts

Dependency Injection Lifetimes in .NET Core

Dependency Injection Lifetimes in .NET Core

Reading Time: 4 minutes

The Problem

One of the most common sources of subtle bugs in ASP.NET Core applications is choosing the wrong dependency injection (DI) lifetime. You register your services, inject them into controllers and other services, and everything seems to work - until you hit a captive dependency bug in production that’s nearly impossible to reproduce locally.

Consider this scenario: you have a UserService that needs a DbContext. You register the DbContext as Scoped (which is the correct default for EF Core), but accidentally register UserService as Singleton. Now your singleton service holds a reference to a scoped DbContext that was created for a single HTTP request - and it keeps using that stale context for every subsequent request. Database connections leak, stale data is returned, and chaos ensues.

// ❌ WRONG: Captive dependency bug
builder.Services.AddSingleton<IUserService, UserService>(); // Singleton captures...
builder.Services.AddScoped<MyDbContext>(); // ...a Scoped dependency!

// UserService will hold a stale DbContext forever
public class UserService : IUserService
{
    private readonly MyDbContext _db;
    public UserService(MyDbContext db) // This DbContext was created for ONE request
    {
        _db = db;
    }
}

The Solution

Understanding and correctly applying the three DI lifetimes in ASP.NET Core will eliminate this entire class of bugs. Let’s break down each lifetime and when to use it.

Transient Lifetime

A Transient service is created every time it is requested from the DI container. If two classes in the same request both inject a Transient service, they each get their own separate instance.

Use Transient for:

  • Lightweight, stateless services
  • Services that should not share state between consumers
  • Utility/helper services (e.g., formatters, validators)
// Registration
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

// Usage - each injection point gets a NEW instance
public class OrderController : ControllerBase
{
    private readonly IEmailSender _emailSender;

    public OrderController(IEmailSender emailSender)
    {
        _emailSender = emailSender; // Fresh instance
    }
}

public class NotificationService
{
    private readonly IEmailSender _emailSender;

    public NotificationService(IEmailSender emailSender)
    {
        _emailSender = emailSender; // Different fresh instance
    }
}

Scoped Lifetime

A Scoped service is created once per HTTP request (or more precisely, once per DI scope). All classes that inject the same Scoped service within the same request share the same instance, but different requests get different instances.

Use Scoped for:

  • Database contexts (EF Core DbContext)
  • Unit-of-work patterns
  • Services that maintain state within a single request
  • Repository implementations
// Registration
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

// Usage - same instance within one HTTP request
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly AppDbContext _db;

    public OrderService(IOrderRepository orders, AppDbContext db)
    {
        _orders = orders; // Same instance as any other class in this request
        _db = db;
    }
}

Singleton Lifetime

A Singleton service is created once for the entire application lifetime and shared by every request and every consumer. The same instance is reused until the application shuts down.

Use Singleton for:

  • Configuration/settings wrappers (read-only after startup)
  • In-memory caches
  • Connection pool managers
  • Services that are expensive to create and thread-safe
  • Background services
// Registration
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

// Usage - always the same instance across ALL requests
public class ProductService
{
    private readonly ICacheService _cache;

    public ProductService(ICacheService cache)
    {
        _cache = cache; // The one and only instance
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        return await _cache.GetOrSetAsync($"product:{id}", () => FetchFromDb(id));
    }
}

Choosing the Right Lifetime

LifetimeCreatedShared WithinBest For
TransientEvery request to the containerNot sharedStateless utilities, lightweight services
ScopedOnce per HTTP requestSame HTTP requestDbContext, repositories, unit of work
SingletonOnce at startupEntire applicationCaches, config, expensive thread-safe services

Captive Dependency Trap

The captive dependency problem occurs when a service with a longer lifetime depends on a service with a shorter lifetime. The longer-lived service “captures” and holds onto the shorter-lived instance, preventing it from being properly disposed.

The rule: A service should only depend on services with equal or longer lifetimes.

// ❌ WRONG: Singleton captures Scoped
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddScoped<AppDbContext>();

// ❌ WRONG: Singleton captures Transient
builder.Services.AddSingleton<IReportService, ReportService>();
builder.Services.AddTransient<IDataProcessor, DataProcessor>();

// ✅ CORRECT: Scoped can capture Transient (Scoped >= Transient)
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailValidator, EmailValidator>();

// ✅ CORRECT: Singleton captures Singleton
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddSingleton<ILogger<ConfigService>>();

ASP.NET Core will throw an InvalidOperationException at runtime if scope validation is enabled (it is by default in Development). Always run your app in Development mode to catch these issues early!

Summary

Getting DI lifetimes right is fundamental to building reliable ASP.NET Core applications:

  • Transient - new instance every time, best for stateless, lightweight services
  • Scoped - one instance per HTTP request, best for DbContext and repositories
  • Singleton - one instance forever, best for caches and expensive thread-safe services
  • Never inject a shorter-lived service into a longer-lived one - the captive dependency trap will cause hard-to-debug issues

When in doubt, default to Scoped for most application services, Transient for utilities, and only reach for Singleton when you have a specific need and can guarantee thread safety.

References