Dependency Injection in .NET: Patterns en Valkuilen

Veelgemaakte fouten bij dependency injection in .NET en hoe die te vermijden. Van captive dependencies tot service locator misbruik.

Jean-Pierre Broeders

Freelance DevOps Engineer

6 maart 20266 min. leestijd

Dependency Injection in .NET: Patterns en Valkuilen

De ingebouwde DI container van .NET doet meer dan de meeste teams beseffen. Tegelijk gaat er ook veel mis — vaak op subtiele manieren die pas in productie opvallen. Memory leaks door verkeerde lifetimes, services die stilletjes stoppen met werken, of een IServiceProvider die overal opduikt als een soort god-object.

De Captive Dependency Trap

Dit is veruit de meest voorkomende fout. Een singleton service krijgt een scoped dependency geïnjecteerd. Klinkt onschuldig, maar het resultaat is dat die scoped service nooit meer wordt opgeruimd.

// ❌ Dit gaat fout
services.AddSingleton<OrderProcessor>();
services.AddScoped<DbContext>();

public class OrderProcessor
{
    private readonly DbContext _db;

    public OrderProcessor(DbContext db)
    {
        // Deze DbContext leeft nu net zo lang als de singleton
        // Change tracking groeit onbeperkt, connections lekken
        _db = db;
    }
}

De DbContext wordt aangemaakt bij de eerste resolve van OrderProcessor en blijft daarna hangen. Zolang de applicatie draait. Change tracking stapelt zich op, de connection pool raakt uitgeput, en na een paar uur begint alles te vertragen.

De fix is simpeler dan verwacht:

// ✅ IServiceScopeFactory injecteren
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 wordt netjes disposed na gebruik
    }
}

Vanaf .NET 8 kan ValidateScopes aangezet worden in development om dit soort fouten vroeg te vangen:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

Die ValidateOnBuild optie is goud waard. Bij het opstarten controleert de container of alle registraties kloppen. Scheelt een hoop debuggen op vrijdagmiddag.

Service Locator: Het Anti-Pattern dat Blijft Terugkomen

IServiceProvider direct injecteren is technisch mogelijk. Maar het maakt code onleesbaar en onttestbaar.

// ❌ Service Locator pattern
public class InvoiceService
{
    private readonly IServiceProvider _provider;

    public InvoiceService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void GenerateInvoice(int customerId)
    {
        // Welke dependencies heeft deze class? Geen idee zonder de code te lezen
        var repo = _provider.GetRequiredService<ICustomerRepository>();
        var mailer = _provider.GetRequiredService<IMailService>();
        var pdf = _provider.GetRequiredService<IPdfGenerator>();
    }
}

Het probleem: de constructor vertelt niets over wat de class nodig heeft. Unit tests worden een nachtmerrie van mocks op IServiceProvider. En refactoring wordt risicovol omdat de compiler niet waarschuwt als een service verdwijnt.

Gewoon constructor injection gebruiken. Expliciet, duidelijk, testbaar.

// ✅ 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: Meerdere Implementaties Registreren

Tot .NET 8 was het registreren van meerdere implementaties van dezelfde interface omslachtig. Named services bestonden niet in de ingebouwde container. Keyed services lossen dat op.

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)
    {
        // Specifieke implementaties direct beschikbaar
    }
}

Voorheen was de standaard oplossing een factory pattern of een dictionary van services. Keyed services maken dat overbodig voor de meeste scenario's. Wel opletten: overmatig gebruik maakt registraties onoverzichtelijk. Als er meer dan vijf keys zijn voor één interface, is het misschien tijd om het design te heroverwegen.

Options Pattern: Configuratie Goed Doen

Configuratie via IOptions<T> wordt vaak halfslachtig geïmplementeerd. Wat regelmatig voorkomt: een POCO class die rechtstreeks uit configuration wordt gebonden, zonder validatie.

// Registratie met validatie
services.AddOptions<SmtpSettings>()
    .BindConfiguration("Smtp")
    .ValidateDataAnnotations()
    .ValidateOnStart();  // Faalt bij startup als config ongeldig is

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;
}

Die ValidateOnStart() call zorgt ervoor dat missende of ongeldige configuratie meteen opvalt. Niet pas wanneer de eerste mail verstuurd moet worden en het hele systeem op zijn gat gaat.

InterfaceWanneer gebruiken
IOptions<T>Singleton, wordt eenmalig geladen
IOptionsSnapshot<T>Scoped, herlaadt per request
IOptionsMonitor<T>Singleton met change notifications

Decorator Pattern Zonder Third-Party Container

Een veelgehoorde reden om Autofac of andere containers te gebruiken is het decorator pattern. Maar met de ingebouwde container gaat het ook, zij het iets minder elegant:

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);
});

Niet de mooiste code, maar het werkt zonder extra dependencies. Voor projecten met een handvol decorators is dit prima. Bij complexere scenario's met tientallen decorators wordt een library als Scrutor of een volledige container als Autofac aantrekkelijker.

Lifetime Mismatches Debuggen

Wanneer er toch iets misgaat met lifetimes, helpt het om even te loggen wat er gebeurt:

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;
});

Als dezelfde hash steeds terugkomt bij verschillende requests, leeft de service langer dan bedoeld. Simpel maar effectief.

De ingebouwde container dekt 90% van de behoeften. Problemen ontstaan zelden door beperkingen van de container — het zijn bijna altijd registratiefouten. Scopes begrijpen en lifetimes bewust kiezen voorkomt het gros van de issues.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op