Advanced .NET Dependency Injection Techniques

Go beyond the basics of dependency injection in .NET. This guide covers advanced topics like keyed services, factory registrations, and managing service lifetimes for complex scenarios.

Once you've mastered the basics of dependency injection (DI) in .NET—registering services and understanding the Transient, Scoped, and Singleton lifetimes—you'll eventually encounter more complex scenarios that require a deeper understanding of the DI container.

Let's explore some advanced techniques that can help you build more flexible and maintainable applications.

1. Keyed Services: When One Interface Isn't Enough

Introduced in .NET 8, keyed services solve a common problem: what if you have multiple implementations of the same interface, and you need to resolve a specific one based on some key?

Imagine you have multiple payment gateway implementations:

public interface IPaymentGateway
{
    Task<bool> ProcessPaymentAsync(decimal amount);
}

public class StripeGateway : IPaymentGateway { /* ... */ }
public class PayPalGateway : IPaymentGateway { /* ... */ }

Before keyed services, you might have had to create a factory to resolve the correct gateway. Now, you can register them with a key:

Registration in Program.cs:

builder.Services.AddKeyedSingleton<IPaymentGateway>("stripe", new StripeGateway());
builder.Services.AddKeyedSingleton<IPaymentGateway>("paypal", new PayPalGateway());

Resolving a Keyed Service: To resolve a specific implementation, you use the [FromKeyedServices] attribute in your constructor:

public class OrderService
{
    private readonly IPaymentGateway _stripeGateway;

    public OrderService([FromKeyedServices("stripe")] IPaymentGateway stripeGateway)
    {
        _stripeGateway = stripeGateway;
    }

    public async Task ProcessOrderAsync(Order order)
    {
        // This will always use the Stripe gateway
        await _stripeGateway.ProcessPaymentAsync(order.TotalAmount);
    }
}

This provides a clean, built-in way to handle strategic choices between different implementations of an interface.

2. Factory Registrations: For Complex Initialization

Sometimes, creating an instance of a service is more complex than just calling its constructor. It might depend on other services or require some configuration logic. For these cases, you can provide a factory function for the registration.

Use Case: Your MyService needs an HttpClient that is configured with a base address from your appsettings.json.

builder.Services.AddHttpClient(); // Ensure IHttpClientFactory is available

builder.Services.AddScoped<MyService>(serviceProvider =>
{
    // Get the required dependencies from the service provider
    var configuration = serviceProvider.GetRequiredService<IConfiguration>();
    var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();

    var apiKey = configuration["MyApiSettings:ApiKey"];
    var httpClient = httpClientFactory.CreateClient();
    httpClient.DefaultRequestHeaders.Add("X-Api-Key", apiKey);

    // Create and return the service instance
    return new MyService(httpClient);
});

This factory function gives you full control over the instantiation process, allowing you to perform complex setup logic while still leveraging the DI container.

3. IServiceProvider and the Service Locator Anti-Pattern

You can inject IServiceProvider directly into your services. This gives you access to the entire DI container, allowing you to resolve any registered service on demand.

public class MyService
{
    private readonly IServiceProvider _serviceProvider;

    public MyService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void DoSomething()
    {
        // Manually resolve a service
        var logger = _serviceProvider.GetRequiredService<ILogger<MyService>>();
        logger.LogInformation("Doing something...");
    }
}

Warning: While this is possible, it should be used sparingly. Overusing this pattern can lead to the Service Locator anti-pattern, where a class's dependencies are no longer declared in its constructor. This makes the code harder to understand and test because the dependencies are hidden. It's almost always better to explicitly inject the services you need directly into the constructor.

4. Registering Open Generics

If you have a generic interface and a corresponding generic implementation, you don't have to register every possible closed type. You can register the open generic type.

Example: A generic repository pattern.

public interface IRepository<T>
{
    Task<T> GetByIdAsync(int id);
}

public class EfRepository<T> : IRepository<T> where T : class
{
    // ... implementation
}

Registration in Program.cs:

// Register the open generic type
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

Now, if a service requests IRepository<Product> or IRepository<User>, the DI container will automatically create the correct closed generic type (EfRepository<Product> or EfRepository<User>) for you.

Conclusion

These advanced DI techniques provide the tools you need to solve complex dependency management problems in a clean and maintainable way. By understanding keyed services for strategic choices, factory functions for complex initializations, and open generics for reusable patterns, you can leverage the full power of the .NET dependency injection container to build sophisticated, loosely coupled applications.