Dependency Injection in ASP.NET Core tends to get taught as a purely mechanical concept: register a type, request it through a constructor, let the framework handle the wiring. What often gets underemphasized is that picking the wrong lifetime for a service registration can quietly reintroduce a familiar category of bug — the same one responsible for classic ASP.NET Session corruption issues, just showing up at a different layer of the application.
Understanding the Three DI Lifetimes
ASP.NET Core's dependency injection container supports three registration lifetimes:
// A single shared instance for the application's entire lifetime
services.AddSingleton<IclsWorkFlow, clsWorkFlow>();
// A new instance created once per HTTP request
services.AddScoped<IclsWorkFlow, clsWorkFlow>();
// A brand-new instance every time it's requested, even within one request
services.AddTransient<IclsWorkFlow, clsWorkFlow>();
A Controller consuming this service never instantiates it directly:
public class WorkFlowController : ControllerBase
{
private readonly IclsWorkFlow _workFlowDataAccess;
public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
_workFlowDataAccess = workFlowDataAccess;
}
}
The framework supplies the instance automatically. The lifetime chosen during registration determines how that instance is created and shared.
How Shared Mutable State Creates Problems
The key issue is not that a particular lifetime is inherently unsafe. The important question is whether the service's lifetime is compatible with its state, dependencies, and concurrency requirements.
A service that stores mutable state in instance fields can become problematic when the same instance is accessed by multiple concurrent operations.
Example: Singleton With Per-Request State
Consider clsWorkFlow registered as a Singleton. There is now exactly one instance of this class, shared across every request the application handles for as long as the application runs.
Suppose that class, even briefly, stores some per-operation state as an instance field:
public class clsWorkFlow : IclsWorkFlow
{
private string _currentEmpId; // risky if this class is a Singleton
public List<CtrlSlnoName> GetAddWorkFlows(int employeeSlno, int createdBy)
{
_currentEmpId = employeeSlno.ToString();
// fetch data using _currentEmpId
}
}
With two requests arriving close together — one from User A, one from User B — both served by the identical shared instance, this sequence becomes entirely possible:
User A's request sets
_currentEmpId = "1023"Before User A's operation finishes, User B's request overwrites the same field with
"2045", using that same shared instanceUser A's request completes, but now processes User B's employee ID instead of its own
Nothing crashes. No exception surfaces. The application passes normal sequential testing without issue, and the defect only appears under genuine concurrent load — precisely the condition most difficult to catch before production.
The underlying problem is shared mutable state being accessed concurrently.
Why This Resembles Session-Scoping Bugs
This can be compared to a classic ASP.NET Session problem where state associated with multiple operations can be unintentionally shared or overwritten.
The two scenarios are not literally the same mechanism. ASP.NET Session has its own session-state behavior and locking semantics, while ASP.NET Core dependency injection manages service instance lifetimes separately.
However, the two scenarios share the same underlying concurrency pattern: mutable state that is unintentionally shared between operations.
A Singleton service holding per-request state creates this problem at the service-instance level. Instead of separate operations working with isolated state, concurrent requests can access and modify the same mutable object.
Why Scoped Often Fits Request-Oriented Services
Registering clsWorkFlow as Scoped provides a separate instance within each HTTP request:
services.AddScoped<IclsWorkFlow, clsWorkFlow>();
Under Scoped lifetime, User A and User B, even arriving simultaneously, receive separate clsWorkFlow instances within their respective requests. A mutable instance field therefore is not shared between those requests.
Scoped is commonly used for request-oriented services and database contexts such as DbContext, where one instance is typically intended to be shared within a request.
However, Scoped is not automatically the correct lifetime for every data-access or application service. The appropriate lifetime depends on the service's actual state, dependencies, and design.
Singleton Does Not Mean Unsafe
A Singleton service is not inherently unsafe.
The important rule is:
A Singleton must be safe for concurrent use.
A stateless service can safely be Singleton when its dependencies are also compatible with Singleton usage.
For example:
public class EmployeeService : IEmployeeService
{
public Employee GetEmployee(int id)
{
// Local variable, not shared instance state
var employee = LoadEmployee(id);
return employee;
}
}
The absence of mutable instance state makes concurrent access easier to reason about.
A Singleton becomes problematic when it contains mutable state that is not designed for concurrent access or depends on services whose lifetimes are incompatible with Singleton usage.
Transient Does Not Guarantee Thread Safety
Transient creates a new instance whenever the container resolves the service, but that does not automatically make the service thread-safe or isolate all of its state.
For example:
Transient Service
|
v
Singleton Dependency
|
v
Shared State
The transient service could still interact with shared mutable state through one of its dependencies.
Therefore, changing a service from Singleton to Transient does not automatically solve concurrency problems. The complete dependency graph and the location of mutable state still need to be considered.
Captive Dependencies
Another important DI lifetime issue is the captive dependency problem.
Consider the following registrations:
services.AddSingleton<IReportService, ReportService>();
services.AddScoped<IReportRepository, ReportRepository>();
If ReportService directly depends on IReportRepository, the Singleton is attempting to hold a Scoped dependency for longer than the Scoped lifetime allows.
The basic rule is:
Singleton
↓
Should not depend on
↓
Scoped service
ASP.NET Core's scope validation can detect many such registrations during development.
This is important because DI lifetime problems are not limited to mutable fields. A service can also have an inappropriate lifetime because of the lifetimes of the services it depends on.
DI Lifetime and Authorization Are Different Concerns
It's worth being precise about the boundaries of this problem.
Role-based authorization — determining whether a given user should be permitted to see or modify particular data — operates independently of thread-safety and instance scoping.
A system can enforce authorization rules and still be exposed to a shared-state bug, because the two concerns sit at different layers:
Authorization governs what a user may access.
Scoping governs how service instances are created and shared.
Thread safety governs whether shared state can be accessed safely by concurrent operations.
Correct authorization logic does not provide protection against concurrency issues caused by inappropriate service state or lifetime configuration.
Practical Code Review Checklist
When reviewing dependency injection registrations, check more than the lifetime declaration itself.
Ask the following questions:
Does the service store mutable state in instance fields?
Is that state specific to a request, user, or operation?
Can multiple requests access the same instance concurrently?
Are all dependencies compatible with the service's lifetime?
Could a Singleton depend on a Scoped service?
Does a Transient service interact with shared Singleton state?
Is the service genuinely stateless?
Are local variables being used instead of shared instance fields where appropriate?
Can the service and its dependencies safely support concurrent access?
The goal is not to choose Scoped for everything. The goal is to make the lifetime match the service's state, dependency behavior, and intended usage.
Summary
ASP.NET Core dependency injection lifetimes determine how service instances are created and shared. The important question is not simply whether a service is Singleton, Scoped, or Transient, but whether that lifetime matches the service's state, dependencies, and concurrency requirements.
A Singleton that stores mutable per-request or per-user state can allow concurrent requests to interfere with one another. Scoped services provide a separate instance within each request and are therefore commonly used for request-oriented services. Transient services provide new instances when resolved, but they do not automatically make shared dependencies or application state thread-safe.
When reviewing a DI registration, look beyond the registration itself. Check instance fields, mutable state, dependencies, and whether the service can safely be used concurrently. This makes it easier to identify shared-state and lifetime problems before they become difficult-to-reproduce production bugs.
Join the conversation! Your thoughts help the community grow.