DevgainsDevgainsDevgains
All articles

Dependency Injection in ASP.NET Core: Singleton, Scoped, and Transient

·9 min read·Updated Jul 8, 2026

Dependency injection in ASP.NET Core is built into the framework, and the single decision you make every time you register a service is its lifetime: Singleton, Scoped, or Transient. Pick the wrong one and you get bugs that are invisible in development and vicious in production — a database context shared across requests, a "singleton" that quietly holds a stale connection, a memory leak that only shows up under load. This guide explains what the three ASP.NET Core service lifetimes actually mean, how the built-in container creates and disposes your objects, and the one rule — avoiding the captive dependency — that prevents most lifetime bugs.

This is a supporting page in the Devgains .NET cluster. If you're still forming the async mental model, start with the pillar, Async/Await in C# Explained, then come back — most services you inject do async I/O.

Quick answer: what are the three service lifetimes?

ASP.NET Core's DI container can create a service in one of three ways:

  • Transient — a new instance every time it's requested. Even within a single request, two injections give you two different objects.
  • Scopedone instance per scope, and in a web app a scope is one HTTP request. Every service in that request that asks for it gets the same object; the next request gets a fresh one.
  • Singletonone instance for the entire application, created once and shared by every request and every thread until the process stops.

The rule of thumb: Scoped is the default you reach for, Singleton is for stateless shared services and expensive-to-build objects, and Transient is for lightweight, stateless helpers.

Why service lifetimes matter

The container isn't just handing you objects — it owns their creation and disposal. Choosing a lifetime is really choosing three things at once:

  • How much state is shared. A Singleton is shared across every concurrent request, so any mutable field in it is shared mutable state across threads — a data race waiting to happen.
  • When Dispose() runs. Scoped IDisposable services are disposed when the request ends; Singletons when the app shuts down; Transients disposed by the container are held until their scope ends, which can pin memory longer than you expect.
  • Whether a dependency is safe to hold. A long-lived service must not capture a shorter-lived one — the heart of the captive dependency bug below.

Get this right and the container manages object lifecycles for you. Get it wrong and you ship a leak or a race that no unit test will catch.

How to register services

You register services on IServiceCollection in Program.cs, one method per lifetime:

var builder = WebApplication.CreateBuilder(args);
 
// Transient: new instance every resolution — cheap, stateless helpers.
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
 
// Scoped: one instance per HTTP request — the common case for
// business/data services that touch per-request state.
builder.Services.AddScoped<IOrderService, OrderService>();
 
// Singleton: one instance for the whole app — stateless, thread-safe,
// or expensive-to-create shared resources.
builder.Services.AddSingleton<IClock, SystemClock>();
 
var app = builder.Build();

You never call new OrderService(...) yourself. When a controller or another service declares an IOrderService constructor parameter, the container resolves the whole graph — building the service and everything it depends on with the correct lifetime — and disposes what it owns when the scope ends.

public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;
 
    // The container injects the Scoped OrderService for this request.
    public OrdersController(IOrderService orders) => _orders = orders;
 
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => Ok(await _orders.GetAsync(id));
}

Singleton vs Scoped vs Transient: comparison

TransientScopedSingleton
Instances createdNew every resolutionOne per HTTP requestOne per application
Shared across requestsNoNoYes
Shared across threadsNo (unless you share it)No (one request = one thread)Yes — must be thread-safe
Disposed whenOwning scope endsRequest endsApp shuts down
Holds per-request state?AvoidYes, safeNever
Good forStateless lightweight helpersBusiness/data services, DbContextConfig, caches, clocks, HTTP client factories
Cost of wrong choiceExtra allocationsUsually safeRaces, leaks, stale state

Read this table as a risk gradient: Transient is the safest default and Singleton the most dangerous, because sharing one object across every concurrent request is exactly where thread -safety and stale-state bugs live.

The captive dependency trap

This is the mistake that produces the strangest bugs. A captive dependency happens when a longer-lived service captures a shorter-lived one, freezing it past its intended lifetime.

// Singleton — lives for the whole app.
public class OrderCache
{
    private readonly AppDbContext _db; // Scoped — meant to live one request!
 
    public OrderCache(AppDbContext db) => _db = db; // BUG: captured forever
}

Because OrderCache is a Singleton, it's built once — and the AppDbContext it grabbed is frozen with it. That DbContext is now used by every request for the life of the app: it's not thread-safe, it accumulates tracked entities, and it holds a database connection from the pool that never returns. You get intermittent DbContext threading exceptions and a slow memory climb that only good observability will help you trace back to its source.

The fix is to resolve the short-lived dependency when you need it, via IServiceScopeFactory:

public class OrderCache
{
    private readonly IServiceScopeFactory _scopeFactory;
 
    public OrderCache(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;
 
    public async Task<Order?> LoadAsync(int id)
    {
        using var scope = _scopeFactory.CreateScope();       // fresh scope
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        return await db.Orders.FindAsync(id);                // disposed with the scope
    }
}

In the Development environment ASP.NET Core's default provider validates scopes and will throw on a captured Scoped-in-Singleton at startup — which is exactly why you want that validation on. Never disable it to make an error go away.

Why EF Core's DbContext is Scoped

The canonical Scoped service is Entity Framework Core's DbContext. AddDbContext<AppDbContext>() registers it as Scoped on purpose:

  • A DbContext is a unit of work — it tracks the entities changed during one request and saves them together. That maps naturally onto request scope.
  • It is not thread-safe. One request runs on one logical flow, so a Scoped context is only ever touched by one thread at a time. Share it across requests (Singleton) and concurrent queries corrupt its change tracker.

So the lifetime isn't arbitrary — it falls out of what the object is. When you're unsure of a lifetime, ask "does this object carry per-request state or a non-thread-safe resource?" If yes, it's Scoped.

Best practices

  • Default to Scoped for services that do request-bound work, then narrow to Singleton or Transient only with a reason.
  • Keep Singletons stateless or thread-safe. No mutable instance fields unless every access is synchronized. Config, caches, and clocks make good Singletons.
  • Never inject a Scoped or Transient service into a Singleton. Use IServiceScopeFactory to create a scope when you need one (background services do this constantly).
  • Leave scope validation on in Development so captive dependencies fail loudly at startup.
  • Register against interfaces (AddScoped<IOrderService, OrderService>()) so lifetime and implementation stay swappable and testable.
  • Let the container own disposal. If a service is IDisposable and the container created it, don't dispose it yourself — the scope will.

Common mistakes

  • Scoped inside Singleton — the captive dependency. The most common lifetime bug.
  • DbContext as Singleton — threading exceptions and unbounded change-tracker growth under load.
  • Mutable state in a Singleton — a shared List<T> or counter mutated without a lock is a race across every concurrent request.
  • Using IServiceProvider as a service locator — resolving everything manually with GetService<T>() instead of constructor injection hides the dependency graph and defeats scope validation.
  • Transient IDisposable held by a long-lived object — the container keeps it alive until the outer scope ends, so it isn't as short-lived as the name suggests.

Key takeaways

  • Transient = new every time, Scoped = one per request, Singleton = one per app.
  • Scoped is the everyday default; Singleton is for stateless/shared/expensive; Transient for lightweight helpers.
  • Never capture a shorter lifetime in a longer one — resolve Scoped work through IServiceScopeFactory.
  • DbContext is Scoped because it's a non-thread-safe unit of work — a template for your own choices.
  • Keep scope validation on so the framework catches captive dependencies before your users do.

FAQ

What are service lifetimes in ASP.NET Core? They're the three ways the built-in DI container creates and disposes a service: Transient (a new instance every resolution), Scoped (one instance per HTTP request), and Singleton (one instance for the whole application). The lifetime controls how much state is shared and when Dispose() runs.

What is the difference between Scoped and Transient? A Scoped service is created once per request and reused everywhere in that request; a Transient service is created fresh on every single resolution, even multiple times within one request. Use Scoped when a request should share one object (like a DbContext), Transient for stateless helpers.

When should I use a Singleton? Use Singleton for stateless, thread-safe services and for objects that are expensive to build and safe to share — configuration, in-memory caches, clocks, and factories. Never store per-request state in a Singleton, and never inject a Scoped service into one.

Why can't I inject a Scoped service into a Singleton? Because the Singleton is built once and would freeze that Scoped instance forever — a captive dependency. The Scoped object (often a DbContext) then leaks across requests and threads. Resolve it on demand with IServiceScopeFactory.CreateScope() instead.

What lifetime should DbContext use? Scoped — which is what AddDbContext registers by default. A DbContext is a per-request unit of work and is not thread-safe, so it must not be shared across requests as a Singleton.

Conclusion

Dependency injection in ASP.NET Core removes the new keyword from your business code and hands object lifecycles to the container — but only if you tell it the right lifetime. Reach for Scoped by default, keep Singletons stateless and thread-safe, use Transient for cheap helpers, and never let a long-lived service capture a short-lived one. Leave scope validation on and the framework will catch the dangerous cases for you at startup. From here, the .NET cluster goes deeper into the services you inject and how they run in production — browse the full .NET guides, and when you ship these services, see how they deploy to production in the DevOps guides.

References

9 min read

Read next