An ultimately pointless exploration of options.

This article is part of a series called Type-safe DI composition. In the previous article, you saw a type-level prototype written in Haskell. If you don't read Haskell code, then it's okay to skip that article and instead start reading here. I'm not going to assume that you've read and understood the previous article.

In this article, I'll begin exploration of a type-safe Dependency Injection (DI) Container prototype written in C#. In order to demonstrate that it works in a realistic environment, I'm going to use it in the code base that accompanies Code That Fits in Your Head.

Empty container #

Like the previous article, we can start with an empty container:

public sealed class Container
{
    public readonly static Container Empty = new Container();
 
    private Container()
    {
    }
 
    public Container<T1> Register<T1>(T1 item1)
    {
        return new Container<T1>(item1);
    }
}

The only API this class affords is the Empty Singleton instance and the Register method. As you can tell from the signature, this method returns a different container type.

Generic container with one item #

The above Register method returns a Container<T1> instance. This class is defined like this:

public sealed class Container<T1>
{
    public Container(T1 item1)
    {
        Item1 = item1;
    }
 
    public T1 Item1 { get; }
 
    // More members here...

This enables you to add a single service of any type T1. For example, if you Register an IConfiguration instance, you'll have a Container<IConfiguration>:

Container<IConfiguration> container = Container.Empty.Register(Configuration);

The static type system tells you that Item1 contains an IConfiguration object - not a collection of IConfiguration objects, or one that may or may not be there. There's guaranteed to be one and only one. No defensive coding is required:

IConfiguration conf = container.Item1;

A container that contains only a single service is, however, hardly interesting. How do we add more services?

Registration #

The Container<T1> class affords a Register method of its own:

public Container<T1, T2> Register<T2>(T2 item2)
{
    return new Container<T1, T2>(Item1, item2);
}

This method return a new container that contains both Item1 and item2.

There's also a convenient overload that, in some scenarios, better support method chaining:

public Container<T1, T2> Register<T2>(Func<T1, T2> create)
{
    if (create is null)
        throw new ArgumentNullException(nameof(create));
 
    T2 item2 = create(Item1);
    return Register(item2);
}

This method runs a create function to produce an object of the type T2 given the already-registered service Item1.

As an example, the code base's Composition Root defines a method for creating an IRestaurantDatabase object:

public static IRestaurantDatabase CreateRestaurantDatabase(
   IConfiguration configuration)
{
    if (configuration is null)
        throw new ArgumentNullException(nameof(configuration));
 
    var restaurantsOptions = configuration.GetSection("Restaurants")
        .Get<RestaurantOptions[]>();
    return new InMemoryRestaurantDatabase(restaurantsOptions
        .Select(r => r.ToRestaurant())
        .OfType<Restaurant>()
        .ToArray());
}

Notice that this method takes an IConfiguration object as input. You can now use the Register overload to add the IRestaurantDatabase service to the container:

Container<IConfiguration, IRestaurantDatabase> container = Container.Empty
    .Register(Configuration)
    .Register(conf => CompositionRoot.CreateRestaurantDatabase(conf));

Or, via eta reduction:

Container<IConfiguration, IRestaurantDatabase> container = Container.Empty
    .Register(Configuration)
    .Register(CompositionRoot.CreateRestaurantDatabase);

You've probably noticed that this container is one with two generic type arguments.

Generic container with two items #

This new class is, not surprisingly, defined like this:

public sealed class Container<T1T2>
{
    public Container(T1 item1, T2 item2)
    {
        Item1 = item1;
        Item2 = item2;
    }
 
    public T1 Item1 { get; }
    public T2 Item2 { get; }
 
    public Container<T1, T2, T3> Register<T3>(T3 item3)
    {
        return new Container<T1, T2, T3>(Item1, Item2, item3);
    }
 
    public Container<T1, T2, T3> Register<T3>(Func<T1, T2, T3> create)
    {
        if (create is null)
            throw new ArgumentNullException(nameof(create));
 
        var item3 = create(Item1, Item2);
        return Register(item3);
    }
 
    public override bool Equals(objectobj)
    {
        return obj is Container<T1, T2> container &&
               EqualityComparer<T1>.Default.Equals(Item1, container.Item1) &&
               EqualityComparer<T2>.Default.Equals(Item2, container.Item2);
    }
 
    public override int GetHashCode()
    {
        return HashCode.Combine(Item1, Item2);
    }
}

Like Container<T1> it also defines two Register overloads that enable you to add yet another service.

If you're on C# 9 or later, you could dispense with much of the boilerplate code by defining the type as a record instead of a class.

Containers of higher arity #

You've probably noticed a pattern. Each Register method just returns a container with incremented arity:

public Container<T1, T2, T3, T4> Register<T4>(T4 item4)
{
    return new Container<T1, T2, T3, T4>(Item1, Item2, Item3, item4);
}

and

public Container<T1, T2, T3, T4, T5> Register<T5>(T5 item5)
{
    return new Container<T1, T2, T3, T4, T5>(Item1, Item2, Item3, Item4, item5);
}

and so on.

Wait! Does every new service require a new class? What if you have 143 services to register?

Well, yes, as presented here, you'll need 144 classes (one for each service, plus the empty container). They'd all be generic, so you could imagine making a reusable library that defines all these classes. It is, however, pointless for several reasons:

  • You shouldn't have that many services. Do yourself a favour and have a service for each 'real' architectural dependency: 1. Database, 2. Email gateway, 3. Third-party HTTP service, etc. Also, add a service for anything else that's not referentially transparent, such as clocks and random number generators. For the example restaurant reservation system, the greatest arity I needed was 5: Container<T1, T2, T3, T4, T5>.
  • You don't need a container for each arity after all, as the next article will demonstrate.
  • This is all pointless anyway, as already predicted in the introduction article.
For now, then, I'll continue with the example. There are five generic container classes, as well as the empty one (which really is redundant, but so is all of this).

Usage #

You can create a statically typed container by registering all the required services:

var container = Container.Empty
    .Register(Configuration)
    .Register(CompositionRoot.CreateRestaurantDatabase)
    .Register(CompositionRoot.CreatePostOffice)
    .Register(CompositionRoot.CreateClock())
    .Register(
        (c_po__) => CompositionRoot.CreateRepository(c, po));

The container object has the type Container<IConfiguration, IRestaurantDatabase, IPostOffice, IClock, IReservationsRepository> , which is quite a mouthful. You can, however, pass it to the CompositionRoot and register it as the application's IControllerActivator:

var compositionRoot = new CompositionRoot(container);
services.AddSingleton<IControllerActivator>(compositionRoot);

CompositionRoot.Create uses the injected container to create Controllers:

public object Create(ControllerContext context)
{
    if (context is null)
        throw new ArgumentNullException(nameof(context));
 
    var t = context.ActionDescriptor.ControllerTypeInfo;
 
    if (t == typeof(CalendarController))
        return new CalendarController(
            container.Item2,
            container.Item5);
    else if (t == typeof(HomeController))
        return new HomeController(container.Item2);
    else if (t == typeof(ReservationsController))
        return new ReservationsController(
            container.Item4,
            container.Item2,
            container.Item5);
    else if (t == typeof(RestaurantsController))
        return new RestaurantsController(container.Item2);
    else if (t == typeof(ScheduleController))
        return new ScheduleController(
            container.Item2,
            container.Item5,
            AccessControlList.FromUser(context.HttpContext.User));
    else
        throw new ArgumentException(
            $"Unexpected controller type: {t}.",
            nameof(context));
}

Having to refer to Item2, Item5, etc. instead of named services leaves better readability to be desired, but in the end, it doesn't even matter, because, as you'll see as this article series progresses, this is all moot.

Conclusion #

You can define a type-safe DI Container with a series of generic containers. Each registered service has a generic type, so if you need a single IFoo, you register a single IFoo object. If you need a collection of IBar objects, you register an IReadOnlyCollection<IBar>, and so on. This means that you don't have to waste brain capacity remembering the configuration of all services.

Compared to the initial Haskell prototype, the C# example shown here doesn't prevent you from registering the same type more than once. I don't know of a way to do this at the type level in C#, and while you could make a run-time check to prevent this, I didn't implement it. After all, as this article series will demonstrate, none of this is particularly useful, because Pure Di is simpler without being less powerful.

If you were concerned about the proliferation of generic classes with increasing type argument arity, then rest assured that this isn't a problem. The next article in the series will demonstrate how to get around that issue.

Next: Nested type-safe DI Containers.



Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.

Published

Monday, 31 January 2022 06:42:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!
Published: Monday, 31 January 2022 06:42:00 UTC