A type-safe DI Container C# example by Mark Seemann
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<T1, T2> { 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(object? obj) { 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.
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.