One of the most common ASP.NET Core dependency injection questions sounds simple:
Should this service be registered as
Singleton,Scoped, orTransient?
The problem is the generic answers are usually too generic to be useful.
You will often hear shortcuts like these:
- singleton for stateless services
- scoped for request-based work
- transient for lightweight services
Those are not wrong, but they are incomplete.
The right lifetime depends on whether the service is thread-safe, whether it holds mutable state, whether it caches data, whether it depends on DbContext, whether it needs request-specific information, and whether keeping it alive longer than necessary increases memory retention or stale state bugs.
In other words, this is not just a DI container setting. It is a design decision.
If you are newer to dependency injection, here is the simplest starting point:
- use scoped for most request-oriented application services
- use singleton only when the service is safe to share across the whole app
- use transient for small, stateless helpers when reuse does not matter
That rule of thumb will get you surprisingly far. Then the rest of this article explains where the edge cases and tradeoffs show up.
What the Three Lifetimes Actually Mean
Before choosing, it helps to define the terms clearly.
Singleton
A singleton is created once for the life of the application and reused everywhere.
builder.Services.AddSingleton<IMyService, MyService>();
That means:
- every request gets the same instance
- every background task gets the same instance
- every thread may touch the same object concurrently
A singleton is not just long-lived. It is shared.
Scoped
A scoped service is created once per scope. In ASP.NET Core, that usually means once per HTTP request.
builder.Services.AddScoped<IMyService, MyService>();
That means:
- all code within the same request can share the same instance
- a new request gets a different instance
- it works naturally with request-specific state and
DbContext
Transient
A transient service is created every time it is requested.
builder.Services.AddTransient<IMyService, MyService>();
That means:
- no instance reuse is assumed
- each consumer typically gets a fresh object
- object creation cost may matter if the service is used heavily
The mistake is thinking lifetime choice is mainly about performance. Most of the time, it is really about correctness and behavior.
Start With the Real Question: Does the Object Hold State?
The first question I ask is not, “How often will this be resolved?”
It is this:
Does this object hold state, and if so, what kind of state?
That one question eliminates a lot of bad registrations.
There are several kinds of state to think about:
- request state — current user, tenant, correlation ID, culture, request-specific decisions
- cached state — previously computed results or lookups kept for reuse
- mutable in-memory state — counters, lists, flags, last result, accumulated data
- infrastructure state — open connections, tracked EF entities, unmanaged resources
- configuration-like state — effectively read-only values loaded once and reused
If a service holds state, that state has a natural boundary. The lifetime should usually match that boundary.
When Scoped Is the Right Choice
In real ASP.NET Core applications, Scoped is often the safest default for application services.
That is especially true when a service:
- uses
DbContext - coordinates work within a single request
- depends on user, tenant, or request context
- should share the same unit of work across multiple collaborators
For example:
public sealed class OrderService : IOrderService
{
private readonly AppDbContext _db;
private readonly ICurrentUserAccessor _currentUser;
public OrderService(AppDbContext db, ICurrentUserAccessor currentUser)
{
_db = db;
_currentUser = currentUser;
}
public async Task<Order> CreateAsync(CreateOrderRequest request)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
CreatedByUserId = _currentUser.UserId,
CreatedUtc = DateTime.UtcNow
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
}
This service is a strong Scoped candidate because:
DbContextis scoped- the current user is request-specific
- the operation belongs to one request boundary
Trying to make this singleton would be incorrect. Trying to make it transient may work, but it does not add value if the dependencies are already scoped and the service participates in request-level behavior.
DbContext Is a Strong Signal
If your service directly depends on EF Core DbContext, that is usually a clear sign the service should be Scoped too.
Why?
Because DbContext is not thread-safe, it tracks entities, and it is designed around a unit-of-work pattern that maps naturally to a request scope.
A singleton depending on a scoped DbContext is invalid and will fail.
A transient service depending on DbContext is possible, but the broader question is whether that service is really request-oriented. If it is, Scoped is usually the cleaner choice.
When Singleton Is the Right Choice
A singleton is a good fit when the object is safe to share across the whole application.
That usually means:
- it is stateless, or its state is effectively immutable
- it is thread-safe
- it does not depend on scoped services
- it benefits from reuse, caching, or expensive initialization
Examples include:
- pure business rule engines with no mutable state
- serializers or formatters that are thread-safe
- metadata caches
- compiled expression or reflection helpers
- reusable clients or factories designed for application-wide reuse
For example:
public sealed class CurrencyMetadataProvider : ICurrencyMetadataProvider
{
private readonly IReadOnlyDictionary<string, int> _currencyDecimalPlaces;
public CurrencyMetadataProvider()
{
_currencyDecimalPlaces = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["USD"] = 2,
["EUR"] = 2,
["JPY"] = 0
};
}
public int GetDecimalPlaces(string currencyCode) =>
_currencyDecimalPlaces.TryGetValue(currencyCode, out var digits)
? digits
: 2;
}
This is a good singleton because:
- the data is loaded once
- the state is read-only after construction
- there is no request context
- concurrent access is safe
Thread Safety Is the Non-Negotiable Test
If a singleton can be touched by multiple requests at the same time, then it must be thread-safe.
That means more than “it seems fine in local testing.”
It means you have deliberately checked whether the service:
- mutates shared fields
- accumulates data in collections
- caches results without synchronization
- stores a “current” value that can be overwritten
- depends on non-thread-safe collaborators
This is dangerous singleton code:
public sealed class BadPriceCache
{
private readonly Dictionary<string, decimal> _prices = new();
public decimal GetOrAdd(string sku, Func<decimal> factory)
{
if (_prices.TryGetValue(sku, out var value))
{
return value;
}
value = factory();
_prices[sku] = value;
return value;
}
}
This may look fine, but it is not safe under concurrent access.
If you want singleton caching, use thread-safe patterns and be explicit about eviction and retention.
public sealed class ProductMetadataCache : IProductMetadataCache
{
private readonly IMemoryCache _cache;
public ProductMetadataCache(IMemoryCache cache)
{
_cache = cache;
}
public Task<ProductMetadata> GetAsync(string sku, Func<Task<ProductMetadata>> factory)
{
return _cache.GetOrCreateAsync(sku, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await factory();
})!;
}
}
That still does not make every cache a singleton automatically, but it is a much safer pattern than rolling shared mutable state by hand.
When Transient Is the Right Choice
Transient is appropriate when you want a fresh instance each time and the object does not need to retain state across operations.
This often fits:
- lightweight mappers
- formatters
- small helper services
- stateless domain services that do not benefit from reuse
- short-lived strategy objects
For example:
public sealed class SlugGenerator : ISlugGenerator
{
public string Generate(string value)
{
return value
.Trim()
.ToLowerInvariant()
.Replace(" ", "-");
}
}
This could be transient, and that would be perfectly reasonable.
Could it also be singleton? Probably yes, since it is stateless.
That is an important point: some services are correct with more than one lifetime.
When that happens, choose the simplest lifetime that matches the design intent. If the service is pure and stateless, singleton may reduce object churn. If you want to keep it obviously disposable and isolated, transient may still be fine.
The key is that either choice should still be correct.
Why Generic Advice Fails
You will sometimes hear rules like:
- “stateless services should always be singleton”
- “transient is safest”
- “scoped is only for
DbContext”
Those rules break down quickly.
A “stateless” service may still depend on request context indirectly.
A transient service may hide expensive initialization and create unnecessary churn.
A scoped service may accidentally hold large cached objects for the whole request graph.
A singleton may look stateless but use a mutable List<T> or Dictionary<TKey, TValue> internally.
The lifetime decision is not just about the class in isolation. It is about the entire object graph and the behavioral boundary the service lives inside.
A Practical Decision Framework
Here is the checklist I use.
Choose Singleton when all of these are true
- the service is safe to share across all requests
- it is thread-safe
- it does not depend on scoped services
- its state is immutable or carefully synchronized
- long-lived reuse is beneficial
Choose Scoped when any of these are true
- the service uses
DbContext - the service works with request or tenant context
- the service participates in a unit of work
- the service should be consistent across one request
- the service has state that should not escape the request boundary
Choose Transient when all of these are mostly true
- the service is lightweight to construct
- the service should not retain state between uses
- the service does not need to be shared across a request
- a fresh instance per resolution is fine
If you are unsure between Scoped and Transient, ask:
Should different parts of the same request share the same instance and behavior?
If yes, Scoped is often the better answer.
If you are unsure between Singleton and Transient, ask:
Is there any reason this object should not be shared safely across the entire app?
If there is any doubt about thread safety or hidden state, do not force singleton just because it sounds more efficient.
Watch for Memory Retention, Not Just Allocations
Developers often worry about allocations and object creation, but lifetime mistakes just as often show up as memory retention problems.
A singleton can quietly hold onto data far longer than intended.
For example:
- cached per-user data that never expires
- large lookup structures that grow forever
- references to request-derived objects that should have been short-lived
- event subscriptions that keep objects alive
That is the other side of the singleton story. Yes, reuse can reduce allocation churn. But long-lived objects also increase the chance of stale state, retained memory, and cross-request contamination.
Sometimes Scoped or Transient is the right answer precisely because you want the runtime to let go of the object sooner.
The Lifetime of Dependencies Matters Too
A service lifetime cannot be chosen in isolation from its dependencies.
The most important rule is this:
- a singleton should not depend on a scoped service
That is the classic lifetime mismatch, and the built-in container validates against it in normal development setups.
Beyond that, things get more nuanced than simple one-line rules.
For example:
- a scoped service can depend on scoped or singleton services
- a transient service can depend on singleton services
- a transient service can also depend on scoped services when it is resolved inside an active scope
That last point is where newer developers often get confused. A transient is not “above” or “below” scoped in some strict hierarchy. It just means a new instance is created each time it is requested. If that request happens inside an HTTP request scope, the transient can use scoped dependencies from that scope.
The real danger is capturing a shorter-lived dependency inside a longer-lived object.
The most important anti-pattern here is a singleton depending on a scoped service.
builder.Services.AddSingleton<ReportService>();
builder.Services.AddScoped<AppDbContext>();
public sealed class ReportService
{
private readonly AppDbContext _db;
public ReportService(AppDbContext db)
{
_db = db;
}
}
That is invalid because the singleton would try to capture a shorter-lived dependency.
There is another subtle problem worth knowing too: resolving a scoped service from the root provider can effectively keep it alive much longer than intended. So the issue is not only what depends on what, but also where the resolution happens.
Even when the container allows something indirectly, the design may still be wrong. Lifetime mismatches are often symptoms of architecture boundaries that are not clear enough.
Background Services Need Extra Care
Hosted services and background workers complicate this a little.
A hosted service is often registered as singleton because it lives for the application lifetime, but it may need scoped dependencies while executing work.
That does not mean everything inside it should become singleton too.
The correct pattern is usually to create a scope inside the background operation.
public sealed class OrderCleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderCleanupWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var cleanup = scope.ServiceProvider.GetRequiredService<IOrderCleanupService>();
await cleanup.RunAsync(stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
In that model:
- the worker itself is long-lived
- the actual business service can remain scoped
DbContextand request-like execution state stay in the right boundary
A Short Reality-Based Summary
Here is the practical version.
- Use singleton for application-wide, thread-safe, reusable services with immutable or carefully managed state.
- Use scoped for request-oriented services, especially anything involving
DbContext, current user context, or a unit of work. - Use transient for lightweight, stateless, short-lived services when you do not need reuse within a request.
But the more important rule is this:
Do not choose a lifetime based on a slogan. Choose it based on the state boundary, dependency graph, and concurrency behavior of the actual service.
That is why generic DI advice is so often incomplete. The correct lifetime is not just about what the service does. It is also about how long its state should live, who can safely share it, and what other objects it brings with it.
If you get that part right, the registration usually becomes obvious.