In this cookbook we'll be building a simple Dependency Injection (DI) container from scratch. We'll start simple and you can take it as far as you want.
The topics are:
- Registering services (Beginner)
- Resolving services (Beginner)
- Handling lifetimes of services (Intermediate)
- Supporting nested dependencies (Intermediate)
- Refactor App-project to use our DI-container (Intermediate)
- Dispose of services when scope ends (Advanced)
- Handling circular dependencies (Advanced)
- Automating the registration of services (Advanced)
We'll be building the DI-container in a test-driven manner, and the tests are already set up for you in the DependencyInjection.Tests
-project.
In the fifth topic we'll refactor the App
-project to use our DI-container, which will not use the unit tests, but rather manual testing of the app.
If you get stuck at any point you can review the solution
-branch to get some hints. But I recommend you try to solve it yourself first!
In this section we'll implement the Add
-method in the ServiceCollection
-class. This method should be able to register services with the DI-container.
The ServiceCollection
-class is in essence a container for ServiceDescriptor
-objects. A ServiceDescriptor
-object contains information about a service, such as the service type, the implementation type and the lifetime of the service.
This type is used when configuring the application, before the application starts.
Work with the tests in the AddServiceTests
-class to check if the implementation is correct.
In this section we'll implement the GetService
- and GetServices
-methods in the ServiceProvider
-class. These methods should be able to resolve services from the DI-container. In addition we will have to implement the BuildServiceProvider
-method in the ServiceCollection
-class in order to get our ServiceProvider
.
The ServiceProvider
-class is typically used after the application has started to resolve services. You normally do not use this directly, but it is used by the application host.
Work with the tests in the GetServiceTests
-class to check if the implementation is correct.
- You can use the
Activator.CreateInstance
-method to create an instance of a type. - Unsure of the difference between
Transient
,Scoped
andSingleton
services? It doesn't matter for this section. We'll cover that in the next section. For now, you can just return a new instance every time.
In this section we'll handle the lifetimes of services when resolving them with the ServiceProvider
.
The lifetimes of services are typically one of three types:
- Transient: A new instance is created every time the service is resolved.
- Scoped: A new instance is created once per scope. In a web application, a scope is typically a single HTTP request.
- Singleton: A single instance is created and shared throughout the application.
This means we will have to implement the CreateScope
-method in the ServiceProvider
-class, and create a new ServiceProvider
in the ServiceScope
-class (ignore the Dispose
-method for now).
Also the resolving of services in the ServiceProvider
-class should be handled differently depending on the lifetime of the service. Work with the tests in the LifetimeTests
-class to check if the implementation is correct.
- Send in the
ServiceDescriptor
-list to theServiceScope
-class, so it can construct a newServiceProvider
with the correct services definitions. - A
Singleton
service should only be created once, and the same instance should be returned every time it is resolved. Even acrossServiceScope
s! Maybe you can extend theServiceDescriptor
-class with a property to store the instance? - A
Scoped
service should be created once perServiceScope
, and not shared between differentServiceScope
s. Should it be cached somehow in eachServiceProvider
?
In this section we'll support nested dependencies when resolving services with the ServiceProvider
. This means that a service can have dependencies on other services, which in turn can have dependencies on other services, and so on.
In order to do this, you'll have to find out which parameters a constructor of a service has, and resolve those services before creating the service.
In other words, you must recursively resolve dependencies when resolving a service. Work with the tests in the NestedDependenciesTests
-class to check if the implementation is correct.
- Use the
GetConstructors
-method on theType
-class to get the constructors of a type. To make it simple, you can assume that the first constructor is the one to use:var constructor = descriptor.ImplementationType.GetConstructors().First();
- Call
GetParameters
on theConstructorInfo
-class to get the parameters of the constructor:var parameters = constructor.GetParameters();
. TheParameterType
-property on theParameterInfo
-class will give you the of the nested dependency. - Recursively calling
GetService
-method is probably a good idea here:var nestedDependencies = constructor.GetParameters().Select(p => GetService(p.ParameterType));
- The
Activator.CreateInstance
-method accepts an array of objects to use as parameters for the constructor:Activator.CreateInstance(descriptor.ImplementationType, nestedDependencies.ToArray());
Now that we have a working DI-container, it's time to refactor the App
-project to use it.
Add a ServiceCollection
-property to the ScreenHostBuilder
-class, and use it to register the screens as services in the AddScreens
-method: Services.AddTransient<IScreen, AboutScreen>();
.
Also modify Program.cs
in the App
-project to use the builder.Services
to register the IDb
and ITodoRepository
dependencies that the screens need.
Lastly, modify the Build
-method in ScreenHostBuilder
to use the give the ScreenProvider
-class a ServiceProvider
instead, and modify the ScreenProvider
-class to dynamically resolve the screens from the ServiceProvider
.
Run the application to see if it works as before - but now with the DI-container!
- You have to add a dependency to the
DependencyInjection
-project from theApp
-project in order to use theServiceCollection
andServiceProvider
classes. - You should not have to change the
ScreenHost
-class at all. TheScreenProvider
-class should be the only class that uses theServiceProvider
. - How you register the services in the
ServiceCollection
is up to you, but the lifetime you choose will change the behavior of the application. For example, if you register theIDb
as aSingleton
, the same database-instance will be used throughout the application. Play around with the lifetimes of the screens or their dependencies to see how it affects the application.
In this section you will implement the Dispose
-method in the ServiceScope
-class. This method should dispose of all services that are IDisposable
when the scope ends.
Work with the tests in the ScopedDisposalTests
-class to check if the implementation is correct.
This is an advanced topic, and you can skip it if you want. No hints!
In this section you will handle circular dependencies when resolving services with the ServiceProvider
. This means that a service can have a dependency on another service, which in turn has a dependency on the first service.
Work with the tests in the CircularDependencyTests
-class to check if the implementation is correct. The test expects that a InvalidOperationException
-exception is thrown when a circular dependency is detected.
This is an advanced topic, and you can skip it if you want.
- A simple way to detect circular dependencies is to keep track of the services that are currently being resolved. If a service is being resolved that is already in the list, you have a circular dependency. This might require quite a bit of refactoring of the
ServiceProvider
-class. - A
HashSet<ServiceDescriptor>
might be a good data structure to use to keep track of the services that are currently being resolved.
A lot of libraries (like for example Mediatr
) can do assembly scanning to automatically register services in the DI-container. We can do the same by modifying the AddScreens
-method in the ScreenHostBuilder
-class to scan the App
-project for types that implement IScreen
and register them as services.
This is a more advanced topic, and you can skip it if you want.
- You can get the screen types from the current assembly with:
var screenTypes = typeof(IScreen).Assembly.GetTypes().Where(t => typeof(IScreen).IsAssignableFrom(t) && !t.IsInterface).ToList();
Congratulations! You have now built a simple Dependency Injection container from scratch. If you get stuck at any point you can review the solution
-branch to get some hints. I'm not saying this is the correct or performant way to go about, but I'm hoping you've learnt that DI is not magic. It's just code.