Dependency Injection in .NET: Beyond the Basics
Most .NET developers know how to register a service and inject it into a constructor. Far fewer have thought carefully about service lifetimes, and that gap is where I've seen some of the more confusing bugs show up in production, the kind that only appear under load and are miserable to trace back to their actual cause.
The three lifetimes, and why the choice isn't cosmetic
csharp
builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
Singleton creates one instance for the entire application lifetime. Scoped creates one instance per request. Transient creates a new instance every time it's injected. This isn't a stylistic choice, it directly determines whether your service is safe to share state in, and getting it wrong produces bugs that only show up under concurrent load.
The mistake that actually causes production incidents
Injecting a scoped service into a singleton is the classic trap, and .NET's default container will throw at startup if you try it directly, which is a mercy. But it's easy to work around that protection accidentally through a factory or a manually resolved service, and end up with a singleton silently holding onto a DbContext instance from the very first request it ever handled, then reusing that same, now-stale context for every request afterward.
csharp
// Dangerous: capturing a scoped DbContext inside a singleton
public class BackgroundReportService
{
private readonly AppDbContext _context; // captured once, reused forever
public BackgroundReportService(AppDbContext context) => _context = context;
}
The fix is to inject IServiceScopeFactory and create a fresh scope whenever the singleton needs to talk to something scoped:
csharp
public class BackgroundReportService
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundReportService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task GenerateReportAsync()
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// work with a fresh, correctly-scoped context
}
}
This exact pattern is the fix for one of the more confusing bugs I've debugged: a background service that appeared to work fine in testing and then started returning stale data in production once real concurrent traffic hit it.
Constructor injection is the default for a reason
Property injection and service locator patterns exist in .NET, but I avoid both outside of narrow edge cases. Constructor injection makes a class's dependencies explicit and visible in one place, which means a class with twelve constructor parameters is honest, uncomfortable feedback that the class is doing too much. Hiding dependencies behind property injection just delays that discomfort without removing the underlying problem.
Interfaces for testability, not ceremony
I don't create an interface for every single class reflexively. I create one when there's a genuine reason: a dependency I need to mock in tests, or a real chance of swapping the implementation later. An interface with exactly one implementation and no test that mocks it is pure ceremony, extra indirection with no actual benefit.
Options pattern over injecting raw configuration
csharp
builder.Services.Configure<PaymentSettings>(builder.Configuration.GetSection("Payment"));
csharp
public class PaymentService
{
private readonly PaymentSettings _settings;
public PaymentService(IOptions<PaymentSettings> options) => _settings = options.Value;
}
This keeps configuration strongly typed and testable, instead of scattering IConfiguration["Payment:ApiKey"] string lookups throughout the codebase where a typo in a key name fails silently at runtime instead of loudly at startup.
The habit that prevents most of this
Before registering a new service, I ask what it needs to hold onto, and for how long. That single question, asked consistently, prevents most of the lifetime mismatches that otherwise only surface once real concurrent traffic finds them.