Singleton, Scoped and Transient

The three service lifetimes, what a scope is during a web request, and the captive-dependency bug they cause.

Syntaxservices.AddTransient<TService, TImplementation>() / AddScoped<...>() / AddSingleton<...>()

When you register a service you also say how long each instance should live. That choice is called the service's lifetime. There are three.

  • AddTransient builds a new instance every single time one is asked for.
  • AddScoped builds one instance per scope and reuses it inside that scope.
  • AddSingleton builds one instance for the whole application.

What a scope is

A scope is a boundary with a start and an end. ASP.NET Core opens a scope when an HTTP request arrives and closes it when the response is finished. So "scoped" means "one per request" in a web app. Every class handled during that request that asks for a scoped service gets the same instance.

When the scope closes, the container disposes the scoped and transient instances it created in that scope that implement IDisposable. Singletons are disposed only when the application shuts down. Never call Dispose yourself on something the container gave you.

Choosing one

Scoped is the right default for anything holding per-request state, which is why AddDbContext registers an EF Core DbContext as scoped. Singleton suits something stateless or expensive to create, and a singleton must be safe to use from many threads at once. Transient suits small, cheap, stateless helpers.

The captive dependency

A singleton that takes a scoped service in its constructor keeps that one instance for ever. The scoped service is now effectively a singleton and will serve request 2 with request 1's state. This is called a captive dependency. In the Development environment ASP.NET Core checks for it at startup and throws, with a message like "Cannot consume scoped service 'Bar' from singleton 'Foo'."

Example

Example · csharp
// Package: Microsoft.Extensions.DependencyInjection
using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new ServiceCollection();
services.AddTransient<TransientThing>();
services.AddScoped<ScopedThing>();
services.AddSingleton<SingletonThing>();

using ServiceProvider provider = services.BuildServiceProvider();

for (int round = 1; round <= 2; round++)
{
    // ASP.NET Core opens one of these per HTTP request. Here we open ours.
    using IServiceScope scope = provider.CreateScope();
    IServiceProvider sp = scope.ServiceProvider;

    // Each line asks twice, so you can see which answers match.
    Console.WriteLine($"scope {round} transient: {Id<TransientThing>(sp)}, {Id<TransientThing>(sp)}");
    Console.WriteLine($"scope {round} scoped:    {Id<ScopedThing>(sp)}, {Id<ScopedThing>(sp)}");
    Console.WriteLine($"scope {round} singleton: {Id<SingletonThing>(sp)}, {Id<SingletonThing>(sp)}");
}

static int Id<T>(IServiceProvider sp) where T : Tracked => sp.GetRequiredService<T>().Id;

// Every instance of every subclass takes the next number, so equal numbers
// mean the same object came back.
abstract class Tracked
{
    private static int _created;

    protected Tracked() => Id = ++_created;

    public int Id { get; }
}

class TransientThing : Tracked { }
class ScopedThing : Tracked { }
class SingletonThing : Tracked { }

// Output:
// scope 1 transient: 1, 2
// scope 1 scoped:    3, 3
// scope 1 singleton: 4, 4
// scope 2 transient: 5, 6
// scope 2 scoped:    7, 7
// scope 2 singleton: 4, 4

When to use it

  • Decide the lifetime for a DbContext wrapper that must see one consistent view per request.
  • Keep an in-memory lookup table built once at startup instead of rebuilt per call.
  • Diagnose a service returning the previous user's data on a later request.

More examples

Scoped per request in a web app

A GET to /who returns JSON whose two fields hold the same value, because the endpoint and the Auditor were served from one request scope. Send the request again and a second RequestId is built. This is the shape you rely on when a scoped unit of work, such as an EF Core DbContext, must be shared by several classes handling one request.

Example · csharp
using Microsoft.Extensions.DependencyInjection;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// One RequestId per HTTP request. Both classes below see the same one.
builder.Services.AddScoped<RequestId>();
builder.Services.AddScoped<Auditor>();

WebApplication app = builder.Build();

app.MapGet("/who", (RequestId id, Auditor auditor) =>
    new { fromEndpoint = id.Value, fromAuditor = auditor.Describe() });

app.Run();

public class RequestId
{
    public string Value { get; } = "req-1";
}

public class Auditor(RequestId id)
{
    public string Describe() => id.Value;
}

The captive dependency, and the fix

Reach for this when a long-lived service seems to need per-request information. Usually the answer is not a different lifetime but a method parameter: the caller already has the request-scoped value, so hand it over at the call. The other fix, when the singleton genuinely needs to resolve scoped services of its own, is IServiceScopeFactory.

Example · csharp
using Microsoft.Extensions.DependencyInjection;

// Wrong. Cache lives for the app, CurrentUser lives for a request.
// In Development this throws at startup:
// InvalidOperationException: Cannot consume scoped service 'CurrentUser'
// from singleton 'Cache'.
public class Cache(CurrentUser user)
{
    public string KeyFor(string name) => $"{user.Id}:{name}";
}

// Right. Pass the short-lived value in as an argument instead of capturing it.
public class CacheFixed
{
    public string KeyFor(string userId, string name) => $"{userId}:{name}";
}

public class CurrentUser
{
    public string Id { get; set; } = "anonymous";
}

// builder.Services.AddSingleton<Cache>();     // captive dependency
// builder.Services.AddSingleton<CacheFixed>(); // safe
// builder.Services.AddScoped<CurrentUser>();

A singleton must be thread safe

The container guarantees that resolving services is thread safe; it does not make your instance thread safe. One singleton is shared by every request on every thread, so any mutable state inside it needs protection. If a class cannot be made safe, register it scoped instead and accept the per-request cost.

Example · csharp
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;

// Broken as a singleton: two requests can increment at the same time and
// lose a count, because int++ is not one atomic step.
public class SloppyCounter
{
    private int _hits;

    public int Hit() => ++_hits;
}

// Safe as a singleton: a type built for concurrent use.
public class RouteCounter
{
    private readonly ConcurrentDictionary<string, int> _hits = new();

    public int Hit(string route) =>
        _hits.AddOrUpdate(route, 1, (_, current) => current + 1);
}

// builder.Services.AddSingleton<RouteCounter>();

Discussion

  • Be the first to comment on this lesson.