Skip to content

.NET Core · Dependency Injection

Service Lifetimes in .NET Core DI

6 min read Updated 2026-06-23 Share:

Practice Service Lifetimes interview questions

Why lifetime bugs are the hardest DI bugs to catch

Lifetime mismatches in .NET Core DI are insidious. The app starts fine, unit tests pass, and the bug only surfaces under concurrent load when a shared DbContext corrupts EF's change tracker — or a stale config value from a Singleton serves wrong prices to every customer. This article explains the three lifetimes, when each is right, and how to detect mismatches before they reach production.

The three lifetimes at a glance

// Singleton — one instance for the entire app lifetime:
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();

// Scoped — one instance per HTTP request (or per explicit scope):
builder.Services.AddScoped<AppDbContext>();

// Transient — new instance on every resolve:
builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();
LifetimeCreatedDisposedThread-safe required?
SingletonApp startApp shutdownYes
ScopedPer requestEnd of requestNo (one request = one thread)
TransientPer resolveEnd of scopeNo (own instance)

Singleton — shared, thread-safe, long-lived

Use Singleton for services that are stateless or explicitly thread-safe, expensive to create, or meant to be shared across all requests.

// Good singletons:
builder.Services.AddSingleton<IHttpClientFactory>(); // pool management — thread-safe
builder.Services.AddSingleton<IMemoryCache, MemoryCache>(); // thread-safe by design
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis"]));

// Thread-safe counter:
public class RequestCounter
{
    private long _count;
    public void Increment() => Interlocked.Increment(ref _count);
    public long Get()       => Interlocked.Read(ref _count);
}

// Unsafe singleton — shared mutable state without synchronization:
public class OrderCache
{
    private List<Order> _orders = new(); // not thread-safe!
    public void Add(Order o) => _orders.Add(o); // race condition under concurrent requests
}

If a Singleton holds mutable state, protect it with Interlocked, lock, or concurrent collections (ConcurrentDictionary, ConcurrentQueue).

Scoped — the right home for DbContext

Scoped services live for exactly one HTTP request. Two classes that both inject the same Scoped service within the same request share the same instance — which is exactly what you want for a DbContext unit of work.

builder.Services.AddScoped<AppDbContext>();

// Both services get the same AppDbContext within one request:
public class OrderService
{
    public OrderService(AppDbContext db) { _db = db; }
}

public class InventoryService
{
    public InventoryService(AppDbContext db) { _db = db; }
    // Same instance as OrderService — shared transaction possible
}

// Creating a scope manually (needed outside requests):
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync(); // own connection; disposed at end of using
}

The "scope" in ASP.NET Core maps to an HTTP request. In background services, Blazor circuits, and gRPC calls, you create scopes explicitly with IServiceScopeFactory.

Transient — cheap and stateless

Transient services are created anew on every resolve. They're safe for non-thread-safe objects because each consumer gets its own instance. The trade-off is allocation cost.

builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();

// Demonstration:
public class SignupService
{
    public SignupService(IEmailValidator v1, IEmailValidator v2)
    {
        Console.WriteLine(ReferenceEquals(v1, v2)); // false — different instances
    }
}

Avoid Transient for expensive-to-construct services or services holding resources like connections — you'll create and dispose them on every injection, multiplying overhead.

Captive dependencies — the most dangerous lifetime bug

A captive dependency occurs when a longer-lived service holds a shorter-lived service in its constructor. The short-lived service is "captured" and effectively becomes long-lived.

// Singleton captures a Scoped service:
public class ProductCache  // Singleton
{
    private readonly AppDbContext _db; // AppDbContext is Scoped

    public ProductCache(AppDbContext db)
    {
        _db = db;
        // _db now lives forever — request-specific EF change tracker shared across ALL requests
        // Symptoms: stale data, entity tracking conflicts, ObjectDisposedException
    }
}

This is silent in development and only manifests under concurrent load or across requests.

// Fix: inject IServiceScopeFactory; create a scope per operation:
public class ProductCache
{
    private readonly IServiceScopeFactory _factory;

    public ProductCache(IServiceScopeFactory factory) => _factory = factory;

    public async Task<IReadOnlyList<Product>> GetAllAsync()
    {
        using var scope = _factory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        return await db.Products.AsNoTracking().ToListAsync();
        // scope and db disposed at end of using block
    }
}

The valid captive directions: Singleton can hold Singleton. Scoped can hold Scoped or Singleton. Transient can hold anything. The invalid direction: any longer-lived service capturing a shorter-lived one.

IServiceScopeFactory — the tool for background work

Background services (IHostedService, BackgroundService) are registered as Singletons. They can't inject Scoped services directly. IServiceScopeFactory creates an explicit scope per work unit.

public class DataSyncJob : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public DataSyncJob(IServiceScopeFactory factory) => _scopeFactory = factory;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // Fresh scope per sync cycle — own DbContext, own transaction:
            using (var scope = _scopeFactory.CreateScope())
            {
                var db   = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();

                await sync.SyncPendingOrdersAsync(ct);
                await db.SaveChangesAsync(ct);
            } // scope, db, and sync disposed here

            await Task.Delay(TimeSpan.FromMinutes(5), ct);
        }
    }
}

IServiceScopeFactory is itself Singleton — safe to inject into any lifetime.

Disposal — who calls Dispose?

The container automatically disposes IDisposable and IAsyncDisposable services when their scope ends. You should not call Dispose on injected services manually.

// The container calls DisposeAsync at request end:
public class ReportWriter : IAsyncDisposable
{
    private readonly StreamWriter _writer;

    public ReportWriter() => _writer = new StreamWriter("report.csv");

    public async ValueTask DisposeAsync() => await _writer.DisposeAsync();
}

builder.Services.AddScoped<ReportWriter>();
// DisposeAsync called automatically when the HTTP request scope ends

// Root-scope transient IDisposable memory leak:
// Resolving a Transient IDisposable from app.Services (root scope) means it won't
// be disposed until app shutdown — always use a child scope for one-off resolutions.
using var scope = app.Services.CreateScope();
var writer = scope.ServiceProvider.GetRequiredService<ReportWriter>();
// Disposed when scope is disposed

ValidateScopes — catch bugs at startup

builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
    bool isDev = ctx.HostingEnvironment.IsDevelopment();
    options.ValidateScopes  = isDev; // throws if Scoped is captured by Singleton
    options.ValidateOnBuild = isDev; // throws if any dependency can't be resolved
});

With ValidateScopes = true, the example captive dependency throws InvalidOperationException at app.Build() — not silently on the 10th concurrent request.

Add an integration test that builds the real container with these options enabled to make scope validation part of CI:

[Fact]
public void ServiceGraph_IsValid()
{
    var services = new ServiceCollection();
    Startup.ConfigureServices(services);

    using var provider = services.BuildServiceProvider(
        new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });

    Assert.NotNull(provider); // build succeeds = graph is valid
}

Lifetimes in Blazor Server

Blazor Server changes what "Scoped" means. A Scoped service in Blazor Server lives for the SignalR circuit (browser tab session), not for an HTTP request. This makes Scoped effectively per-user-session — much longer than ASP.NET Core Scoped.

// In Blazor Server:
builder.Services.AddScoped<IUserState, UserState>();
// One instance per browser tab — persists across component navigations

// Singleton in Blazor Server = shared across ALL connected browser tabs:
builder.Services.AddSingleton<ISharedCache, MemoryCache>();
// All users share this — only safe for truly thread-safe, shared state

Recap

Singleton lives for the app, Scoped lives for the request, and Transient lives per resolve. Captive dependencies — Singletons capturing Scoped or Transient services — are the most dangerous lifetime bug: silent in dev, corrupting in production. Use IServiceScopeFactory in Singletons and background services that need Scoped resources. The container owns disposal for IDisposable services — never call Dispose on injected services yourself. Enable ValidateScopes and ValidateOnBuild in development to turn lifetime mismatches into startup errors rather than production incidents.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel