Transient vs Scoped vs Singleton in ASP.NET Core: DI Lifetimes Explained (.NET 10)

Use AddScoped for per-request state like a DbContext, AddTransient for small stateless helpers, and AddSingleton only for thread-safe objects the whole app can share. That’s my rule for transient vs scoped vs singleton in ASP.NET Core.

The only real difference is how long the container keeps an instance: per injection, per request, or for the life of the app.

On a .NET 10 minimal API, three requests kept the singleton at instance #1, gave each request its own scoped instance, and created a fresh transient on every injection.

Transient vs Scoped vs Singleton at a Glance

The instance numbers in this table come from the real run further down:

FeatureTransientScopedSingleton
Register withAddTransientAddScopedAddSingleton
New instanceEvery time it’s resolvedOnce per request (scope)Once per app
Asked for twice in one requestTwo instances (#3, #4)Same one (#1, #1)Same one
Next requestNew instancesNew instance (#2)Same one (#1)
DisposedEnd of the scope it came fromEnd of the requestApp shutdown
Needs thread-safe codeNo, unless a singleton holds itRarelyYes, always
Inject into a singleton?Works, but lives as long as the singletonNo: captive dependencyYes
Typical useSales-tax calculator, mappersDbContext, order pricingCounters, caches, config

None of this changed in the .NET 10 release. Keyed registrations, covered below, arrived in .NET 8.

The Sample: An Order-Pricing API with a Sales-Tax Calculator

Every test runs on one project, PricingApi (SDK 10.0.402). It prices a US order with state sales tax using a singleton QuoteCounter, a scoped OrderPricingService and a transient SalesTaxCalculator.

Each class inherits Tracked, which numbers new instances per type (#1, #2, #3), so you can spot a repeat instantly. Here’s Services.cs:

using System.Collections.Concurrent;

namespace PricingApi;

// Every new instance gets the next number for its type: #1, #2, #3 ...
public abstract class Tracked : IDisposable
{
    static readonly ConcurrentDictionary<Type, int> created = new();
    public int Id { get; }
    protected Tracked() => Id = created.AddOrUpdate(GetType(), 1, (_, n) => n + 1);
    public void Dispose() => Console.WriteLine($"disposed {GetType().Name} #{Id}");
}

// Singleton: one per app, so it must be thread-safe
public class QuoteCounter : Tracked
{
    int count;
    public int Next() => Interlocked.Increment(ref count);
}

// Transient: small, stateless, cheap to create
public class SalesTaxCalculator : Tracked
{
    public decimal Tax(decimal subtotal, decimal rate) =>
        Math.Round(subtotal * rate, 2, MidpointRounding.AwayFromZero);
}

// Scoped: one per HTTP request
public class OrderPricingService(QuoteCounter counter, SalesTaxCalculator calculator) : Tracked
{
    public PriceQuote Quote(decimal subtotal, IStateSalesTax state)
    {
        var tax = calculator.Tax(subtotal, state.Rate);
        return new(counter.Next(), state.Code, subtotal, tax, subtotal + tax);
    }
}

public record PriceQuote(int QuoteNumber, string State, decimal Subtotal, decimal Tax, decimal Total);

// Keyed services: one implementation per state
public interface IStateSalesTax
{
    string Code { get; }
    decimal Rate { get; }
}

public class TexasSalesTax : IStateSalesTax
{
    public string Code => "TX";
    public decimal Rate => 0.0625m;
}

public class CaliforniaSalesTax : IStateSalesTax
{
    public string Code => "CA";
    public decimal Rate => 0.0725m;
}

The dependencies arrive through a primary constructor in C#, and PriceQuote is a positional record (my record vs class vs struct comparison explains that choice). Program.cs registers everything and maps the endpoints:

using PricingApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<QuoteCounter>();
builder.Services.AddScoped<OrderPricingService>();
builder.Services.AddTransient<SalesTaxCalculator>();
builder.Services.AddKeyedScoped<IStateSalesTax, TexasSalesTax>("TX");
builder.Services.AddKeyedScoped<IStateSalesTax, CaliforniaSalesTax>("CA");

var app = builder.Build();

// Middleware resolves the same services from the request scope
app.Use(async (context, next) =>
{
    var sp = context.RequestServices;
    context.Items["mw"] = Ids(sp.GetRequiredService<QuoteCounter>(),
        sp.GetRequiredService<OrderPricingService>(), sp.GetRequiredService<SalesTaxCalculator>());
    await next(context);
});

// The endpoint asks for the scoped and transient services twice
app.MapGet("/lifetimes", (HttpContext context, QuoteCounter singleton,
    OrderPricingService scoped1, OrderPricingService scoped2,
    SalesTaxCalculator transient1, SalesTaxCalculator transient2) =>
    $"middleware  {context.Items["mw"]}\n" +
    $"endpoint    singleton #{singleton.Id}   scoped #{scoped1.Id} #{scoped2.Id}   " +
    $"transient #{transient1.Id} #{transient2.Id}");

app.MapGet("/quote/tx", (decimal subtotal, [FromKeyedServices("TX")] IStateSalesTax tax,
    OrderPricingService pricing) => pricing.Quote(subtotal, tax));

app.MapGet("/quote/{state}", (string state, decimal subtotal, HttpContext context,
    OrderPricingService pricing) =>
    context.RequestServices.GetKeyedService<IStateSalesTax>(state.ToUpperInvariant()) is { } tax
        ? Results.Ok(pricing.Quote(subtotal, tax))
        : Results.NotFound($"No sales tax service registered for {state}"));

app.Run();

static string Ids(QuoteCounter s, OrderPricingService sc, SalesTaxCalculator t) =>
    $"singleton #{s.Id}   scoped #{sc.Id}      transient #{t.Id}";

The project needs no NuGet packages, since the container ships with ASP.NET Core. The launch profile pins port 5738 and Development:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>
{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5738",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

AddScoped vs AddTransient: How Many Instances in One Request?

With the API running, this file-based app calls /lifetimes three times and prints what the middleware and the endpoint received:

var api = new HttpClient { BaseAddress = new Uri("http://localhost:5738/") };

for (int i = 1; i <= 3; i++)
{
    Console.WriteLine($"Request {i}");
    Console.WriteLine(await api.GetStringAsync("lifetimes"));
}

Output:

Request 1
middleware  singleton #1   scoped #1      transient #2
endpoint    singleton #1   scoped #1 #1   transient #3 #4
Request 2
middleware  singleton #1   scoped #2      transient #6
endpoint    singleton #1   scoped #2 #2   transient #7 #8
Request 3
middleware  singleton #1   scoped #3      transient #10
endpoint    singleton #1   scoped #3 #3   transient #11 #12
Transient vs scoped vs singleton in ASP.NET Core: CallApi.cs in VS Code calls the .NET 10 API three times; the singleton stays #1, the scoped id repeats inside each request and the transient id changes on every injection
Three real requests: watch which instance numbers repeat

In request 1, the endpoint asked for OrderPricingService twice and got #1 #1, the same object the middleware saw. It asked for SalesTaxCalculator twice and got #3 #4.

Transient #1 went into the OrderPricingService constructor, so each request built four calculators but one pricing service. That’s the addscoped vs addtransient difference: scoped shares within a request, transient never shares.

Singleton vs Scoped: What Changes Between Requests?

Same PricingApi run, read across the requests: the singleton stayed #1 all three times, while the scoped service went #1, #2, #3. ASP.NET Core creates a scope per request and disposes it after the response.

Middleware is where you can trip. Resolve scoped services in InvokeAsync or from RequestServices, as mine does, never in a middleware constructor, which runs once and turns them into singletons.

Anything about the current user, like the claims in my JWT authentication guide, belongs in scoped services for the same reason.

Transient vs Scoped vs Singleton in a Console App with CreateScope

A console app has no requests, so you create the scopes with CreateScope. This generic host (Microsoft.Extensions.Hosting 10.0.14) does what ASP.NET Core does per request:

#:package Microsoft.Extensions.Hosting@10.0.14
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<QuoteCounter>();
builder.Services.AddScoped<OrderPricing>();
builder.Services.AddTransient<TaxCalculator>();
using var host = builder.Build();

for (int i = 1; i <= 2; i++)
{
    using var scope = host.Services.CreateScope();
    Console.WriteLine($"Scope {i}");
    Show<QuoteCounter>(scope.ServiceProvider);
    Show<OrderPricing>(scope.ServiceProvider);
    Show<TaxCalculator>(scope.ServiceProvider);
}

// Resolve T twice from the same provider and print both ids
static void Show<T>(IServiceProvider sp) where T : Tracked
{
    var a = sp.GetRequiredService<T>();
    var b = sp.GetRequiredService<T>();
    Console.WriteLine($"  {typeof(T).Name}: #{a.Id} #{b.Id}");
}

abstract class Tracked
{
    static readonly Dictionary<Type, int> made = [];
    public int Id { get; }
    protected Tracked() =>
        Id = made[GetType()] = made.GetValueOrDefault(GetType()) + 1;
}

class QuoteCounter : Tracked;
class OrderPricing : Tracked;
class TaxCalculator : Tracked;

Output:

Scope 1
  QuoteCounter: #1 #1
  OrderPricing: #1 #1
  TaxCalculator: #1 #2
Scope 2
  QuoteCounter: #1 #1
  OrderPricing: #2 #2
  TaxCalculator: #3 #4
Add dependency injection to a console app: HostDemo.cs uses Host.CreateApplicationBuilder and CreateScope; the singleton is #1 in both scopes, the scoped id is #1 then #2 and each transient is new
The same three lifetimes in a .NET 10 console app with the generic host

Same pattern as the API: QuoteCounter is #1 in both scopes, OrderPricing repeats inside a scope (#1 #1, then #2 #2), and TaxCalculator is always new.

That’s all it takes to add dependency injection to a console app. Starting fresh? Set up a console app in VS Code first.

When Are Transient, Scoped and Singleton Services Disposed?

Every sample class implements IDisposable, so the server log shows the cleanup at the end of request 1:

disposed SalesTaxCalculator #4
disposed SalesTaxCalculator #3
disposed SalesTaxCalculator #2
disposed OrderPricingService #1
disposed SalesTaxCalculator #1

The four transients and the scoped service were disposed when the request’s scope ended, newest first. The singleton lives until shutdown. You never call Dispose on something the container created.

The risky case is a disposable transient resolved from the root provider instead of a scope. This test resolves one inside a scope and three from the root:

#:package Microsoft.Extensions.DependencyInjection@10.0.14
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection().AddTransient<TaxCalculator>();
using (var root = services.BuildServiceProvider())
{
    using (var scope = root.CreateScope())
    {
        scope.ServiceProvider.GetRequiredService<TaxCalculator>();
        Console.WriteLine("Leaving the scope");
    }
    for (int i = 0; i < 3; i++) root.GetRequiredService<TaxCalculator>();
    Console.WriteLine("3 transients resolved from the root provider");
}
Console.WriteLine("Root provider disposed");

class TaxCalculator : IDisposable
{
    static int made;
    public int Id { get; } = ++made;
    public void Dispose() => Console.WriteLine($"  disposed TaxCalculator #{Id}");
}

Output:

Leaving the scope
  disposed TaxCalculator #1
3 transients resolved from the root provider
  disposed TaxCalculator #4
  disposed TaxCalculator #3
  disposed TaxCalculator #2
Root provider disposed

Important: #1 was disposed when its scope closed, but #2 to #4 lived until the root provider was disposed. Do that in a loop and you have a memory leak. My notes on Dispose and Finalize in C# cover the pattern itself.

Captive Dependency: A Scoped Service Inside a Singleton

A captive dependency is a longer-lived service holding a shorter-lived one. Here a singleton PriceCache takes a scoped ShoppingCart, and the host is built twice:

#:package Microsoft.Extensions.Hosting@10.0.14
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

foreach (var env in new[] { "Production", "Development" })
{
    var settings = new HostApplicationBuilderSettings();
    settings.EnvironmentName = env;
    var builder = Host.CreateApplicationBuilder(settings);
    builder.Services.AddSingleton<PriceCache>();  // singleton
    builder.Services.AddScoped<ShoppingCart>();   // scoped
    try
    {
        using var host = builder.Build();
        int first = Cart(host), second = Cart(host);
        Console.WriteLine($"{env}: cart #{first} then #{second}");
    }
    catch (AggregateException ex)
    {
        var inner = ex.InnerException!;
        Console.WriteLine($"{env}: {inner.GetType().Name}");
        Console.WriteLine(inner.Message);
    }
}

// One "request": a new scope that asks for the singleton
static int Cart(IHost host)
{
    using var scope = host.Services.CreateScope();
    var sp = scope.ServiceProvider;
    return sp.GetRequiredService<PriceCache>().Cart.Id;
}

class ShoppingCart
{
    static int made;
    public int Id { get; } = ++made;
}

class PriceCache(ShoppingCart cart)
{
    public ShoppingCart Cart => cart;
}

Output:

Production: cart #1 then #1
Development: InvalidOperationException
Error while validating the service descriptor 'ServiceType: PriceCache Lifetime: Singleton ImplementationType: PriceCache': Cannot consume scoped service 'ShoppingCart' from singleton 'PriceCache'.
Captive dependency in .NET 10: Captive.cs keeps the same scoped cart #1 in Production, and in Development ValidateOnBuild throws InvalidOperationException: Cannot consume scoped service 'ShoppingCart' from singleton 'PriceCache'
Production hides the bug; Development throws at Build()

Production “works,” and that’s the bug: both requests got cart #1, so one customer’s cart leaks into the next request. Development threw an InvalidOperationException (inside an AggregateException) at Build().

That’s scope validation: ValidateScopes and ValidateOnBuild are on by default in Development. Turn them on everywhere with builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; }).

How Do You Use a Scoped Service from a Singleton or BackgroundService?

A BackgroundService is a singleton, so it can’t take a scoped service in its constructor. Inject IServiceScopeFactory and open a scope per unit of work:

#:package Microsoft.Extensions.Hosting@10.0.14
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddScoped<OrderPricing>();
builder.Services.AddHostedService<NightlyRepricer>();
await builder.Build().RunAsync();

class NightlyRepricer(IServiceScopeFactory scopes,
    IHostApplicationLifetime app) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        for (int batch = 1; batch <= 3; batch++)
        {
            await using var scope = scopes.CreateAsyncScope();
            var sp = scope.ServiceProvider;
            var pricing = sp.GetRequiredService<OrderPricing>();
            Console.WriteLine($"Batch {batch}: OrderPricing #{pricing.Id}");
        }
        app.StopApplication();
    }
}

class OrderPricing : Tracked;

abstract class Tracked : IDisposable
{
    static readonly Dictionary<Type, int> made = [];
    public int Id { get; }
    protected Tracked() => Id = made[GetType()] = made.GetValueOrDefault(GetType()) + 1;
    public void Dispose() => Console.WriteLine($"  disposed {GetType().Name} #{Id}");
}

Output:

Batch 1: OrderPricing #1
  disposed OrderPricing #1
Batch 2: OrderPricing #2
  disposed OrderPricing #2
Batch 3: OrderPricing #3
  disposed OrderPricing #3
BackgroundService with IServiceScopeFactory in .NET 10: Worker.cs opens CreateAsyncScope per batch, so each batch gets a new scoped OrderPricing #1, #2, #3 that is disposed when the batch ends
One scope per batch: a fresh scoped service each time, disposed right after

Each batch got its own OrderPricing, disposed before the next. CreateAsyncScope with await using also handles IAsyncDisposable services; see async and await in C# for that syntax.

Thread Safety: The Real Cost of a Singleton

A singleton serves every request at once, so its fields are shared. This test hits one QuoteCounter 100,000 times from Parallel.For:

#:package Microsoft.Extensions.DependencyInjection@10.0.14
using Microsoft.Extensions.DependencyInjection;

using var sp = new ServiceCollection().AddSingleton<QuoteCounter>().BuildServiceProvider();

Parallel.For(0, 100_000, _ => sp.GetRequiredService<QuoteCounter>().Add());

var counter = sp.GetRequiredService<QuoteCounter>();
Console.WriteLine($"Expected 100000 | count++ {counter.Unsafe} | Interlocked {counter.Safe}");

class QuoteCounter
{
    int unsafeCount, safeCount;
    public int Unsafe => unsafeCount;
    public int Safe => safeCount;
    public void Add()
    {
        unsafeCount++;                          // read, add, write: two threads can collide
        Interlocked.Increment(ref safeCount);   // atomic
    }
}

Output:

Expected 100000 | count++ 55885 | Interlocked 100000

Plain count++ lost 44,115 increments because threads overwrote each other; Interlocked.Increment hit exactly 100,000. The container creates services safely, but your singleton’s own state needs Interlocked, a lock or a concurrent collection.

DbContext and HttpClient Lifetimes in ASP.NET Core

AddDbContext registers the context as scoped by default, one change tracker per request. Never put it in a singleton; my ASP.NET Core Web API with EF Core gets it per request.

Typed clients from AddHttpClient<T> are transient, and Learn warns against capturing one in a singleton (it stops seeing DNS changes). In a singleton, inject IHttpClientFactory and call CreateClient. More in my HttpClient in C# guide.

Keyed Services with AddKeyedScoped and [FromKeyedServices]

Keyed services (.NET 8+) register several implementations of one interface in C# under keys. /quote/tx picks Texas with [FromKeyedServices("TX")]; /quote/{state} picks at run time with GetKeyedService. Same PricingApi project, $120 subtotal:

curl.exe -s "http://localhost:5738/quote/tx?subtotal=120"
{"quoteNumber":1,"state":"TX","subtotal":120,"tax":7.50,"total":127.50}

curl.exe -s "http://localhost:5738/quote/ca?subtotal=120"
{"quoteNumber":2,"state":"CA","subtotal":120,"tax":8.70,"total":128.70}

curl.exe -i "http://localhost:5738/quote/fl?subtotal=120"
HTTP/1.1 404 Not Found
"No sales tax service registered for fl"

Texas (6.25%) added $7.50, California (7.25%) added $8.70, and Florida got a 404 because no key matched. Quote numbers 1 and 2 came from the one singleton counter.

GetService vs GetRequiredService, and TryAdd vs Add

This test adds Texas and California with Add, tries New York with TryAdd, then resolves a service nobody registered:

#:package Microsoft.Extensions.DependencyInjection@10.0.14
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

var services = new ServiceCollection();
services.AddSingleton<IStateSalesTax, TexasSalesTax>();
services.AddSingleton<IStateSalesTax, CaliforniaSalesTax>();   // Add: appends a second registration
services.TryAddSingleton<IStateSalesTax, NewYorkSalesTax>();   // TryAdd: skipped, one already exists
using var sp = services.BuildServiceProvider();

Console.WriteLine($"Registrations: {services.Count}");
Console.WriteLine($"GetRequiredService<IStateSalesTax>: {sp.GetRequiredService<IStateSalesTax>().Code}");
Console.WriteLine($"GetServices<IStateSalesTax>: {string.Join(", ", sp.GetServices<IStateSalesTax>().Select(t => t.Code))}");
Console.WriteLine($"GetService<IShippingRate>: {sp.GetService<IShippingRate>()?.ToString() ?? "null"}");
try
{
    sp.GetRequiredService<IShippingRate>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"GetRequiredService<IShippingRate>: {ex.Message}");
}

interface IShippingRate { }
interface IStateSalesTax { string Code { get; } }
class TexasSalesTax : IStateSalesTax { public string Code => "TX"; }
class CaliforniaSalesTax : IStateSalesTax { public string Code => "CA"; }
class NewYorkSalesTax : IStateSalesTax { public string Code => "NY"; }

Output:

Registrations: 2
GetRequiredService<IStateSalesTax>: CA
GetServices<IStateSalesTax>: TX, CA
GetService<IShippingRate>: null
GetRequiredService<IShippingRate>: No service for type 'IShippingRate' has been registered.

GetService returned null; GetRequiredService threw. I use GetRequiredService so a missing registration fails loudly. With two Adds, a single resolve returns the last one (CA), and TryAddSingleton skipped New York because the type was already registered.

When Should You Use Transient, Scoped or Singleton?

Ask what state the class holds and who may see it:

SituationLifetimeWhy
Holds data for one request or user (cart, DbContext, current user)ScopedShared inside the request, isolated between requests
Stateless helper (calculator, mapper, validator)TransientCheap to build, nothing to share
Expensive to build or app-wide (cache, counter, config)SingletonBuilt once; must be thread-safe
Singleton or BackgroundService needs a scoped serviceInject IServiceScopeFactoryOne scope per unit of work
Several implementations picked by a keyAddKeyed* with any lifetimeLifetime and key are separate choices

If you’re torn on a stateless class, transient and scoped both work. Pick singleton only when there’s no per-request data and you’ve made it thread-safe.

More ASP.NET Core and C# guides from the site:

Frequently Asked Questions

What is the default lifetime in .NET dependency injection?

There isn’t one; you choose it with AddTransient, AddScoped or AddSingleton. Framework helpers pick for you: AddDbContext registers the context as scoped.

Is scoped the same as singleton in a console app?

Only if you never create a scope. Scoped services resolved from the root provider live until it’s disposed, so call CreateScope per unit of work.

Can a scoped service depend on a singleton?

Yes. A service may depend on anything that lives as long or longer. Only a longer-lived service holding a shorter-lived one is a problem.

How do I use dependency injection with constructor parameters that aren’t services?

Register a factory: builder.Services.AddScoped(sp => new TaxClient(apiKey, sp.GetRequiredService<ILogger<TaxClient>>())). For settings, the options pattern (IOptions<T>) is cleaner.

Why do my transient services never get disposed?

They were resolved from the root provider, so the container keeps them until the app stops. Resolve them inside a request or a CreateScope block. See Service lifetimes on Microsoft Learn.