Dependency Injection in ASP.NET Core is often taught as a simple mechanical pattern: register an interface, request it in a constructor, the framework wires it up. What's frequently glossed over is that choosing the wrong lifetime for a registered service can silently reproduce the exact same class of bug that shows up in completely unrelated contexts — including the classic ASP.NET Session multi-tab problem many developers eventually encounter. Understanding the connection between these two makes both concepts easier to reason about.
The Three Lifetimes, Briefly
ASP.NET Core's built-in DI container offers three lifetime options when registering a service:
csharp
// One instance for the entire application's lifetime
services.AddSingleton();
// One instance per HTTP request
services.AddScoped();
// A new instance every single time it's requested
services.AddTransient();
For a Controller like this:
csharp
public class WorkFlowController : ControllerBase
{
private readonly IclsWorkFlow _workFlowDataAccess;
public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
_workFlowDataAccess = workFlowDataAccess;
}
}
The Controller never writes new clsWorkFlow() itself — the DI container creates and supplies the instance automatically, based on how it was registered. But which lifetime was chosen for that registration has real consequences that only surface under concurrent load.
The Scenario: Two Users, One Instance
Imagine clsWorkFlow is registered as a Singleton. There is now exactly one instance of this class for the entire running application — shared by every user, on every request, simultaneously.
Suppose, for the sake of illustration, that this Data Access class holds some piece of state internally during a method call — even something as seemingly harmless as a private field tracking the employee ID currently being processed:
csharp
public class clsWorkFlow : IclsWorkFlow
{
private string _currentEmpId; // dangerous if this class is a Singleton
public List<CtrlSlnoName> GetAddWorkFlows(int employeeSlno, int createdBy)
{
_currentEmpId = employeeSlno.ToString();
// ... fetch data using _currentEmpId ...
}
}
If two requests arrive close together — User A's request and User B's request — and both are being served by the same shared Singleton instance, this sequence becomes possible:
User A's request begins, sets _currentEmpId = "1023"
Before User A's request finishes processing, User B's request arrives and overwrites _currentEmpId = "2045", using the same shared instance
User A's request completes, but by now _currentEmpId has been overwritten — User A's operation may complete using User B's data instead of their own
No exception is thrown. No error is logged. The application appears to work correctly under light, sequential testing, and the failure only appears under real concurrent traffic — exactly the conditions that are hardest to reproduce in a typical development or QA environment.
The Same Root Cause as a Familiar Bug
This is structurally identical to a well-known ASP.NET problem: Session state being overwritten when a user has multiple browser tabs open. In that scenario, two tabs share the same Session object because they share the same session cookie; whichever tab writes last "wins," and the other tab's data is silently corrupted at the moment of save.
The DI Singleton scenario is the same failure pattern at a different layer: instead of two browser tabs sharing one Session, it's two concurrent HTTP requests sharing one service instance. In both cases, the underlying mistake is the same — treating shared, mutable state as if it were private to a single operation, when in reality it's accessible to multiple operations happening at once.
Why Scoped Is the Safer Default for Data Access
Registering clsWorkFlow as Scoped instead of Singleton eliminates this entire class of problem for the common case:
csharp
services.AddScoped();
With Scoped lifetime, each HTTP request gets its own fresh instance of clsWorkFlow. User A's request and User B's request, even arriving at the exact same moment, are working with two completely separate objects. There's no shared internal field to overwrite, because there's no sharing happening at all between requests.
This is why Scoped is the conventional default for Data Access classes and anything tied to per-request context (like a database connection or a current-user identifier): it matches the natural boundary of "one request, one unit of work" without the overhead of creating a brand-new instance multiple times within a single request, which is what Transient would do unnecessarily.
Why This Isn't About Authorization
It's worth being precise about what this problem is, and what it isn't. Role-based access control — checking whether a user is authorized to see or modify certain data — is a completely separate concern from thread-safety and instance scoping. A system can have perfectly correct authorization logic and still suffer from this exact bug, because the two problems operate at different layers: authorization determines what a user is allowed to access; scoping determines whether concurrent operations interfere with each other at the object level. Fixing one does nothing to address the other.
A Practical Check
A useful habit when registering any service in DependencyInjection.cs: ask whether the class holds any state that changes during a method call, even temporarily. If it does, and it's registered as Singleton, that state is a shared resource across every concurrent user — a strong candidate for the same category of bug described above. Stateless services, or services whose state is fully contained within a single method call and never stored as a field, are safe as Singletons. Anything that stores per-request or per-user data as instance state should almost always be Scoped instead.
Takeaway
Dependency Injection lifetime selection isn't just a performance or memory optimization detail — choosing Singleton for a service that isn't genuinely safe to share across concurrent requests can silently reintroduce the same class of data-corruption bug found in classic Session-scoping mistakes, just at a different architectural layer. Recognizing the pattern — shared mutable state, accessed by more than one operation at once — makes it possible to spot this risk in code review before it ever reaches production, rather than debugging it after a support ticket reports data that doesn't make sense.
Top comments (0)