Dependency Injection in .NET: Patterns and Pitfalls
Common mistakes with dependency injection in .NET and how to avoid them. From captive dependencies to service locator abuse.
Jean-Pierre Broeders
Freelance DevOps Engineer
Dependency Injection in .NET: Patterns and Pitfalls
The built-in DI container in .NET does more than most teams realize. At the same time, a lot goes wrong — often in subtle ways that only surface in production. Memory leaks from incorrect lifetimes, services that silently stop working, or an IServiceProvider showing up everywhere like some kind of god object.
The Captive Dependency Trap
This one tops the list. A singleton service receives a scoped dependency through its constructor. Sounds harmless, but the result is that scoped service never gets disposed.
// ❌ This breaks
services.AddSingleton<OrderProcessor>();
services.AddScoped<DbContext>();
public class OrderProcessor
{
private readonly DbContext _db;
public OrderProcessor(DbContext db)
{
// This DbContext now lives as long as the singleton
// Change tracking grows unbounded, connections leak
_db = db;
}
}
The DbContext gets created on first resolve of OrderProcessor and stays around. For the entire lifetime of the application. Change tracking piles up, the connection pool runs dry, and after a few hours everything slows to a crawl.
The fix is straightforward:
// ✅ Inject IServiceScopeFactory
public class OrderProcessor
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderProcessor(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task ProcessOrder(int orderId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<DbContext>();
// DbContext gets properly disposed after use
}
}
Starting with .NET 8, ValidateScopes can be enabled in development to catch these issues early:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
That ValidateOnBuild option is worth its weight in gold. At startup, the container checks whether all registrations resolve correctly. Saves a lot of debugging on Friday afternoon.
Service Locator: The Anti-Pattern That Won't Die
Injecting IServiceProvider directly is technically possible. But it makes code unreadable and untestable.
// ❌ Service Locator pattern
public class InvoiceService
{
private readonly IServiceProvider _provider;
public InvoiceService(IServiceProvider provider)
{
_provider = provider;
}
public void GenerateInvoice(int customerId)
{
// What dependencies does this class have? No clue without reading the code
var repo = _provider.GetRequiredService<ICustomerRepository>();
var mailer = _provider.GetRequiredService<IMailService>();
var pdf = _provider.GetRequiredService<IPdfGenerator>();
}
}
The problem: the constructor reveals nothing about what the class needs. Unit tests become a nightmare of IServiceProvider mocks. And refactoring gets risky because the compiler won't warn when a service disappears.
Just use constructor injection. Explicit, clear, testable.
// ✅ Constructor injection
public class InvoiceService
{
private readonly ICustomerRepository _repo;
private readonly IMailService _mailer;
private readonly IPdfGenerator _pdf;
public InvoiceService(
ICustomerRepository repo,
IMailService mailer,
IPdfGenerator pdf)
{
_repo = repo;
_mailer = mailer;
_pdf = pdf;
}
}
Keyed Services: Registering Multiple Implementations
Until .NET 8, registering multiple implementations of the same interface was clunky. Named services didn't exist in the built-in container. Keyed services solve that.
builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");
builder.Services.AddKeyedScoped<INotificationSender, PushSender>("push");
public class NotificationOrchestrator
{
public NotificationOrchestrator(
[FromKeyedServices("email")] INotificationSender emailSender,
[FromKeyedServices("sms")] INotificationSender smsSender)
{
// Specific implementations directly available
}
}
Previously, the go-to solution was a factory pattern or a dictionary of services. Keyed services make that unnecessary for most cases. Worth noting though: overuse makes registrations hard to follow. When there are more than five keys for a single interface, it might be time to rethink the design.
Options Pattern: Getting Configuration Right
Configuration through IOptions<T> often gets implemented half-heartedly. A common sight: a POCO class bound directly from configuration, without any validation.
// Registration with validation
services.AddOptions<SmtpSettings>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart(); // Fails at startup if config is invalid
public class SmtpSettings
{
[Required]
public string Host { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; }
[Required]
public string Username { get; set; } = string.Empty;
}
That ValidateOnStart() call ensures missing or invalid configuration gets caught immediately. Not when the first email needs sending and the whole system falls over.
| Interface | When to use |
|---|---|
IOptions<T> | Singleton, loaded once |
IOptionsSnapshot<T> | Scoped, reloads per request |
IOptionsMonitor<T> | Singleton with change notifications |
Decorator Pattern Without a Third-Party Container
A common reason to pull in Autofac or similar containers is the decorator pattern. But the built-in container handles it too, albeit less elegantly:
services.AddScoped<CustomerRepository>();
services.AddScoped<ICustomerRepository>(sp =>
{
var inner = sp.GetRequiredService<CustomerRepository>();
var logger = sp.GetRequiredService<ILogger<CachingCustomerRepository>>();
var cache = sp.GetRequiredService<IMemoryCache>();
return new CachingCustomerRepository(inner, cache, logger);
});
Not the prettiest code, but it works without extra dependencies. For projects with a handful of decorators, this is fine. More complex scenarios with dozens of decorators make a library like Scrutor or a full container like Autofac more attractive.
Debugging Lifetime Mismatches
When something does go wrong with lifetimes, logging what happens helps:
services.AddScoped<IOrderService>(sp =>
{
var service = new OrderService(
sp.GetRequiredService<IOrderRepository>());
var logger = sp.GetRequiredService<ILogger<Program>>();
logger.LogDebug("OrderService created: {Hash}", service.GetHashCode());
return service;
});
If the same hash keeps showing up across different requests, the service lives longer than intended. Simple but effective.
The built-in container covers 90% of needs. Problems rarely stem from container limitations — they're almost always registration mistakes. Understanding scopes and choosing lifetimes deliberately prevents the bulk of issues.
