Elevating Code Quality with Advanced .NET Dependency Injection Techniques
In my recent personal project, I had the opportunity to dive deep into .NET Dependency Injection (DI) and leverage advanced techniques to enhance our application's architecture. By utilizing these methods, I was able to significantly clean up the code and improve maintainability. Let me take you through the key strategies I implemented and how they transformed my project.
1. Leveraging Service Lifetimes Effectively
Understanding the importance of service lifetimes was crucial in organizing our dependencies. I carefully defined services based on how they should be instantiated:
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 services
services.AddSingleton<CacheService>();
public class UserService : IUserService
{
private readonly AppDbContext _context;
public UserService(AppDbContext context) { _context = context; }
public string? GetUserById(int id)
=> _context.Users.Where(u => u.Id == id).Select(u => u.Username).FirstOrDefault();
}
// Register in services
services.AddScoped<IUserService, UserService>();
public class RandomService
{
public int GetRandomNumber() => new Random().Next(1, 100);
}
// Register in services
services.AddTransient<RandomService>();
This approach allowed me to manage service lifetimes effectively, resulting in a cleaner and more maintainable architecture.
2. Manual Service Resolution with IServiceProvider
In situations where I needed to handle background tasks—like processing data from an MQTT broker—I found that injecting services directly wasn’t sufficient. Instead, I used IServiceProvider to resolve services manually:
public class MqttListener
{
private readonly IServiceProvider _serviceProvider;
public MqttListener(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void ProcessMessage()
{
var cacheService = _serviceProvider.GetRequiredService<CacheService>();
cacheService.Set("mqtt_data", "Sample Data");
}
}
This approach ensured that service lifetimes were handled correctly, even in complex scenarios.
3. Conditional Dependency Injection
Flexibility became key when injecting services based on environmental conditions. I implemented conditional DI like this:
public void ConfigureServices(IServiceCollection services)
{
if (someCondition)
{
services.AddTransient<IParentService, CustomServiceA>();
}
else
{
services.AddTransient<IParentService, CustomServiceB>();
}
}
This technique reduced boilerplate code and made the application adaptable to different scenarios.
4. Handling Multiple Implementations
Injecting multiple implementations of the same interface became seamless by utilizing IEnumerable<T>. Instead of injecting each implementation separately, I could inject all at once:
Recommended by LinkedIn
public interface ICustomService
{
void PrintMessage();
}
public class MyConsumerService
{
private readonly IEnumerable<ICustomService> _services;
public MyConsumerService(IEnumerable<ICustomService> services)
{
_services = services;
}
public void ExecuteAll()
{
foreach (var service in _services)
{
service.PrintMessage();
}
}
}
This approach minimized constructor clutter and promoted cleaner, more maintainable code.
5. Resolving Circular Dependencies
Circular dependencies can lead to runtime errors. I tackled this issue by resolving dependencies at runtime using IServiceProvider:
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();
}
}
This method prevented infinite loops and ensured a robust system.
6. Implementing Classes with Multiple Interfaces
To simplify logging behavior, I leveraged the ability to implement multiple interfaces in a single class. This ensured all logging behavior referred to one shared instance:
public class LoggingService : IFileLogger, IMonitoringLogger
{
public void LogToFile(string message) => Console.WriteLine($"Writing to file: {message}");
public void LogToMonitoringSystem(string message) => Console.WriteLine($"Monitoring: {message}");
}
// Registration in DI
services.AddSingleton<LoggingService>();
services.AddSingleton<IFileLogger>(provider => provider.GetService<LoggingService>());
services.AddSingleton<IMonitoringLogger>(provider => provider.GetService<LoggingService>());
This technique enhanced resource management and improved maintainability.
🚀 7. Adopting the New AddKeyed Technique
With .NET 8, I utilized the AddKeyed feature for conditionally resolving implementations at runtime:
builder.Services.AddKeyedTransient<IStorageService, SqlStorageService>("SQL");
builder.Services.AddKeyedTransient<IStorageService, NoSqlStorageService>("NoSQL");
builder.Services.AddKeyedTransient<IStorageService, FileStorageService>("file");
This allowed me to define multiple storage strategies and dynamically choose one at runtime based on user input, enhancing the application’s adaptability.
🎉 Conclusion
Implementing these advanced Dependency Injection techniques in my project not only improved code cleanliness but also made the application more maintainable and adaptable. By effectively managing service lifetimes, resolving dependencies dynamically, and handling multiple implementations with ease, I’ve created a more robust architecture.
✅ Dependency Injection is truly a cornerstone of modern application development, and mastering its advanced features opens up a world of possibilities for building scalable and maintainable software.
💬 I’d love to hear how you’ve used DI in your projects! What techniques or challenges have you encountered? Let’s exchange ideas in the comments!
#dotnet #dependencyinjection #softwareengineering #cleanarchitecture #codingbestpractices