Skip to content

Case 9: The Hidden Danger of Dependency Injection Scopes ​

The Scenario πŸ“ ​

  • System: An ASP.NET Core application.
  • Problem: A developer wants to create a background service or a "smart" cache (MyReportingService) to aggregate report data. Because this service needs to exist for the entire lifetime of the application, they register it with a Singleton lifetime. This service needs to query the database, so they inject AppDbContext into it.

The Problematic Code (Extremely Dangerous) ​

In Program.cs:

csharp
// The DbContext is registered with a Scoped lifetime (the default)
builder.Services.AddDbContext<AppDbContext>(...);

// The service is registered with a Singleton lifetime
builder.Services.AddSingleton<MyReportingService>();

In MyReportingService.cs:

csharp
public class MyReportingService // Registered as a Singleton
{
    private readonly AppDbContext _context; // The DbContext is Scoped!

    // The DbContext is injected ONLY ONCE when the application starts
    public MyReportingService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Report> GenerateReportAsync()
    {
        // Every request that calls this method will use the SAME DbContext instance
        var data = await _context.Orders.Where(...).ToListAsync();
        // ...
        return report;
    }
}

The Bottleneck and the Danger 🧐 ​

  1. Understanding Scopes:

    • Singleton: Only one instance is ever created and reused for the entire lifetime of the application.
    • Scoped: A new instance is created for each HTTP request.
    • Transient: A new instance is created every time it is requested.
  2. The Core Problem: "Captive Dependency"

    • AppDbContext is registered as Scoped by default. This is correct because DbContext is designed to be short-lived, existing only for a single request.
    • MyReportingService is a Singleton, so it lives forever.
    • When MyReportingService is created (at application startup), the dependency injection container injects a DbContext instance into it. This DbContext instance is now "captured" and will live alongside the singleton service until the application shuts down.
  3. The Consequences:

    • Memory Leak: The "captured" DbContext will track every entity it has ever loaded. Since it is never disposed of, the memory it consumes will grow and grow every time GenerateReportAsync is called.
    • Stale Data: The service will always see the data as it was when it was first loaded by that specific DbContext instance. It will never see changes made by other requests because it's using its own long-lived DbContext cache.
    • Concurrency Issues: This is the most dangerous problem. DbContext is not thread-safe. If two requests call GenerateReportAsync at the same time, they will both be using the same DbContext instance, leading to unpredictable exceptions, corrupted data, and potentially crashing the application.

The Solution: Use IServiceScopeFactory βœ… ​

  • The Logic: A long-lived service (Singleton) must not directly depend on a shorter-lived service (Scoped). Instead, it must inject IServiceScopeFactory so it can create its own new scope whenever it needs to do work.

  • The Optimized Code:

    csharp
    public class MyReportingService // Still a Singleton
    {
        private readonly IServiceScopeFactory _scopeFactory;
    
        public MyReportingService(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }
    
        public async Task<Report> GenerateReportAsync()
        {
            // 1. Create a new scope for this specific task
            using (var scope = _scopeFactory.CreateScope())
            {
                // 2. Get the DbContext from this new scope. It's a fresh instance.
                var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
                // 3. Use the context safely
                var data = await context.Orders.Where(...).ToListAsync();
                // ...
                return report;
            } // <-- The context is disposed of when the using block ends
        }
    }

The Results ✨ ​

  1. Correct Lifetime Management: Every time GenerateReportAsync is called, it creates and uses its own short-lived DbContext, just like a normal HTTP request would.
  2. Safety and Stability: The problems of memory leaks, stale data, and concurrency issues are completely solved.

Conclusion:

  • Golden Rule: Never inject a shorter-lived service (Scoped/Transient) into a longer-lived service (Singleton).
  • The correct pattern is for the Singleton service to inject IServiceScopeFactory and create its own scope for each unit of work.
  • Misunderstanding DI scopes is a major cause of serious and very-hard-to-diagnose bugs in ASP.NET Core applications.