All articles

.NET Dependency Injection Object Lifetimes

The built-in .NET dependency injection container ships three service lifetimes: Transient (a new instance on every resolution), Scoped (one instance per…

0 · log in to like, save & follow Share on LinkedIn Share on X

The built-in .NET dependency injection container ships three service lifetimes: Transient (a new instance on every resolution), Scoped (one instance per scope, which for a web app means per HTTP request), and Singleton (one instance for the whole application). Picking the right one is mostly about how much shared state a service holds and how expensive it is to build. This article uses a small .NET 9 (C# 13) console demo that prints instance ids so you can see exactly when a new object is created, then works through the pitfalls that bite people in production — chiefly the captive dependency problem.

.NET Dependency Injection Object Lifetimes

The three lifetimes at a glance

You register a service against an interface (or concrete type) with one of three methods on IServiceCollection:

  • AddTransient<TInterface, TImpl>() — a fresh instance every time the service is requested, even twice within the same scope.
  • AddScoped<TInterface, TImpl>() — one instance per scope. In ASP.NET Core the framework opens a scope per request, so scoped means "once per request".
  • AddSingleton<TInterface, TImpl>() — one instance created the first time it is needed (or the instance you pass in), reused for the application's entire lifetime.
builder.Services.AddTransient<IReportBuilder, ReportBuilder>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();

The container is thread-safe for resolution, and singletons in particular must be safe to use from multiple threads because the same instance is shared everywhere. There is also a fourth registration style that is easy to overlook: AddSingleton(instance), where you construct the object yourself and hand it to the container. That instance is a singleton by definition, but the container did not create it, which changes how disposal works — more on that below.

Registration order does not matter for lifetimes, but it does matter for duplicates: the last registration for a given service type wins when you resolve a single instance, while GetServices<T>() returns all of them in registration order. Keep this in mind when a library and your own code both register the same interface.

Watch the lifetimes with instance ids

The clearest way to internalize this is to give each service a Guid at construction and print it. Register one service of each lifetime, then resolve each one twice inside two separate scopes.

Console.WriteLine("Scope 1:");
using (var scope = provider.CreateScope())
{
    var sp = scope.ServiceProvider;
    Print("transient A", sp.GetRequiredService<ITransientSvc>());
    Print("transient B", sp.GetRequiredService<ITransientSvc>());
    Print("scoped A", sp.GetRequiredService<IScopedSvc>());
    Print("scoped B", sp.GetRequiredService<IScopedSvc>());
    Print("singleton", sp.GetRequiredService<ISingletonSvc>());
}

Running this against a second scope shows the pattern precisely:

  • The two transient ids differ every single time.
  • The two scoped ids match within a scope but differ between scope 1 and scope 2.
  • The singleton id is identical everywhere, across both scopes.

That single output tells you everything about which services share state and which do not. The Print helper just writes the label and the service's Id property:

static void Print(string label, IHasId svc) =>
    Console.WriteLine($"  {label,-12} -> {svc.Id}");

Because the ids are stable and human-readable, the demo doubles as a regression check: if a refactor accidentally promotes a scoped service to singleton, its id stops changing between scopes and you notice immediately.

The captive dependency problem

The most common lifetime bug is a captive dependency: injecting a shorter-lived service into a longer-lived one. If a singleton takes a scoped (or transient) service through its constructor, the container resolves that dependency exactly once — when it builds the singleton — and the singleton then holds that one instance forever. Your "scoped" service is now effectively a singleton, captured inside its captor.

// BAD: OrderRepository is scoped, but this cache lives forever.
public sealed class OrderCache(IOrderRepository repo) // singleton
{
    // 'repo' is captured for the app's lifetime.
}

This is dangerous for two reasons. First, a DbContext and anything wrapping it is not thread-safe and is not meant to survive one request — a captured one accumulates tracked entities and leaks memory. Second, per-request data (the current user, a request id) silently freezes to whatever the first request happened to carry.

C# registration of Transient, Scoped, and Singleton services in .NET 9

Why DbContext is scoped

EF Core's AddDbContext registers your context as Scoped by default, and that is the correct choice. A DbContext holds a database connection and a change tracker; both are cheap to create per request and must not be shared across concurrent requests. Registering it as a singleton is the textbook captive-dependency mistake — you will see A second operation was started on this context instance errors under load, because two requests are hitting the same change tracker at once.

The rule of thumb: a service should never depend on something with a shorter lifetime than its own. Singletons may depend on singletons; scoped may depend on scoped or singleton; transient may depend on anything. Any edge that points from a longer lifetime to a shorter one is a bug waiting for load.

Resolving scoped services inside a singleton

Sometimes a singleton genuinely needs a scoped service — a background worker that processes queue messages, for instance, each message deserving its own DbContext. The fix is not to inject the scoped service; it is to inject IServiceScopeFactory and open a fresh scope per unit of work.

public sealed class QueueWorker(IServiceScopeFactory scopeFactory)
{
    public async Task ProcessAsync(Message message)
    {
        using var scope = scopeFactory.CreateScope();
        var repo = scope.ServiceProvider
            .GetRequiredService<IOrderRepository>();
        await repo.SaveAsync(message.ToOrder());
    } // scope disposed here -> DbContext disposed here
}

Each CreateScope() produces an isolated scope with its own scoped instances, disposed when the using block exits. This is exactly how ASP.NET Core itself creates a scope around every request.

Scope validation catches captives early

The default host in .NET 9 turns on scope validation in the Development environment. When ValidateScopes is enabled, the container throws at resolution time if it detects a scoped service being resolved from the root provider (the singleton scope), which surfaces captive dependencies immediately instead of as a mysterious production leak.

var provider = services.BuildServiceProvider(
    new ServiceProviderOptions
    {
        ValidateScopes = true,   // reject scoped-from-root
        ValidateOnBuild = true   // check the whole graph at startup
    });

ValidateOnBuild goes further and walks the entire registration graph when the provider is built, failing fast at startup for missing or mis-scoped dependencies. Keep both on outside production.

Disposal semantics per lifetime

The container owns disposal of the instances it creates. If a service implements IDisposable or IAsyncDisposable, the container tracks it and disposes it at the end of the relevant lifetime:

  • Transient and Scoped disposables are disposed when their owning scope is disposed.
  • Singleton disposables are disposed when the root provider (the application host) shuts down.

One caveat: a transient service that implements IDisposable is still held by its scope until that scope ends, so resolving many transient disposables inside a long-lived scope can pile up. Instances you create yourself and pass via AddSingleton(instance) are not disposed by the container — you own those.

How to choose a lifetime

  • Singleton for stateless, thread-safe services and shared caches or configuration: clocks, HTTP clients (via IHttpClientFactory), option snapshots.
  • Scoped for anything tied to a request or unit of work, and for anything that touches a DbContext.
  • Transient for lightweight, stateless helpers where a fresh instance costs nothing and sharing would be surprising.

When unsure, default to Scoped in a web app — it matches the request boundary most services actually want.

Key takeaways

  • Transient = new per resolution, Scoped = one per scope/request, Singleton = one per app.
  • Never inject a shorter-lived service into a longer-lived one — that is a captive dependency.
  • DbContext is Scoped because it is not thread-safe and belongs to one request.
  • Use IServiceScopeFactory.CreateScope() to get scoped services inside a singleton.
  • Enable ValidateScopes and ValidateOnBuild to catch mistakes at startup.
  • The container disposes what it creates; scoped/transient at scope end, singleton at shutdown.

Frequently asked questions

Is Scoped the same as Singleton in a console app?

Not automatically. A console app has no request pipeline, so you must call CreateScope() yourself. Within one scope a scoped service behaves like a singleton; create a second scope and you get a second instance.

What happens if I inject a scoped service into a singleton?

The container resolves it once while building the singleton and the singleton captures that instance forever. With ValidateScopes enabled (the default in Development on .NET 9) this throws instead of silently leaking.

Why can't I make DbContext a singleton to save allocations?

DbContext holds a change tracker and a connection that are not thread-safe. A shared instance corrupts state under concurrent requests and grows unbounded. It is cheap to create per request, so Scoped is both correct and fast.

Are transient services ever a memory concern?

Yes. If a transient service implements IDisposable, its owning scope holds a reference until the scope ends. Creating many of them inside a long-lived scope keeps them all alive, so prefer short scopes for disposable transients.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.