Singleton, Scoped and Transient
Choose singleton, scoped or transient for each service by what it holds, who shares it, and whether it is safe across threads.
builder.Services.AddSingleton<IExchangeRates, CachedExchangeRates>();
builder.Services.AddScoped<ICurrentCustomer, HttpCurrentCustomer>();
builder.Services.AddTransient<InvoiceFormatter>();A lifetime answers one question: when the container is asked for this service again, does it hand back the same object? The answer depends on which scope is asking. ASP.NET Core creates one scope per HTTP request and exposes it as HttpContext.RequestServices; the root provider (app.Services) is the scope that lives as long as the app.
| Lifetime | One instance per | Typical services | Must be |
|---|---|---|---|
| Singleton | Root provider (whole app) | Caches, TimeProvider, configuration-derived clients, IHttpClientFactory | Thread safe; holds no per-user state |
| Scoped | Scope (one HTTP request) | DbContext, unit of work, current user/tenant context | Used by one request at a time |
| Transient | Every resolution | Light stateless helpers, typed HttpClient classes | Cheap to build |
Scoped and DbContext
AddDbContext registers the context as scoped by default. That is the point: one change tracker and one connection usage per request, and every repository in that request sees the same tracked entities, so one SaveChangesAsync commits them together. A DbContext is not thread safe; two concurrent queries on it throw A second operation was started on this context instance. Scoped is safe inside a request because a request is a single logical flow, as long as you do not fire unawaited tasks or Task.WhenAll over the same context.
Singletons and thread safety
A singleton is called by every request at once. A plain Dictionary mutated from two threads can corrupt itself and spin a CPU at 100% forever; use ConcurrentDictionary, Interlocked, immutable snapshots or a lock. A singleton must also never keep request data in fields: the second customer will see the first customer's cart.
Transient traps
Transient is not "safe by default". Each resolution allocates, so a transient injected into a hot path costs garbage. Worse, a transient that implements IDisposable is tracked by the scope that created it until that scope ends; resolved from the root provider, it is kept until shutdown, a slow memory leak.
Choosing
- Holds per-request state or a
DbContext: scoped. - Stateless or thread-safe shared state, expensive to build: singleton.
- Stateless and cheap, or must not be shared: transient (or scoped; for a stateless class the difference is only allocation count).
A longer-lived service may not depend on a shorter-lived one: a singleton holding a scoped service keeps the first request's instance forever. That is a captive dependency, covered in the next lesson, and Development-mode scope validation catches it.
Example
When to use it
- Keep one ShopDb per request so the order, its lines and the stock reservation commit in a single SaveChangesAsync
- Share an exchange-rate cache across all requests as a thread-safe singleton that refreshes each currency every 5 minutes
- Hold the authenticated customer and tenant id in a scoped context that repositories read without passing it everywhere
More examples
A singleton cache that is safe under load
Two requests can still fetch the same currency at the same moment, which is acceptable for a rate lookup; the dictionary itself stays consistent. What would not be acceptable is a plain <code>Dictionary</code> or a <code>List</code> appended from many threads, which fails rarely and unpredictably, usually only under production load.
using System.Collections.Concurrent;
public interface IExchangeRateSource
{
Task<decimal> FetchAsync(string currency, CancellationToken ct);
}
// Registered with AddSingleton: every request shares this instance concurrently.
public sealed class CachedExchangeRates(IExchangeRateSource source, TimeProvider clock)
{
private sealed record Entry(decimal Rate, DateTimeOffset FetchedAt);
// A Dictionary here can corrupt its buckets under concurrent writes and hang a thread.
private readonly ConcurrentDictionary<string, Entry> _rates = new(StringComparer.OrdinalIgnoreCase);
private static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(5);
public async Task<decimal> GetAsync(string currency, CancellationToken ct)
{
if (_rates.TryGetValue(currency, out var entry) && clock.GetUtcNow() - entry.FetchedAt < MaxAge)
{
return entry.Rate;
}
var rate = await source.FetchAsync(currency, ct);
_rates[currency] = new Entry(rate, clock.GetUtcNow());
return rate;
}
}DbContext lifetimes: scoped, pooled and factory
<code>AddDbContext</code> is scoped, which matches one request. <code>AddDbContextPool</code> reuses context instances to save allocation on very hot endpoints, but the context must hold no custom state because it is reset and handed to the next request. <code>AddDbContextFactory</code> registers a singleton <code>IDbContextFactory</code> for code that runs outside a request or needs several short-lived contexts in parallel.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var cs = builder.Configuration.GetConnectionString("Shop");
// Default: scoped. One context per request, disposed when the request ends.
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(cs));
// Alternatives (pick one per context type in a real app):
// builder.Services.AddDbContextPool<ShopDb>(o => o.UseNpgsql(cs), poolSize: 1024);
// builder.Services.AddDbContextFactory<ShopDb>(o => o.UseNpgsql(cs));
var app = builder.Build();
app.MapGet("/orders/{id:guid}", async (Guid id, ShopDb db, CancellationToken ct) =>
await db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct) is { } order
? Results.Ok(order)
: Results.NotFound());
app.Run();
public sealed class Order
{
public Guid Id { get; set; }
public string CustomerEmail { get; set; } = "";
public decimal Total { get; set; }
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}A test that pins the lifetimes
Lifetimes are easy to change by accident in a refactor, for example a scoped tenant context registered with <code>AddSingleton</code> in a new extension method. A small test that asserts sharing behaviour, built with <code>ValidateScopes = true</code> like Development, catches that before it leaks one tenant's data to another.
using Xunit;
public sealed class CartContext;
public sealed class PriceFormatter;
public class LifetimeTests
{
private static ServiceProvider Build()
{
var services = new ServiceCollection();
services.AddScoped<CartContext>();
services.AddTransient<PriceFormatter>();
return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
}
[Fact]
public void Scoped_is_shared_within_a_scope_but_not_across_scopes()
{
using var root = Build();
using var first = root.CreateScope();
using var second = root.CreateScope();
var a = first.ServiceProvider.GetRequiredService<CartContext>();
Assert.Same(a, first.ServiceProvider.GetRequiredService<CartContext>());
Assert.NotSame(a, second.ServiceProvider.GetRequiredService<CartContext>());
}
[Fact]
public void Transient_is_new_every_time()
{
using var root = Build();
using var scope = root.CreateScope();
Assert.NotSame(
scope.ServiceProvider.GetRequiredService<PriceFormatter>(),
scope.ServiceProvider.GetRequiredService<PriceFormatter>());
}
}
Discussion