Wednesday, June 06, 2018

Honey, I shrunk `ConfigureServices()`

The problem with all DI systems is that configuration generally is pulled in two polar-opposite directions. Either your container is configured by convention (with a few rare exception cases), or it's configured directly and declaratively with masses of registrations.
The latter configuration practice has the benefit of not having any side-effects as you bring in third party libraries - you'll not get random Controller classes added to your system, for example. And this is why it's my preference.
BUT, declaring every dependency in your container can lead to the dreaded "God Configuration Method" - where one method has hundreds of lines of container registration. This bad - it couples your application tightly to all dependencies at all levels, and makes it hard to maintain.
For the last few years, I've been using a discovery system for Unity that moves the registrations for dependencies away from the "God Configuration Method" and into individual "Bootstrappers" that live alongside the implementation of the service that they register.
This "magic" discovery pattern works well - reducing the "God Configuration Method" to a single line - and allowing (for example) client libraries to define the "right" lifecycles themselves, not relying on a programmer getting it right each time they use the client.
Today, I'm releasing an experiment - a port of "magic" bootstrapper discovery for ASPNet Core and the Microsoft DI framework.
It might be useful - it's certainly simple enough to use. The experiment is to see what sort of take-up it gets.

Note: I added the "magic" extensions under the Microsoft.Extensions.DependencyInjection.Discovery namespace deliberately.

Usage

Using the discovery library is simple. First install the NuGet package:
install-package CheviotConsulting.DependencyInjection.Discovery
Next, where previously you would add registrations into the Startup.ConfigureServices(...) method, you write a IServiceDiscoveryBootstrapper implementation
using Microsoft.Extensions.DependencyInjection;

public sealed class WebAppServiceBootstrapper : IServiceDiscoveryBootstrapper
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<IWebAppService, MyWebAppService>();
        }
    }
You can have as many of these as you like, in as many referenced projects as you like - which allows you to put a service registration alongside a service or service client itself.
Then in the Startup.ConfigureServices(...) method, you add the following single line:
using Microsoft.Extensions.DependencyInjection.Discovery;

public class Startup
{
    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        ...
        services.BootstrapByDiscovery();
    }
And that's it. The discovery framework will load any assemblies referenced by your application, find all IServiceDiscoveryBootstrapper instances and execute the ConfigureServices(...) method on each in turn - bootstrapping the IServiceCollection used by ASPNet Core / Microsoft.Extensions.DependencyInjection.

Advanced Usage

Bootstrapper Dependencies

Unlike the Startup.ConfigureServices method, you can't pass dependencies in the method parameters. However, by default the bootstrapper classes themselves are resolved using the IServiceCollection as it stands just before the call to BootstrapByDiscovery() is made. So you can use constructor dependency injection in your bootstrapper.
public sealed class SampleServicesBootstrapper : IServiceDiscoveryBootstrapper
{
    private readonly IHttpContextAccessor contextAccessor;

    public SampleServicesBootstrapper(IHttpContextAccessor contextAccessor)
    {
        this.contextAccessor = contextAccessor;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<MyCustomService>(new MyCustomService(contextAccessor));
    }
}

Manual Bootstrapping

If you don't want to rely on the "magic" discovery, you can always bootstrap each of your bootstrappers individually within your Startup.ConfigureServices(...) method.
public class Startup
{
    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        // Bootstrap services manually
        services.BootstrapFrom<SampleServicesBootstrapper>();
        services.BootstrapFrom(new WebAppServiceBootstrapper());
    }
}

5 comments:

Steve Grattan said...

We've been using an incarnation of this in-house and it's a real boon on large projects where it isn't possible for developers to know every registration that might be needed without having a complete understanding of all dependencies.

To be able to roll your own bootstrapper following this scheme and include it in a NuGet package that many clients might use almost eliminates the need for any documentation of your 'masterpiece' if they just want to use your NuGet out-of-box...and you can silently change the registrations in the future and re-publish without breaking your client base!

Well done Joel.

وحيد نصيري said...

Hi, There's another similar library for this matter
https://github.com/khellang/Scrutor

Joel Hammond-Turner said...

I'd not seen Scrutor, and see how it's similar - but it's trying to do other things.

Scrutor is a fluent convention-based API for service discovery and registration, with the bonus of being able to decorate stuff post-registration.

Mine is just about breaking the `ConfigureServices` method apart - and would work quite nicely alongside Scrutor.

MCA said...

Do you have any good way of including registrations for Tests? Or there is no going around that you have to manually register them in Test Projects?

Joel Hammond-Turner said...

I'm assuming you're doing something like self-hosting in your tests?

The "magic" framework works by using assembly discovery, so if you add a bootstrapper class in your test assembly, it'll only be discovered when the test bootstraps the hosting environment.

Alternatively, you can bootstrap your services "manually" (see the readme) in your tests, and have your test bootstrapper last - guaranteeing it's registrations take precidence.

Don't forget your bootstrappers can be clever - not registering implementations if another bootstrapper got there first or removing other unwanted implementations as required.