Nested type-safe DI Containers by Mark Seemann
How to address the arity problem with type-safe DI Container prototypes.
This article is part of a series called Type-safe DI composition. In the previous article, you saw a C# example of a type-safe DI Container. In case it's not clear from the article that introduces the series, there's really no point to any of this. My motivation for writing the article is that readers sometimes ask me about topics such as DI Containers versus type safety, or DI Containers in functional programming. The goal of these articles is to make it painfully clear why I find such ideas moot.
N+1 arity #
The previous article suggested a series of generic containers in order to support type-safe Dependency Injection (DI) composition. For example, to support five services, you need five generic containers:
public sealed class Container public sealed class Container<T1> public sealed class Container<T1, T2> public sealed class Container<T1, T2, T3> public sealed class Container<T1, T2, T3, T4> public sealed class Container<T1, T2, T3, T4, T5>
As the above listing suggests, there's also an (effectively redundant) empty, non-generic Container
class. Thus, in order to support five services, you need 5 + 1 = 6 Container
classes. In order to support ten services, you'll need eleven classes, and so on.
While these classes are all boilerplate and completely generic, you may still consider this a design flaw. If so, there's a workaround.
Nested containers #
The key to avoid the n + 1 arity problem is to nest the containers. First, we can delete Container<T1, T2, T3>
, Container<T1, T2, T3, T4>
, and Container<T1, T2, T3, T4, T5>
, while leaving Container
and Container<T1>
alone.
Container<T1, T2>
needs a few changes to its Register
methods.
public Container<T1, Container<T2, T3>> Register<T3>(T3 item3) { return new Container<T1, Container<T2, T3>>( Item1, new Container<T2, T3>( Item2, item3)); }
Instead of returning a Container<T1, T2, T3>
, this version of Register
returns a Container<T1, Container<T2, T3>>
. Notice how Item2
is a new container. A Container<T2, T3>
is nested inside an outer container whose Item1
remains of the type T1
, but whose Item2
is of the type Container<T2, T3>
.
The other Register
overload follows suit:
public Container<T1, Container<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); }
The only change to this method, compared to the previous article, is to the return type.
Usage #
Since the input parameter types didn't change, composition still looks much the same:
var container = Container.Empty .Register(Configuration) .Register(CompositionRoot.CreateRestaurantDatabase) .Register(CompositionRoot.CreatePostOffice) .Register(CompositionRoot.CreateClock()) .Register((conf, cont) => CompositionRoot.CreateRepository(conf, cont.Item1.Item2)); var compositionRoot = new CompositionRoot(container); services.AddSingleton<IControllerActivator>(compositionRoot);
Only the last Register
call is different. Instead of a lambda expression taking four arguments ((c, _, po, __)
), this one only takes two: (conf, cont)
. conf
is an IConfiguration
object, while cont
is a nested container of the type Container<Container<IRestaurantDatabase, IPostOffice>, IClock>
.
Recall that the CreateRepository
method has this signature:
public static IReservationsRepository CreateRepository( IConfiguration configuration, IPostOffice postOffice)
In order to produce the required IPostOffice
object, the lambda expression must first read Item1
, which has the type Container<IRestaurantDatabase, IPostOffice>
. It can then read that container's Item2
to get the IPostOffice
.
Not particularly readable, but type-safe.
The entire container
object passed into CompositionRoot
has the type Container<IConfiguration, Container<Container<Container<IRestaurantDatabase, IPostOffice>, IClock>, IReservationsRepository>>
.
Equivalently, the CompositionRoot
's Create
method has to train-wreck its way to each service:
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.Item1.Item1.Item1, container.Item2.Item2); else if (t == typeof(HomeController)) return new HomeController(container.Item2.Item1.Item1.Item1); else if (t == typeof(ReservationsController)) return new ReservationsController( container.Item2.Item1.Item2, container.Item2.Item1.Item1.Item1, container.Item2.Item2); else if (t == typeof(RestaurantsController)) return new RestaurantsController(container.Item2.Item1.Item1.Item1); else if (t == typeof(ScheduleController)) return new ScheduleController( container.Item2.Item1.Item1.Item1, container.Item2.Item2, AccessControlList.FromUser(context.HttpContext.User)); else throw new ArgumentException( $"Unexpected controller type: {t}.", nameof(context)); }
Notice how most of the services depend on container.Item2.Item1.Item1.Item1
. If you hover over that code in an IDE, you'll see that this is an IRestaurantDatabase
service. Again, type-safe, but hardly readable.
Conclusion #
You can address the n + 1 arity problem by nesting generic containers inside each other. How did I think of this solution? And can we simplify things even more?
Read on.