Table of Contents
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:
Lifetime | When to Use | Examples |
---|---|---|
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.
Frequently Asked Questions
What are DI scopes in ASP.NET Core?
Why is using DbContext as a Singleton a mistake?
Can middleware use scoped services?
How can I debug DI scope issues in ASP.NET Core?
IServiceScopeFactory
, and scope validation (builder.Services.ValidateScopes = true
) to catch misconfigurations early in development.What’s the best practice for ASP.NET Core DI?
References
- Dependency injection in ASP.NET Core | Microsoft Learn
- Dependency injection guidelines - .NET | Microsoft Learn
- c# - .NET Core/EF 6 - Dependency Injection Scope - Stack Overflow
- Service Lifetimes in ASP.NET Core | endjin