.NET Dependency Injection: Advanced Techniques Beyond the Basics

Dependency injection (DI) is a powerful feature in .NET applications. It allows you the decoupling of dependencies by injecting required services at runtime, ensuring the modularity and testability of your code. You can create separate services or import them from NuGet packages by registering them into your application. However, injecting and using services are not always that straightforward. Sometimes, you need to use dependency injection differently from conventional usage. Today, I will enclose some advanced techniques for dependency injection beyond the basics.

.NET Dependency Injection: Advanced Techniques Beyond the Basics

Basics of Dependency Injection

Before proceeding, let's understand the surface of the dependency injection. Asp .NET Core supports three provider lifetimes: Singleton, Scoped, and Transient. You can choose one based on your use case and applicability.

Singleton creates a single instance when the application starts and remains the application’s lifetime. It is best for stateless services, caching, or configuration management.

public class CacheService
{
    private readonly Dictionary<string, string> _cache = new();
    public void Set(string key, string value) => _cache[key] = value;
    public string? Get(string key) => _cache.TryGetValue(key, out var value) ? value : null;
}

// Register in DI
services.AddSingleton<CacheService>();
  • Scoped: creates a new instance on each request (in web applications) and disposes it when it ends. Suitable for services that need to maintain state within a single request but should not be shared across multiple requests.
public interface IUserService
{
    string? GetUserById(int id);
}

public class UserService: IUserService
{
    private readonly AppDbContext _context;

    public UserService( AppDbContext context) { _context = dbContext; }

    public string? GetUserById(int id)
        => _context.Users.Where(u => u.Id == id)
            .Select(u=>u.Username)
            .FirstOrDefault();
}

// Register in DI
services.AddScoped<IUserService, UserService>();

// Example usage (inside a controller)
public class MyController(IUserService userService)
{
    public IActionResult GetUserById() => Ok(userService.Id);
}
  • Transient: creates a new instance whenever the service is required regardless of the request or application lifetime. It is ideal for lightweight, stateless services that do not require sharing state. The following example
public class RandomService
{
    public int GetRandomNumber() => new Random().Next(1, 100);
}

// Register in DI
services.AddTransient<RandomService>();

// Example usage
public class MyController(RandomService randomService)
{
    public IActionResult GetRandom()
        => Ok(randomService.GetRandomNumber());
}

Dependency injection with IServiceProvider and IServiceScopeFactory

Injecting services into constructors works seamlessly with the .NET dependency injection pipeline or user-driven events such as API requests or UI interactions. However, specific scenarios—such as processing data from an MQTT broker or handling background tasks—require manual service resolution. In such cases, you can leverage IServiceProvider and IServiceScopeFactory for proper service management and lifecycle control.

IServiceProvider enables you to resolve Singleton dependencies manually.

public class MqttListener
{
    private readonly IServiceProvider _serviceProvider;

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

    public void ProcessMessage()
    {
        var cacheService = _serviceProvider.GetRequiredService<CacheService>();
        cacheService.StoreMessage("mqtt_data", "Sample Data");
    }
}

While IServiceScopeFactory manages the Scoped services lifecycle.

public class MqttListener
{
    private readonly IServiceScopeFactory _scopeFactory;

    public MqttListener(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void ProcessMessage()
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        dbContext.Messages.Add(new Message { Content = "MQTT Data" });
        dbContext.SaveChanges();
    }
}

Conditional DI with IServiceProvider

In some cases, you can inject services dynamically based on certain conditions.

public void ConfigureServices(IServiceCollection services)
{
    if (someCondition)
    {
        services.AddTransient<IParentService, CustomServiceA>();
    }
    else
    {
        services.AddTransient<IParentService, CustomServiceB>();
   }
}

DI container will decide at runtime what implementation to invoke, depending on the environment or situation.

Handling Multiple Implementations with IEnumerable

If an interface has multiple implementation classes, the developers usually need to inject each implementation separately, leading to repetitive code and increased complexity. However, .NET provides a more concise and readable approach in which you can inject all the single interface implementations simultaneously. Consider the example where you have one interface.

public interface ICustomService
{
    void PrinMessage();
}

Multiple classes have their implementations.

public class CustomServiceA: ICustomService
{
    public void PrinMessage() => Console.WriteLine("Service A executed");
}

public class CustomServiceB: ICustomService
{
    public void PrinMessage() => Console.WriteLine("Service B executed");
}

Register them

services.AddTransient<ICustomService, CustomServiceA>();
services.AddTransient<ICustomService, CustomServiceB>();

To inject them at once in a constructor

public class MyConsumerService
{
    private readonly IEnumerable<ICustomService> _services;

    public MyConsumerService(IEnumerable<ICustomService> services)
    {
        _services = services;
    }

    public void ExecuteAll()
    {
        foreach (var service in _services)
        {
            service.PrinMessage();
        }
    }
}

Managing Circular Dependencies in ASP .NET Core DI

When two or more services depend on each other in a way that creates a cycle—where ServiceA depends on ServiceB and ServiceB depends on ServiceA—this results in a circular dependency. This can cause runtime errors, as the dependency injection container gets stuck in an infinite loop and cannot resolve the dependencies correctly. The following creates a circular dependency.

public class ServiceA
{
    private readonly ServiceB _serviceB;
    public ServiceA(ServiceB serviceB) => _serviceB = serviceB;
}

public class ServiceB
{
    private readonly ServiceA _serviceA;
    public ServiceB(ServiceA serviceA) => _serviceA = serviceA;
}

In the above snippet, ServiceA needs ServiceB → ServiceB needs ServiceA → Infinite loop occurs.

To resolve

public class ServiceA
{
    private readonly IServiceProvider _serviceProvider;
    public ServiceA(IServiceProvider serviceProvider)
    {
_serviceProvider = serviceProvider;
    }

    public void UseServiceB()
    {
         var serviceB = _serviceProvider.GetRequiredService<ServiceB>(); // Resolves at runtime
         serviceB.PerformOperation();
     }
}

public class ServiceB
{
    private readonly ServiceA _serviceA;
    public ServiceB(ServiceA serviceA)
    {
        _serviceA = serviceA;
    }

    public void PerformOperation()
    {
        Console.WriteLine("ServiceB Operation Executed");
    }
}

ServiceB is injected directly into the ServiceA constructor. While ServiceB is resolved at runtime inside a method.

Implementing a Class with Multiple Interfaces

In ASP .NET Core Dependency Injection (DI), a single class can implement multiple interfaces and the same instance can be used across all those interfaces. This ensures that when you inject different interfaces, they all refer to one shared instance of the implementing class. Consider the example where we have two separate interfaces.

public interface IFileLogger
{
    void LogToFile(string message);
}

public interface IMonitoringLogger
{
    void LogToMonitoringSystem(string message);
}

Implement them in one class.

public class LoggingService : IFileLogger, IMonitoringLogger
{
    public void LogToFile(string message)
    {
        Console.WriteLine($"Writing to file: {message}");
    }

    public void LogToMonitoringSystem(string message)
    {
        Console.WriteLine($"Sending to monitoring system: {message}");
    }
}

Now register

services.AddSingleton<LoggingService>(); // Register the main class
services.AddSingleton<IFileLogger>(provider => provider.GetService<LoggingService>()); //

Use the same instance.

services.AddSingleton<IMonitoringLogger>(provider => provider.GetService<LoggingService>());
 // Use the same instance

To use those services

public class ReportGenerator
{
    private readonly IFileLogger _fileLogger;
    private readonly IMonitoringLogger _monitoringLogger;
    public ReportGenerator(IFileLogger fileLogger, IMonitoringLogger monitoringLogger)
    {
        _fileLogger = fileLogger;
        _monitoringLogger = monitoringLogger;
    }

    public void GenerateReport()
    {
        string report = "Monthly sales report generated.";
        _fileLogger.LogToFile(report);
        _monitoringLogger.LogToMonitoringSystem(report);
    }
}

Why this approach is required?

If we register it like

services.AddSingleton<IFileLogger, LoggingService>();
services.AddSingleton<IMonitoringLogger, LoggingService>();

The DI container resolves two instances of LoggingService, one for each interface—the provider.GetService ensures both interfaces resolve to the same instance, which is correct as LoggingService needed to be instantiated once.

AddKeyedTransient for Multiple Implementations

.NET 8 introduces the AddKeyed technique in dependency injection, which allows developers to register multiple implementations of the same interface and resolve one based on a key at runtime. Keyed DI comes with three lifecycles: AddKeyedTransient for transient implementations, AddKeyedScoped for scoped services, and AddKeyedSingleton for jobs with an application lifecycle.

We have interface

public interface IStorageService
{
    void SaveData(string data);
}

// Its implementations

public class SqlStorageService: IStorageService
{
    public void SaveData(string data)
    {
        Console.WriteLine($"Saving data to SQL Database: {data}");
    }
}

public class NoSqlStorageService: IStorageService
{
    public void SaveData(string data)
    {
       Console.WriteLine($"Saving data to NoSQL Database: {data}");
    }
}

public class FileStorageService: IStorageService
{
    public void SaveData(string data)
    {
        Console.WriteLine($"Saving data to a File: {data}");
    }
}

At program.cs register them

builder.Services.AddKeyedTransient<IStorageService, SqlStorageService>("SQL"); builder.Services.AddKeyedTransient<IStorageService, NoSqlStorageService>("NoSQL"); builder.Services.AddKeyedTransient<IStorageService, FileStorageService>("file");

To use

public class DataController(IServiceProvider serviceProvider)
{
    public IActionResult SaveUserData(string storageType, string data)
    {
        var storageService = serviceProvider.GetKeyedService<IStorageService>(storageType);
        if (storageService == null)
            return BadRequest("Invalid storage type");

        storageService.SaveData(data);
        return Ok($"Data stored successfully using {storageType} storage.");
    }
}

✅ If the user requests "SQL", it uses SqlStorageService.

✅ If the user requests "NoSQL", it uses NoSqlStorageService.

✅ If the user requests "file", it uses FileStorageService.

Conclusion

Dependency injection is a curbstone for loosely coupled applications. It enables you to create services such as repos or background jobs separately and register them in the application. DI ensures better code management and a maintainable architecture. In the article, I discussed some advanced techniques of dependency injections that resolve services that conventional DI cannot. Manual options like IServiceProvider and IServiceScopeFactory are suitable for background jobs and other independent tasks. You can register services with conditions and provoke them at runtime. IEnumerable injection allows you to inject implementations of an interface concisely. IServiceProvider also resolves circular dependency issues and providers.GetService ensures an interface to be registered once. Finally, we discovered AddKeyed DI for conditional resolution at runtime.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website