Most of us treat dependency injection scopes like a checkbox: pick Singleton, Scoped, or Transient and move on. That confidence lasts until a 3 AM production crash, and you're staring at an ObjectDisposedException
.
I learned this the hard way. Let's walk through the five most common DI scope mistakes I see in code reviews so you can avoid them.
1. Registering DbContext
as Singleton
This is the fastest way to corrupt data. DbContext
is not thread-safe and holds tracked entities. As a singleton, it creates massive memory leaks and concurrency bugs.
❌ The Mistake
// DON'T DO THIS
services.AddSingleton<AppDbContext>();
✅ The Fix
Always register DbContext
as scoped. This is the default behavior and ensures one instance per request.
// CORRECT
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
2. Injecting Scoped Services into Singletons
A singleton lives forever; a scoped service dies with the request. Trying to inject a scoped service (like a repository using DbContext
) into a singleton will cause an InvalidOperationException
.
❌ The Mistake
public class MySingletonService
{
// You can't inject a scoped service here!
public MySingletonService(IUserRepository userRepo) { ... }
}
services.AddSingleton<MySingletonService>();
services.AddScoped<IUserRepository, UserRepository>();
✅ The Fix
If you must access a scoped service from a singleton, inject IServiceScopeFactory
and create a scope manually.
public class MySingletonService
{
private readonly IServiceScopeFactory _scopeFactory;
public async Task DoWorkAsync()
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IUserRepository>();
// ... use the repo
}
}
3. Forgetting Scopes in Background Services
BackgroundService
instances are singletons and run outside the request pipeline, so there's no automatic scope. Injecting DbContext
directly will cause it to live forever, leading to memory leaks.
❌ The Mistake
public class NotificationService : BackgroundService
{
private readonly AppDbContext _dbContext; // WRONG! Lives forever.
public NotificationService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
protected override async Task ExecuteAsync(CancellationToken token)
{
// This uses the same DbContext instance for days...
var items = await _dbContext.Notifications.ToListAsync();
}
}
✅ The Fix
Use IServiceScopeFactory
to create a new scope for each unit of work inside your ExecuteAsync
loop.
public class NotificationService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public NotificationService(IServiceScopeFactory scopeFactory) { ... }
protected override async Task ExecuteAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// DbContext is disposed correctly after each loop
var items = await dbContext.Notifications.ToListAsync(token);
}
await Task.Delay(TimeSpan.FromMinutes(5), token);
}
}
}
4. Misunderstanding Middleware Scopes
Middleware classes are singletons—created once for the application's lifetime. If you inject a scoped service into the constructor, you capture the service from the first request and reuse it for all subsequent requests, leading to data bleeding between users.
❌ The Mistake
public class TenantMiddleware
{
private readonly ITenantService _tenantService; // Scoped, but captured!
// Constructor injection captures the service
public TenantMiddleware(RequestDelegate next, ITenantService tenantService)
{
_tenantService = tenantService;
}
public async Task InvokeAsync(HttpContext context)
{
// Using the same tenant service instance for every request!
await _tenantService.DoWorkAsync();
await _next(context);
}
}
✅ The Fix
Inject scoped services directly into the InvokeAsync
method. The DI container resolves these dependencies per-request.
public class TenantMiddleware
{
public TenantMiddleware(RequestDelegate next) { ... }
// Scoped services are injected here, per-request
public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
{
await tenantService.DoWorkAsync();
await _next(context);
}
}
5. Overusing Transient for Heavy Objects
Transient
creates a new instance every single time an object is requested. This is fine for lightweight, stateless services, but it's a performance killer for heavy objects like HttpClient
(can cause socket exhaustion) or compiled regex patterns.
❌ The Mistake
// BAD: Creates a new client every time, can lead to socket exhaustion
services.AddTransient<HttpClient>();
✅ The Fix
Use AddHttpClient
for HttpClient
management and register other heavy, stateless services as Singleton
.
// GOOD: Manages HttpClient lifecycle and connection pooling
services.AddHttpClient<MyApiService>();
// GOOD: Compile once, reuse everywhere
services.AddSingleton<IRegexValidator>(sp =>
new RegexValidator(MyCompiledRegex));
Quick Checklist
- Singleton: For stateless, thread-safe services (e.g., loggers, configuration).
-
Scoped: For services that need to maintain state within a single request (e.g.,
DbContext
, Unit of Work). - Transient: For lightweight, stateless services that are cheap to create.
Take 5 minutes to audit your Program.cs
file. A quick check now can save you a major production headache later.
Happy coding!
Top comments (0)