Dependency Injection simplified: Convention over configuration

February 2, 2023

For a long time, Dependency Injection and Inversion of Control are the most common patterns used to reduce coupling at class and component level. For complex systems having multiple composition roots such as microservices, it’s crucial to use a solution which doesn’t require registering components everywhere it’s going to be used, and this problem can be solved by registering dependencies based on certain conventions.
In this article I’m going to present a lightweight solution implemented in .net core, which supports convention based dependency registration, conditional dependencies and the Decorator Pattern. The code used in this article can be found in my github repository.

Since first introduced in .Net Core, the IServiceCollection and IServiceProvider are integral part of the most common Microsoft libraries such as Asp.Net or the NET.Sdk.Functions, inevitable by any solution built on top of them. Compared to its predecessor (UnityContainer) IServieCollection and IServiceProvider separates the concepts of dependency registration from dependency injection, which results in a better organized code when using them. There are well defined features one can do and the other don’t and can only be used in a certain order.

The beauty of the IServieCollection and IServiceProvider is that they are lightweight, but they are also limited in terms of registering dependencies based on convention, instead of registering each of them individually.

There are popular libraries such as AtoFac, which scans assemblies looking for types implementing certain interfaces or decorated with some attributes. AutoFac is very robust, with a rich set of capabilities, however when implemented in .net core it actually wraps the IServiceColletion together with its dependencies, and registers in its own container. At least in the Abp framework I last used it did this way, resulting in a hybrid solution which was hard to follow and debug, when services were not injected the way they were supposed. Besides the fact that extending them to support the decorator pattern or filtering dependencies based on a given criteria, is really difficult.

Few years ago, in a multi-tenant solution, when had the requirement to have Tenant and Api Version specific logic implemented in isolation, I decided to address this issue directly at the object composition level, and have components that are only injected when a certain condition is met, otherwise the base component. For this I implemented a piece of code on top of IServiceCollection to register dependencies based on conventions and resolve them conditionally if there is any.
Here is how it looks like:

Host.CreateDefaultBuilder(args)
    .ConfigureServices((hostContext, services) =>
    {
        services.AddConventionalDependencies("TNArch.DependencyInjection",
                                        c => c.WithConfiguration(hostContext.Configuration)
                                              .DecoratedWith<DependencyAttribute>()
                                              .DecoratedWith<DecorateDependencyAttribute>()
                                              .DecoratedWith<ReplaceDependencyAttribute>()
                                              .UseCache("Redis:ConnectionString")
                                              .UseApplicationInsights("ApplicationInsights:ConnectionString")
                                              .DecoratedWithConfigurationDescriptor());
    
        services.AddHostedService<HostedService>();
    
    }).Build().Start();

In the above code the AddConventionalDependencies method scans all the referenced assemblies whose name starts with “TNArch.DependencyInjection”, to avoid scanning all the referenced assemblies. While iterating the types registered in the specified assemblies, the rules between line 4-10 will register dependencies in the service collection. Each rule represents a convention upon which types will be registered in the ServiceCollection.

The DecoratedWith<> method, implements the convention which registers all the types that have the given attribute, with the contract and lifetime defined in the attribute.

Classes with the Dependency attribute, are registered as injectable object via the type defined in the attribute. Additional options are lifetime and scope. In the below example the TestService will be injectable as ITestService with the default Scoped lifetime.

[Dependency(typeof(ITestService))]
public class TestService : ITestService
{
    public TestService(ITestDependency dependency)
    {            
    }
}

Classes with the DecorateDependency attribute, are registered as an injectable object via the type defined in the attribute. However they can get injected the same type with a different implementation. This is the implementation of the Decorator design pattern. The pattern allows the extension of existing services with new behavior, without altering the base service. A typical use case is caching, but it has many more applications. Below is an example of caching:

[Dependency(typeof(ITenantRepository))]
public class TenantRepository : ITenantRepository
{
    public TenantRepository() { }

    public async Task<List<Tenant>> GetTenants()
    {
        //return Tenants from Database
    }
}

[DecorateDependency(typeof(ITenantRepository), typeof(TenantRepository))]
public class TenantRepositoryCache : ITenantRepository
{
    private readonly ITenantRepository _tenantRepository;
    private readonly ICacheService _cacheService;

    public TenantRepositoryCache(ITenantRepository tenantRepository, ICacheService cacheService)
    {
        _tenantRepository = tenantRepository;
        _cacheService = cacheService;
    }

    public virtual async Task<List<Tenant>> GetTenants()
    {
        return await _cacheService.GetOrCreate("Tenants", _tenantRepository.GetTenants);
    }
}

Classes with the ReplaceDependency attribute, are registered as an injectable object via the type defined in the attribute. However they are replacing an existing implementation if  the ServiceProvider is created in the same scope as defined in the attribute. This is useful if you have an API with multiple versions and you want to execute code specific for a version. Both the DecorateDependency and ReplaceDependency attributes support scope filters, however using ReplaceDependency only makes sense if used together with a scope.
In the below example TestDependencyV2 gets resolved instead of TestDependency due to the fact that it’s resolved by a ServiceProvider scoped to “V2” created at line 13:

[Dependency(typeof(ITestDependency))]
public class TestDependency : ITestDependency
{
    public TestDependency(){}
}    

[ReplaceDependency(typeof(ITestDependency), typeof(TestDependency), scope: "V2")]
public class TestDependencyV2 : ITestDependency
{
    public TestDependencyV2(){}
}

var scopedServiceProvider = _serviceProvider.CreateScope("V2").ServiceProvider;
var servicesInScopeV2 = scopedServiceProvider.GetService<ITestDependency>();

In every composition root of your application only the required rules should be used. Dependency injection logic should be encapsulated into a custom rule instead of defining extensions methods for IServiceCollection itself, like in most of the libraries. I found this approach more elegant and creates a unified overview over the dependencies being registered.
In the above example RedisCache and ApplicationInsights are registered at lines 9-10.


Custom rules

A good example of dependency injection logic every application has, is configuration binding and configuration object registration. To solve this without the need of repetitive coding I use the following custom rule:

public DependencyDescriptorBuilder DecoratedWithConfigurationDescriptor()
{
    ServiceFactories.Add((type, attributes) =>
    {
        var configurationDescriptor = attributes.OfType<ConfigurationDescriptorAttribute>().FirstOrDefault();

        if (configurationDescriptor == null)
            return;

        if (configurationDescriptor.IsCollection)
            type = type.MakeArrayType();

        var instance = Configuration.GetSection(configurationDescriptor.ConfigurationPath ?? type.Name).Get(type);

        ServiceCollection.AddSingleton(type, instance);
    });

    return this;
}
The convention implemented above is, whenever there is an object decorated with the ConfigurationDescriptor attribute, register an instance of that type, with values bound from the IConfiguration object using the configurationPath defined in the attribute.
[ConfigurationDescriptor("Settings:TestConfigs")]
public class TestConfiguration
{
    public string Value1 { get; set; }
    public TimeSpan Value2 { get; set; }
}

public class TestService : ITestService
{
    private readonly TestConfiguration _configuration;

    public TestService(TestConfiguration configuration)
    {
         _configuration = configuration;
    }
}

Another example of custom rule is the registration of Azure Redis Cache, which if not present an in memory cache is registered. This reduces costs when running the application in a local development environment.

public static DependencyDescriptorBuilder UseCache(this DependencyDescriptorBuilder builder, string redisConfigKey)
 {
     var redisConfigs = builder.Configuration.GetValue<string>(redisConfigKey);

     if (string.IsNullOrEmpty(redisConfigs))
     {
         builder.ServiceCollection.AddDistributedMemoryCache();
         return builder;
     }

     builder.ServiceCollection.AddStackExchangeRedisCache(options =>
     {
         options.Configuration = redisConfigs;
         options.InstanceName = "AppCache";
     });

     return builder;
 }

During the years in many project found a large variety use cases which I solved with this extension build in top of Microsoft’s Microsoft.Extensions.DependencyInjection library, here are a few:

1. Support for the Decorator design pattern, to implement caching and exception handling.
2. Isolating tenant specific logic in a multi-tenant application by using service replacements for certain dependencies, without any reference from the tenant agnostic code.
3. Isolating code resolving backwards compatibility for a given api version, without any reference from other versions.
4. Implement the Service Locator pattern with the possibility to controll the number of dependencies resolved based on filtering condition (scope).
5. Implement advanced DI logic while keeping the business domain agnostic of it, just decorating objects with attributes and implementing interfaces.
6. Fast and lightweight convention based dependency registration for serverless azure functions that require reduced warm-up time.

Applying convention based dependency registration is not only reducing DI logic dramatically, but also reduces the risk of referencing a dependency which is not registered in the container. This because if you can reference the type, means that it’s in  referenced assembly which is getting to be scanned on startup, and from that point you only have to make sure it has an actual implementation with an attribute or interface. The rest of the settings you have to do only once.

Although the source code in my git repository is a complete solution and I’ve been using it for years, it’s not a refined product! Its purpose is to showcase a concept of an abstraction and serve the baseline of an implementation by taking and adapting the source code, rather than trying to implement many unused features and become heavier and slower like existing products on the market.

You may also like…

Microservices simplified: Concurrent processes

Microservices simplified: Concurrent processes

Handling concurrent processes it’s not specific to microservices, but microservices and distributed systems in general bring an additional complexity to the table, which is caused by the fact that multiple concurrent and distributed flows can run in…

read more