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:
| Feature | Transient | Scoped | Singleton |
|---|---|---|---|
| Register with | AddTransient | AddScoped | AddSingleton |
| New instance | Every time it’s resolved | Once per request (scope) | Once per app |
| Asked for twice in one request | Two instances (#3, #4) | Same one (#1, #1) | Same one |
| Next request | New instances | New instance (#2) | Same one (#1) |
| Disposed | End of the scope it came from | End of the request | App shutdown |
| Needs thread-safe code | No, unless a singleton holds it | Rarely | Yes, always |
| Inject into a singleton? | Works, but lives as long as the singleton | No: captive dependency | Yes |
| Typical use | Sales-tax calculator, mappers | DbContext, order pricing | Counters, 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

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

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'.

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

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:
| Situation | Lifetime | Why |
|---|---|---|
| Holds data for one request or user (cart, DbContext, current user) | Scoped | Shared inside the request, isolated between requests |
| Stateless helper (calculator, mapper, validator) | Transient | Cheap to build, nothing to share |
| Expensive to build or app-wide (cache, counter, config) | Singleton | Built once; must be thread-safe |
| Singleton or BackgroundService needs a scoped service | Inject IServiceScopeFactory | One scope per unit of work |
| Several implementations picked by a key | AddKeyed* with any lifetime | Lifetime 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:
- All ASP.NET Core tutorials on AspDotnetHelp
- Upgrade a project from .NET 8 to .NET 10
- .NET Core interview questions and answers
- Learn C# from the ground up
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.
Bijay Kumar is a renowned software engineer, accomplished author, and distinguished Microsoft Most Valuable Professional (MVP) specializing in SharePoint. With a rich professional background spanning over 15 years, Bijay has established himself as an authority in the field of information technology. He possesses unparalleled expertise in multiple programming languages and technologies such as ASP.NET, ASP.NET MVC, C#.NET, and SharePoint, which has enabled him to develop innovative and cutting-edge solutions for clients across the globe. Read more…