Human readable Dependency Injection in .NET Core
In many applications and development teams, dependency injection (or DI) with inversion of control (or IoC) has become standard practice for creating better software design. It allows for loosely coupled modules, better unit tests (or even TDD) and a better implementation of SOLID principles.
Over the years we all got used to our favorite IoC frameworks; including Autofac, Ninject, Windsor and many others. But, with the release of ASP.NET Core, we now have DI built right into the framework! It is intentionally designed to have less features, lowering the learning curve for those who are unfamiliar with the concept. And, should the built-in IoC container not meet all your needs, a third-party IoC container plugin can be used quite easily.
With this post I would like to show you how you can write "human readable" dependency registrations. While this is framework agnostic, the code samples use the .NET Core DI framework.
If you are unfamiliar with dependency injection in ASP.NET Core, please review the documentation provided by Microsoft here.
So, let's dive in!
Standard registrations
We use the IServiceCollection
to do registrations in the Startup
class of the client application. Which looks like this:
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IApp>(new App("switching-api"));
services.AddDbContext<SomebContext>(o =>
o.UseSqlServer(SqlServerConnectionString))
services.AddTransient<IRepository, Repository>();
}
Layered Application
As a general practice, move your registration to the responsible layer. If you have a data access layer (DAL), all registrations should be located within its boundaries. This follows the DRY (don't repeat yourself) principle and increases the level of maintainability (remember SOLID).
We can use an extension method to achieve exactly that. In the DB project, we add a file containing the following code:
public static IServiceCollection RegisterDbModule(this IServiceCollection services, string connectionString)
{
services.AddDbContext<SomebContext>(
options => options.UseSqlServer(connectionString));
services.AddDbContext<ReadReplicaContext>(
options => options.UseSqlServer(connectionString));
services.AddTransient<IRepository, Repository>();
services.AddTransient<IFastReadService, FastReadService>();
return services;
}
All the DB related registrations are moved to the DAL. Our startup class is now much cleaner:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IApp>(new App("switching-api"));
services.RegisterDbModule(SqlServerConnectionString);
}
Advantages should be clear at this point. We can use the RegisterDbModule
wherever we want. Multiple clients (and multiple Startup
classes) are not subject to change any more. If we need extra registrations in the DAL, we only need to change the extension method.
N-Tier and Micro-Services
If you have a lot of layers, a lot of micro services, an N-Tier or message/event driven application; the RegisterDbModule
will not be enough.
Enter human readable registrations!
Moving on with the sample, we could split up the code even further:
public static IServiceCollection WithDbContext(this IServiceCollection services, string connectionString)
{
services.AddDbContext<SomebContext>(
options => options.UseSqlServer(connectionString));
return services;
}
public static IServiceCollection WithFastReadReplica(this IServiceCollection services, string connectionString)
{
services.AddDbContext<ReadReplicaContext>(
options => options.UseSqlServer(connectionString));
services.AddTransient<IFastReadService, FastReadService>();
return services;
}
public static IServiceCollection WithRepositories(this IServiceCollection services)
{
services.AddTransient<IRepository, Repository>();
return services;
}
Which then gives you more control regarding dependency availability for each client application. With the sample above, our Startup
class now looks like this:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IApp>(new App("switch-api"));
//no fast read service required for this client
services.WithDbContext(SqlServerConnectionString)
.WithRepositories();
}
Or, with the fast-read service required:
public void ConfigureServices(IServiceCollection services)
{
//fast read service required for this client
services.WithDbContext(SqlServerConnectionString)
.WithFastReadReplica(SqlServerConnectionString)
.WithRepositories();
}
This is, of course, an overly simplistic sample.
If you are working on a an application where the registrations get more complex, the advantages are enormous.
Let's take a look at a bootstrap Startup
class of a fairly complex application using Azure Service Bus, Azure BlobStorage and Azure functions:
public class Startup : FunctionsStartup
public override void Configure(IFunctionsHostBuilder builder)
{
var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
var connectionStore = new AzureCredentialStore(config);
builder.Services.AddSingleton<IApp>(new App("some-app"));
builder.Services
.PerformDefaultRegistrationForFunction(connectionStore)
.WithAzureBlobStorage(typeof(AzureBlobContainer).Assembly)
.WithDbContext(connectionStore)
.WithRectificationTraceService()
.WithRetryHandling()
.WithEventModule()
.WithEntityCollectors()
.WithMarketExport();
builder.Services.AddLogging();
}
}
As you can see, the code is very clear and transparent. It only takes a second to analyze what will be available for this client during its run-time.
Hope you enjoyed the read and happy coding!
Photo by Markus Spiske on Unsplash