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

March 6, 20266 min. read

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.

InterfaceWhen 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.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch