Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 004 / 100

DI lifetimes: Singleton, Scoped, Transient — a deep dive

The bug I see most in code reviews is a lifetime mismatch.

Expona Admin6 min read

DI Lifetimes: Singleton, Scoped, Transient — The Most Common Bug in Code Reviews

I've reviewed hundreds of .NET services over the past five years, and there's one pattern that shows up in nearly every codebase: a developer confidently registering a service with the wrong lifetime, then wondering why their DbContext is shared across requests or their singleton keeps holding stale data. Dependency injection lifetimes aren't complicated, but getting them wrong quietly breaks your application in ways that only show up under load or after weeks of production traffic. Today we're going to fix that.

Understanding the Three Lifetimes

ASP.NET Core gives you three lifetime choices when registering a service: Singleton, Scoped, and Transient. The lifetime controls how long the framework keeps an instance around and how many requests or components share it. Pick based on state and thread-safety, not habit.

Lifetime Use For Thread-Safe Required Examples
Singleton Stateless, thread-safe services, caches, configuration readers Yes ILogger, IConfiguration, cache layer, email sender
Scoped Per-request state, database contexts, unit-of-work patterns No DbContext, repositories, request-specific services
Transient Lightweight, stateless helpers, value objects No Validators, formatters, temporary builders

Here's how I think about it: Singleton is forever. One instance for the entire application lifetime. Scoped is per-request. A new instance at the start of an HTTP request, disposed at the end. Transient is on-demand. A fresh instance every time you ask for it.

Let me show you the registration:

csharp
var builder = WebApplication.CreateBuilder(args);

// Singleton: one instance for the app's lifetime
builder.Services.AddSingleton<ILogger>(new ConsoleLogger());
builder.Services.AddSingleton<ICache, RedisCache>();

// Scoped: new instance per HTTP request
builder.Services.AddScoped<AppDbContext>();
builder.Services.AddScoped<IUserRepository, UserRepository>();

// Transient: new instance every time
builder.Services.AddTransient<IValidator<User>, UserValidator>();
builder.Services.AddTransient<CsvBuilder>();

var app = builder.Build();

Singleton is fast because the instance is created once, at startup, and reused. Scoped is the middle ground: you get isolation between requests but avoid the overhead of creating thousands of instances per second. Transient is the safest but slowest—useful for stateful objects that have a short life.

The Captive Dependency: Your Silent Killer

Here's the bug I see most often in production: a Singleton that injects a Scoped service. The framework allows it at registration time, but you've just created a time bomb.

When a Singleton depends on a Scoped service, the Scoped instance is created once (when the Singleton is first resolved) and then held by the Singleton forever. Your DbContext is now shared across all requests. Your request-specific state is shared across users. This is catastrophic.

Let me show you the problem:

csharp
public interface IUserService
{
    Task<User> GetUserAsync(int id);
}

public class UserService : IUserService
{
    private readonly AppDbContext _context;

    // This receives a SCOPED DbContext
    public UserService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<User> GetUserAsync(int id)
    {
        return await _context.Users.FirstOrDefaultAsync(u => u.Id == id);
    }
}

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<AppDbContext>();

// PROBLEM: UserService is SINGLETON, but it depends on SCOPED DbContext
builder.Services.AddSingleton<IUserService, UserService>();

var app = builder.Build();

The first request resolves IUserService, which creates a UserService instance and injects a DbContext. The `UserService` is stored as a Singleton. Request two comes in, asks for `IUserService`, and gets the same instance—with the same `DbContext` from request one. Both requests are now using the same database connection and session. Objects are stale, transactions conflict, and you lose your mind debugging this at 2 AM.

Let the Framework Catch It at Startup

ASP.NET Core has built-in guards to catch captive dependencies, but you have to enable them. Use ValidateScopes and ValidateOnBuild:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<AppDbContext>();
builder.Services.AddSingleton<IUserService, UserService>();

// Enable validation: catch the bug at startup, not in production
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

var app = builder.Build();
app.Run();

When you start this app, the container will inspect the entire graph and throw an exception the moment it finds a Singleton depending on a Scoped service:

InvalidOperationException: Cannot resolve scoped service 'AppDbContext' from root provider.

This is gold. You catch the mistake immediately, in development, not when a customer's data gets corrupted in production. I enable both flags on every new project by default. The cost is maybe 50ms at startup; the benefit is catching architectural mistakes before they deploy.

In production builds, you might disable `ValidateScopes` for performance (the Scoped check runs on every request), but always keep `ValidateOnBuild` on. It's one-time only.

Need Scoped Work in a Singleton? Use IServiceScopeFactory

Sometimes you have a legitimate Singleton that needs to do Scoped work. The classic case is a background service that runs periodically and needs to talk to the database without an HTTP request context.

Don't inject the Scoped service directly. Instead, inject IServiceScopeFactory and create a scope on demand:

csharp
public interface IEmailBackgroundService
{
    Task SendPendingEmailsAsync();
}

public class EmailBackgroundService : IEmailBackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<EmailBackgroundService> _logger;

    public EmailBackgroundService(
        IServiceScopeFactory scopeFactory,
        ILogger<EmailBackgroundService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    public async Task SendPendingEmailsAsync()
    {
        // Create a new scope for this unit of work
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        var pendingEmails = await dbContext.PendingEmails
            .Where(e => e.Status == EmailStatus.Pending)
            .ToListAsync();

        _logger.LogInformation("Found {Count} pending emails", pendingEmails.Count);

        foreach (var email in pendingEmails)
        {
            email.Status = EmailStatus.Sent;
            email.SentAt = DateTime.UtcNow;
        }

        await dbContext.SaveChangesAsync();
    }
}

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<AppDbContext>();

// Singleton service that does scoped work
builder.Services.AddSingleton<IEmailBackgroundService, EmailBackgroundService>();

var app = builder.Build();

Each time `SendPendingEmailsAsync()` runs, you create a fresh scope, get a fresh `DbContext`, do your work, and dispose the scope. No state leaks. No shared connections. This is the standard pattern for background workers, Hangfire jobs, and periodic tasks.

The key: IServiceScopeFactory is always Singleton-safe. You can inject it into anything.

Common Mistakes

  • Registering DbContext as Singleton — I see this occasionally from developers coming from other frameworks. DbContext is stateful and not thread-safe by default. Always Scoped.
  • Making repository Singleton — Repositories often wrap DbContext or hold state. Keep them Scoped alongside DbContext.
  • Transient by default — Some developers register everything as Transient to be "safe". You'll create millions of instances per second and tank performance. Use Scoped as your default for services; Singleton for stateless utilities.
  • Ignoring ValidateScopes in development — Then being surprised when the bug appears only under production load.
  • Forgetting to dispose IServiceScope — When using IServiceScopeFactory, always wrap it in using. Otherwise, Scoped dependencies are never disposed.

Key Takeaways

  • Lifetime is about who holds the reference and for how long. Pick based on state and thread-safety, not habit.
  • Singleton is fast and shared forever. Use it for stateless, thread-safe services like loggers, caches, and config readers.
  • Scoped is per-request isolation. Use it for DbContext, repositories, and anything with request-specific state.
  • Transient is safest but slowest. Use it for lightweight, stateless helpers or when you need a fresh instance every time.
  • A Singleton depending on a Scoped service keeps the Scoped instance alive forever. This is a captive dependency, and it breaks your application silently.
  • Enable ValidateScopes = true and ValidateOnBuild = true to catch captive dependencies at startup, not in production.
  • When a Singleton legitimately needs Scoped work, inject IServiceScopeFactory and create a scope on demand. Always use using to dispose the scope.
  • DbContext is never Singleton. Always Scoped. If you're tempted to make it Singleton, you're solving the wrong problem.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles