TL;DR

Most DI scope issues in ASP.NET Core come from misusing lifetimes.

  • ✘ Don’t register DbContext as Singleton.
  • ✘ Don’t inject scoped services into Singleton middleware.
  • ✘ Avoid Transient services that hold heavy resources.
  • ✔ Use Scoped for per-request services, Singleton for stateless logic, and Transient only for lightweight objects.
  • ✔ Enable ValidateScopes in development to catch leaks early.

Why DI Scopes Matter More Than You Think

Most .NET developers treat dependency injection scopes like a checkbox: pick Singleton, Scoped, or Transient and move on. That confidence lasts right up until production crashes with ObjectDisposedException errors at 3 AM, or your memory usage graph looks like a hockey stick.

I learned this lesson the hard way when our authentication middleware started throwing random database errors after deploying to Azure. The culprit? A singleton service holding onto a scoped DbContext. What seemed like basic DI knowledge turned into a two-day debugging session and a hotfix deployment.

Let’s walk through the five most common DI scope mistakes I see in code reviews, along with battle-tested fixes you can apply immediately.

Mistake #1: Registering DbContext as Singleton

The Problem

Here’s code I’ve seen in production more times than I’d like to admit:

// DON'T DO THIS
services.AddSingleton<AppDbContext>();

Developers often think caching the DbContext improves performance. After all, why create a new instance for every request? The reality is brutal: Entity Framework Core’s DbContext isn’t thread-safe. When multiple requests hit your singleton context simultaneously, you’ll get everything from incorrect query results to full application crashes.

What Actually Happens

Your DbContext holds onto tracked entities, database connections, and transaction state. As a singleton, it accumulates tracked entities forever, causing massive memory leaks. Plus, concurrent operations corrupt the change tracker, leading to data inconsistencies.

The Fix

Always register DbContext as scoped:

// CORRECT
services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString), 
    ServiceLifetime.Scoped); // Scoped is actually the default

Production Note: If you need connection pooling for performance, use AddDbContextPool instead of making your context singleton. This gives you performance benefits without the threading nightmares.

Mistake #2: Mixing Scoped Services in Singleton Dependencies

The Trap

Picture this common scenario:

// Singleton service
public class CacheService
{
    private readonly IUserRepository _userRepo; // Scoped!
    
    public CacheService(IUserRepository userRepo)
    {
        _userRepo = userRepo;
    }
}

// Registration
services.AddSingleton<CacheService>();
services.AddScoped<IUserRepository, UserRepository>();

Your application starts fine. Then, the first request hits and boom: InvalidOperationException: Cannot consume scoped service 'IUserRepository' from singleton 'CacheService'.

Why This Breaks

ASP.NET Core’s DI container prevents scope bleeding by design. A singleton lives for the entire application lifetime, while scoped services live only for a single request. If a singleton could hold a scoped service, that scoped service would effectively become a singleton too, breaking its intended lifecycle.

The Solution

You have three options, ordered by preference:

// Option 1: Make both services scoped
services.AddScoped<CacheService>();
services.AddScoped<IUserRepository, UserRepository>();

// Option 2: Inject IServiceProvider (use sparingly)
public class CacheService
{
    private readonly IServiceProvider _serviceProvider;
    
    public async Task<User> GetUserAsync(int id)
    {
        using var scope = _serviceProvider.CreateScope();
        var repo = scope.ServiceProvider.GetRequiredService<IUserRepository>();
        return await repo.GetByIdAsync(id);
    }
}

// Option 3: Redesign to avoid the dependency entirely
// (Often the best long-term solution)

Mistake #3: Overusing Transient for Heavy Objects

The Misconception

“When in doubt, use Transient. It’s the safest option.” I’ve heard this advice repeated countless times. It’s also terrible guidance when applied blindly.

// BAD: Creating new HttpClient for every injection
services.AddTransient<HttpClient>();

// BAD: Compiling regex patterns repeatedly
services.AddTransient<IRegexValidator>(sp => 
    new RegexValidator(@"^[a-zA-Z0-9]+$"));

The Performance Hit

Transient services create a new instance every time they’re requested. For lightweight objects, this is fine. For objects with expensive initialization (HTTP clients, compiled regex patterns, database connection pools), you’re burning CPU cycles and memory for no benefit.

I once reviewed an API that was creating 50+ HttpClient instances per request due to transient registration. The fix alone reduced response times by 40% and eliminated socket exhaustion errors.

Smart Lifetime Choices

// GOOD: Stateless services as singleton
services.AddSingleton<IPasswordHasher, PasswordHasher>();

// GOOD: HttpClient via HttpClientFactory
services.AddHttpClient<GitHubService>();

// GOOD: Compiled regex as singleton
services.AddSingleton<IRegexValidator>(sp => 
    new RegexValidator(CompiledRegexOptions));

Mistake #4: Forgetting Scopes in Background Services

The Silent Killer

Background services run outside the request pipeline, meaning there’s no automatic scope creation. This code looks reasonable but fails at runtime:

public class NotificationService : BackgroundService
{
    private readonly AppDbContext _dbContext; // WRONG!
    
    public NotificationService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var pending = await _dbContext.Notifications
                .Where(n => !n.Sent)
                .ToListAsync();
            // Process notifications...
        }
    }
}

What Goes Wrong

Your hosted service is singleton by default. Injecting scoped services directly causes DI validation errors. Even if you bypass validation, you’d have a single DbContext instance running for hours or days, accumulating tracked entities and eventually crashing.

The Correct Pattern

Always create explicit scopes in background services:

public class NotificationService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<NotificationService> _logger;
    
    public NotificationService(
        IServiceScopeFactory scopeFactory,
        ILogger<NotificationService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using (var scope = _scopeFactory.CreateScope())
            {
                var dbContext = scope.ServiceProvider
                    .GetRequiredService<AppDbContext>();
                
                var pending = await dbContext.Notifications
                    .Where(n => !n.Sent)
                    .ToListAsync(stoppingToken);
                    
                // Process in same scope
                await ProcessNotifications(pending, scope.ServiceProvider);
            } // DbContext disposed here
            
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

Real-World Impact: I’ve seen background services consume gigabytes of memory because they held onto a single DbContext for weeks. The fix was adding four lines of scope management code.

Mistake #5: Misunderstanding Middleware Scopes

The Middleware Lifetime Trap

Middleware classes are instantiated once and reused for all requests. Injecting scoped services through the constructor creates a captured dependency problem:

// WRONG: Constructor injection in middleware
public class TenantMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ITenantService _tenantService; // Scoped service!
    
    public TenantMiddleware(
        RequestDelegate next, 
        ITenantService tenantService)
    {
        _next = next;
        _tenantService = tenantService; // This captures the service
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        // Using captured scoped service across requests
        var tenant = await _tenantService.ResolveTenantAsync(context);
        context.Items["Tenant"] = tenant;
        await _next(context);
    }
}

Why Middleware Is Different

Middleware instances are effectively singleton. When you inject scoped services via constructor, you’re holding onto the first request’s scope forever. This causes data to bleed between requests, creating security vulnerabilities and data corruption.

The Correct Approach

Inject scoped services through the InvokeAsync method:

// CORRECT: Method injection for scoped services
public class TenantMiddleware
{
    private readonly RequestDelegate _next;
    
    public TenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    
    public async Task InvokeAsync(
        HttpContext context,
        ITenantService tenantService) // Injected per request
    {
        var tenant = await tenantService.ResolveTenantAsync(context);
        context.Items["Tenant"] = tenant;
        await _next(context);
    }
}

ASP.NET Core’s middleware pipeline resolves method parameters from the current request’s scope, ensuring proper lifetime management.

Best Practices Checklist

After debugging countless DI issues, here’s my mental checklist for choosing service lifetimes:

LifetimeWhen to UseExamples
Singleton- Service is completely stateless
- Thread-safe without locks
- Expensive to initialize (e.g., compiled regex, configuration)
Loggers, options, validators
Scoped- Service maintains per-request state
- Used with Entity Framework Core
- Manages database transactions
DbContext, unit of work, current user service
Transient- Service is lightweight to create
- Needs a unique instance each time
- Stateless but not thread-safe
Random generators, lightweight DTOs

Red Flags to Watch For:

  • Singleton service with mutable state
  • Scoped service in singleton constructor
  • DbContext without explicit scope in background tasks
  • Middleware with constructor-injected dependencies

Conclusion

Dependency injection scopes seem straightforward until they’re not. I’ve watched senior developers spend days tracking down issues that boiled down to a single wrong service lifetime registration.

The worst DI bug I ever encountered happened in a financial services app. A singleton service was caching user context, causing transactions to occasionally process under the wrong account. We caught it during staging, but the potential impact still gives me nightmares.

Take 15 minutes this week to audit your DI registrations. Look for singleton services with dependencies, check your background services for proper scope usage, and verify your middleware isn’t capturing scoped services. These small checks prevent big production headaches.

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

Frequently Asked Questions

What are DI scopes in ASP.NET Core?

ASP.NET Core provides three lifetimes for services: Singleton, Scoped, and Transient. Choosing the wrong scope can cause performance or memory issues.

Why is using DbContext as a Singleton a mistake?

DbContext is not thread-safe. Registering it as Singleton leads to concurrency bugs and corrupted data. Scoped lifetime per request is the correct choice.

Can middleware use scoped services?

Only when middleware is registered per request using factory methods. Otherwise, injecting scoped services into singleton middleware causes scope leaks.

How can I debug DI scope issues in ASP.NET Core?

Use logging, IServiceScopeFactory, and scope validation (builder.Services.ValidateScopes = true) to catch misconfigurations early in development.

What’s the best practice for ASP.NET Core DI?

Keep services with external resources (DbContext, HttpClient, etc.) scoped. Use Singleton for stateless services, and Transient only for lightweight objects.

References

Related Posts