Dependency injection (DI) is a fundamental concept in software development that promotes loose coupling and enhances the maintainability and testability of code. It is an essential aspect of building scalable and extensible applications.
At its core, is a design pattern that allows you to provide the dependencies required by a class from external sources rather than having the class create them internally. Instead of tightly coupling classes together by instantiating their dependencies directly, dependency injection enables you to “inject” those dependencies into classes from the outside.
Another major benefit of dependency injection is improved testability. By injecting dependencies, you can substitute real implementations with mock objects or stubs during unit testing. This allows you to isolate the code being tested and focus on its behavior without worrying about the functionality of its dependencies. As a result, unit tests become more reliable, easier to write, and faster to execute.
In the context of .NET, the framework provides a built-in dependency injection framework that simplifies the implementation of DI in your applications, you can also use open-source solutions for more features and tools, like Autofac, Lamar, Ninject, and many others ones.
but in this article we will be focusing on using the default implementation that comes with the .NET, using the “service container” wish is available as part of the Microsoft.Extensions.DependencyInjection namespace.
How Dependency Injection (DI) works?
for us to better understand how Dependency Injection works, let’s first define the key elements that make the process:
- Dependency definition:
In your application, you will have classes that rely on other classes or services to perform their tasks. These dependent classes are known as dependencies. For example, aUserService
class may depend on aUserRepository
to retrieve user data from a database. - Dependency injection container:
A DI container (also known as an inversion of control container) is responsible for managing the dependencies of your application. It acts as a central repository for creating and providing instances of these dependencies. - Dependency Registration:
To use DI, you need to register your dependencies with the DI container. This step involves informing the container about the dependencies and how they should be created. In our example, you would register theUserRepository
as a dependency of theUserService
.
there is another concept here called Registration Scope, but we will talk about it in more detail later. - Dependency resolution:
When a class needs its dependencies, instead of creating them directly, it asks the DI container to provide them. This process is called dependency resolution. The DI container examines the registered dependencies and uses that information to create and provide the required instances. - Dependency injection:
Once the dependencies are resolved, they are injected into the dependent class. This means that the dependent class receives the instances of its dependencies through constructor parameters, property assignments, or method calls.
Now, how does it work internally?
the DI framework manages the creation and resolution of dependencies in a series of steps.
First, dependencies are registered with the DI container, providing information on how they should be created and shared. When a class is requested from the container, it examines the dependencies of that class and looks at the registration information of the dependencies, and proceeds to create a new instance or retrieve a pre-existing one. If a dependency has its own dependencies, the container recursively resolves and injects them, Once the dependency is created, the container injects it into the requesting class, whether through constructor parameters, property setters, or method parameters.
in simple words we use DI containers to manage and create dependencies instances automatically and if a dependency also has dependencies they also going to be created recursively.
Dependency Registration and Registration Scope
The most important step in using DI is to inform the DI container about your different services, and how they should be created and managed.
when we register our services in the DI container, we have to specify the lifetime of each service it is called “Registration Scope”, and in general there are 3 types of scopes:
- Transient: A transient lifetime means that the DI container creates a new instance of the dependency each time it is requested. Transient dependencies are not shared among different parts of the application and have a short lifespan.
- Scoped: Scoped dependencies are created once per scope. A scope is defined as a specific context, such as a web request or an operation within a unit of work. Within the same scope, the DI container provides the same instance of the scoped dependency. Different scopes, however, may have different instances.
- Singleton: Singleton dependencies are created only once and shared throughout the entire lifespan of the application. The DI container always returns the same instance of a singleton dependency whenever it is requested.
It’s important to consider the consumption of services based on their lifetime when working with Dependency Injection. The lifetime of a service determines the scope of its dependencies.
Here’s an overview of the dependencies that services can rely on based on their registration lifetime:
- Transient Lifetime: Services registered as transient can depend on any other service regardless of its lifetime. They have the flexibility to depend on services registered as transient, scoped, or singleton.
- Scoped Lifetime: Services registered as scoped have a narrower scope and can only depend on services registered as scoped or singleton. Scoped services are typically used within specific contexts, such as web requests, and they should rely on dependencies that have a similar lifespan. This helps maintain consistency and isolation within the defined scope.
- Singleton Lifetime: Services registered as singletons have a global scope and are shared throughout the entire application. Singleton services can only depend on other services registered as singletons. This restriction ensures that singletons have predictable behavior and avoid dependencies with shorter lifetimes that might cause unexpected state changes.
so, how we register the services in the .NET built in DI framework?
When using the built-in DI framework in .NET, registering services is straightforward. Depending on the type of application you are building, there are different ways to accomplish this.
for example, if you are using ASP.NET Core with a startup class, you can register your services in the ConfigureServices()
method. you will do it like the following:
public class Startup
{
public void ConfigureService(IServiceCollection services)
{
services.AddTransient<YourService1>();
services.AddScoped<YourService2>();
services.AddSingleton<YourService3>();
}
}
In this code snippet, the IServiceCollection
parameter is used to register services with different lifetimes. You can use the AddTransient()
, AddScoped()
, or AddSingleton()
extension methods to specify the desired lifetime for each service.
but what if you want to build the DI container from scratch, it is surprisingly simple. First, you construct an instance of ServiceCollection
, register your services using the provided extension methods, and then build the DI container by calling BuildServiceProvider()
. Here’s an example:
using Microsoft.Extensions.DependencyInjection;
// register services with ServiceCollection
IServiceCollection servicesCollection= new ServiceCollection()
.AddTransient<YourService1>()
.AddScoped<YourService2>()
.AddSingleton<YourService3>();
// build the DI container
IServiceProvider container = servicesCollection.BuildServiceProvider();
To summarize, when using the built-in DI framework, there are two important components to work with:
IServiceCollection
: This interface is used to register services with their desired lifetimes.IServiceProvider
: This interface represents the DI container, which holds the definitions of the registered services and is used to resolve them when needed.
Resolving Dependencies
When working with a pre-configured environment like ASP.NET Core or Maui, much of the dependency injection process is handled for you. Let’s focus on the example of ASP.NET Core. In this framework, when you have a controller that requires a dependency, you can simply specify the required dependencies in the constructor of the controller, and they will be injected using constructor injection. Here’s an explanation of how this process works within ASP.NET Core.
Suppose a request arrives at the application, and based on the routing configuration, it is determined that the route “/index” should be handled by the HomeController
. The ASP.NET Core framework utilizes the dependency injection (DI) system to resolve an instance of the HomeController
to handle the request.
Before creating the HomeController
instance, the framework establishes a scope that defines the HTTP request context. This scope ensures that any services resolved within the scope are unique to the request, allowing for proper isolation and disposal of resources when the request is completed.
public class Startup
{
public void ConfigureService(IServiceCollection services)
{
// calling this action will register you controllers in DI (and many other things)
services.AddControllers();
}
}
now that the required dependencies are found and can be resolved, the DI container creates an instance of the HomeController and injects the resolved dependencies into its constructor. The controller is now ready to handle the incoming request, utilizing the injected dependencies to perform the necessary actions and produce the response.
DI & Interfaces
Interfaces play a crucial role in the context of Dependency Injection (DI). They facilitate loose coupling and abstraction, making it easier to swap implementations and promote testability. Let’s dive into the usage of interfaces in the context of DI.
1- Defining & Implementing Interfaces:
When designing our classes, we start by defining interfaces that represent the contracts to which the classes will adhere. These interfaces act as blueprints for the functionalities your classes should implement.
For example, if you have a service that interacts with a database, you might create an interface like IUserRepository with methods for retrieving and storing user data.
public interface IUserRepository
{
User GetUserById(int userId);
void SaveUser(User user);
}
Classes that provide the actual implementations of these interfaces should adhere to the defined contracts. For instance, you may have a class SqlUserRepository
that implements IUserRepository
and interacts with a SQL database.
public class SqlUserRepository : IUserRepository
{
public User GetUserById(int userId)
{
// Implementation for retrieving user from SQL database
}
public void SaveUser(User user)
{
// Implementation for saving user to SQL database
}
}
2- DI registration
now instead of registering the services directly in the DI container, you will register them against their interfaces.
services.AddScoped<IUserRepository, SqlUserRepository>();
This will allow you to easily switch implementations by changing the registration. The rest of the application code, depending on the IUserRepository
interface remains unchanged.
3- Usage
now that we have registered our dependencies against their interfaces, we will be using the contract instead of the concrete implementation.
public class UserService
{
private readonly IUserRepository _userRepository;
public UserService(IUserRepository userRepository)
=> _userRepository = userRepository;
// Rest of the UserService implementation
}
Here, the UserService
class depends on the IUserRepository
interface, allowing different implementations to be injected during the application’s lifetime.
Mocking
mocking is another concept that plays well with DI, combined with the usage of interfaces you get the ultimate testing experience. when you want to test a service, let’s take for example the above UserService class that depends on the IUserRepository interface. With DI, you can easily substitute the actual database-dependent implementation with a mock implementation during testing. This allows you to isolate the unit under test and verify its behavior without concerns about the real database interactions.
public class MockUserRepository : IUserRepository
{
// Mock implementation for testing
}
public class UserServiceTests
{
[Fact]
public void UserService_Should_SaveUser_When_Called()
{
// Arrange
var mockUserRepository = new MockUserRepository();
var userService = new UserService(mockUserRepository);
// Act
// the test logic
// Assert
// the test assertion
}
}
While manually creating concrete mock implementations can be time-consuming, especially as your codebase grows in complexity, there exists a more efficient and scalable approach, and that is to use mocking libraries such as NSubstitute, they offer a flexible and fluent approach for generating mock objects, the usage of these libraries is out of the scope of this article, but you can find plenty of resources on this subject, here is a simple example of using NSubstitute
public class UserServiceTests
{
[Fact]
public void UserService_Should_SaveUser_When_Called()
{
// Arrange
// NSubstitute example for mocking IUserRepository
var userRepositoryMock = Substitute.For<IUserRepository>();
// Configuring mock behavior
userRepositoryMock.GetUserById(1).Returns(new User { Id = 1, Name = "Test User" });
// Using the mock in your service during testing
var userService = new UserService(userRepositoryMock);
// Act
// the test logic
// Assert
// the test assertion
}
}
Troubleshooting
When you use Dependency Injection (DI) in your project, you might encounter a few common problems as you develop your software. From my experience, three errors tend to pop up more frequently.
1. Dependency Resolution Failures:
Sometimes, the DI container may fail to resolve dependencies correctly, for example, you may inject a dependency in one of your services and you will see an error stating that the system failed to resolve the required dependency.
once you see this error it means you have not configured your dependencies registration correctly, check where the error is occurring and also which dependencies it failed to resolve, then check if you have registered that dependency correctly in the DI container, as you can see on this exception message:
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
An unhandled exception has occurred while executing the request.
System.InvalidOperationException: No service for type ‘IService1’ has been registered.
at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
at lambda_method1(Closure, Object, HttpContext)
at Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
it says that the service “IService1” is not registered, so all we need to do is add it to the DI container.
2. Circular Dependency:
this one is a tricky one, to understand the issue, check this example:
public interface IService1 {}
public class Service1(IService2 service) : IService1 {}
public interface IService2 {}
public class Service2(IService1 service) : IService2 {}
as you can see here Service1 depends on Service2, and Service2 depends on Service1, so when resolving Service2 to construct an instance of Service1, we need an instance of Service1 and we go in a loop, and here where the term “Circular Dependencies” comes from, we go in a circle to resolve the dependencies, usually you will see an exception message like this when having this issue:
System.AggregateException: ‘Some services are not able to be constructed (Error while validating the service descriptor ‘ServiceType: IService1 Lifetime: Scoped ImplementationType: Service1’: A circular dependency was detected for the service of type ‘IService1’.
IService1(Service1) -> IService2(Service2) -> IService1) (Error while validating the service descriptor ‘ServiceType: IService2 Lifetime: Scoped ImplementationType: Service2’: A circular dependency was detected for the service of type ‘IService2’.
IService2(Service2) -> IService1(Service1) -> IService2)’
3. Lifetime Management Issues
the lifetime management issue occurs when you have dependencies not correctly configured based on the registration scope, such as having a scoped or singleton service depending on a transient service, check the “Dependency Registration and Registration Scope” section for more details.
Conclusion
Dependency Injection (DI) stands as a cornerstone principle in modern software development, offering invaluable benefits in terms of code maintainability, testability, and scalability. it should be part of your toolkit as a developer, and you should continue pursuing the knowledge on this subject learning about best practices and how to use open-source solutions as an alternative to the default one.