Dependency Injection
For modern applications, various methods have been created to simplify the implementation of an application from scratch. Even though there are many libraries that facilitate writing applications for different use cases, the challenge lies in application construction and control. In procedural programming, when writing code, it calls other code that can reside in libraries or be exposed by a framework, dictating both the program's control flow and its construction. This approach is problematic as it imposes on the program the responsibility of determining the control flow of different components based on their implementation details, rather than using an abstraction to make the code more reusable and easier to follow.
There are several concepts to discuss here. Let's detail what we desire: we want the program we write to be modular with easily reusable modules. Thus, we aim to give control of the program to the framework, and through the abstractions we provide, the framework calls the code we've written. This design pattern is called Inversion of Control (IoC). This technique is used in both graphical user interface programming and web server development, and there are pre-implemented frameworks that facilitate this process.
IoC is often used in conjunction with another concept: the Dependency Inversion Principle (DIP). This principle states that if we have high-level modules, they shouldn't be aware of details from lower-level modules. Both should depend on abstractions, not implementation details. This leads to the inversion of dependencies: implementations depend on abstractions, not the other way around. Instead of having a high-level generic (abstract) implementation of a program that depends on the implementation details of its components, the components depend on abstractions. As an example, rather than defining a class A at a higher level that depends on class B at a lower level, we abstract both using interfaces. Where class A requires interaction with class B, we achieve this through an interface, not an implementation. This approach is beneficial, particularly when tests are involved. Instead of using specific implementations for components, such as calls to a web service, we can create mock components that substitute the actual implementation while maintaining the same abstraction (interface).
These concepts are essential to understand to reach one of the most commonly used concepts: Dependency Injection (DI). The concept itself is straightforward: when logic is encapsulated within a class, it may need references to other classes, i.e., it has dependencies, to perform various actions. Dependencies can be passed as parameters in a constructor or assigned as properties. Instead of explicitly instantiating each constructor parameter or class property, we use a DI framework. We only declare which components need to be instantiated at runtime, and the framework handles the recursive resolution of each dependency when a component is requested.
In practice, DI achieves IoC by allowing the framework to control the instantiation and invocation of components. Dependency inversion is also crucial because we don't inject implementations directly. Each dependency should ideally be an interface that is implemented by a concrete class, thereby hiding the implementation details of a component from another. As an example, if we have a Service class and a Repository class, and the Service requires data provided by the Repository, we create the IService and IRepository interfaces, which are then implemented by these classes. In the Service class constructor, we add a parameter of type IRepository.
In certain frameworks like Java Spring, DI via properties is allowed, but it's not recommended. In C#, it's avoided entirely and only done through constructors. The reason is that, with constructors, we know the class hasn't been initialized before and there's no gap between class initialization and populating it with dependencies, which could lead to potential errors. If the class were first initialized and then populated with dependencies, there would be a gap between these two events managed by the framework and unknown to the programmer. Errors could easily slip in without awareness of this gap. Additionally, cyclic dependencies are not allowed. If an interface A's implementation recursively requires the same interface A, the implementation can't be built, as it would result in infinite recursion and cause the program to crash due to stack exhaustion.
With DI, the question of how long component instances can be used arises – that is, what is their lifetime. Generally, there are three lifetimes when referring to how long these components can live during the execution of a instantiation request.
- Transient: With each request, the class is instantiated anew, including resolving each dependency within a request. This lifetime is typically used for objects where the instantiation cost is low or where they need to be destroyed after each use.
- Scoped: With each request, the resolved dependency reference remains the same; other scoped references can be created when a new scope is created. This lifetime is used in certain situations, especially when dealing with communication sessions with remote processes like databases.
- Singleton: The instance is created on the first request where this dependency is resolved, and that reference is used wherever an instance of that type is needed. If the instantiated object is stateless, using this lifetime class is a good choice to avoid instantiation cost and because concurrent usage of that class is not an issue.
Although instances with different lifetimes can be combined, singleton instances should have singleton dependencies, scoped instances should have singleton or scoped dependencies, and transient instances can have any type of dependency to maintain consistent lifetimes for each dependency.
It's important to note that component instantiation occurs within a host object that handles all instantiation and keeps track of dependencies. To illustrate how DI works, let's provide an example of a minimal DI implementation. Keep in mind that in practice, more efficient and well-tested standard implementations are used.
// Using this enum to specify lifetimes for components.
public enum LifetimeEnum
{
Transient,
Scoped,
Singleton
}
// Declaring an interface for the scope that is also disposable.
public interface IServiceProvider : IDisposable
{
// This method is for injecting a component from the Assembly using attributes.
public ServiceProvider AddInjected();
// These methods are for registering components with different lifetimes, to be declared as interfaces with implementations.
public ServiceProvider AddTransient<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
public ServiceProvider AddScoped<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
public ServiceProvider AddSingleton<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
// When needed, use this method to create a new scope for a request.
public IServiceScope CreateScope();
// These methods are for creating an instance of a component.
public object GetService(Type type);
public T GetService<T>() where T : class;
}
// Declaring an interface for the scope that is also disposable.
public interface IServiceScope : IDisposable
{
public object GetService(Type type);
public T GetService<T>() where T : class;
}
/* To use attributes, we must inherit from Attribute and add an attribute to indicate where this attribute can be used,
* in this case, it's used on a class.
*/
[AttributeUsage(AttributeTargets.Class)]
public class InjectableAttribute : Attribute
{
public LifetimeEnum Lifetime { get; }
public InjectableAttribute(LifetimeEnum lifetime) => Lifetime = lifetime;
}
// Implementing an IServiceProvider for dependency injection.
public sealed class ServiceProvider : IServiceProvider
{
// This field is used to avoid multiple recursion during Dispose().
private bool _isDisposed;
// Keeping track of registered services, with type as key and a tuple of implementation and lifetime.
private readonly Dictionary<Type, (Type, LifetimeEnum)> _services = new();
// Keeping track of singleton instances here.
private readonly Dictionary<Type, object> _singletons = new();
public ServiceProvider()
{
// Adding the default provider as a singleton to create components on demand.
_services.Add(typeof(IServiceProvider), (typeof(ServiceProvider), LifetimeEnum.Singleton));
_singletons.Add(typeof(IServiceProvider), this);
}
// Using this method, we register an interface with an implementation and a lifetime.
public ServiceProvider Add<TInterface, TImplementation>(LifetimeEnum lifetime) where TInterface : class where TImplementation : class, TInterface
{
// Creating Type instances using typeof, which encapsulates type information, an example of reflection.
var interfaceType = typeof(TInterface);
var implementationType = typeof(TImplementation);
// Checking if the type is an interface.
if (!interfaceType.IsInterface)
{
throw new ArgumentException($"{interfaceType.Name} is not an interface!");
}
// Or it's a class.
if (!implementationType.IsClass)
{
throw new ArgumentException($"{implementationType.Name} is not a concrete class!");
}
_services.Add(interfaceType, (implementationType, lifetime));
// For method chaining, we return this.
return this;
}
public ServiceProvider AddTransient<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Transient);
public ServiceProvider AddScoped<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Scoped);
public ServiceProvider AddSingleton<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Singleton);
// Searching the assembly for registered types.
public ServiceProvider AddInjected()
{
foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
{
// Extracting custom attributes and adding types with their lifetimes.
var attribute = type.GetCustomAttributes(typeof(InjectableAttribute)).FirstOrDefault();
if (attribute is InjectableAttribute injectableAttribute)
{
_services.Add(type, (type, injectableAttribute.Lifetime));
}
}
return this;
}
// When traversing the dependency chain, we need to keep track of traversed types and scoped objects.
private object GetService(Type type, IReadOnlySet<Type> traversedTypes, IDictionary<Type, object> scopedObjects)
{
// If we're requesting a type that we've already traversed, there's a cyclic dependency, so throw an exception.
if (traversedTypes.Contains(type))
{
throw new ArgumentException($"Cyclic dependency detected, cannot construct type {traversedTypes.FirstOrDefault()}!");
}
// If the type is not registered, throw an exception.
if (!_services.ContainsKey(type))
{
throw new ArgumentException($"Cannot construct type {traversedTypes.FirstOrDefault()}, {type.Name} was not registered!");
}
var (serviceType, lifetime) = _services[type];
// If the type is singleton and has been instantiated, return it.
if (lifetime == LifetimeEnum.Singleton)
{
if (_singletons.TryGetValue(type, out var value))
{
return value;
}
}
// If the type is scoped, check the list for scoped instances and return it if it exists.
if (lifetime == LifetimeEnum.Scoped)
{
if (scopedObjects.TryGetValue(type, out var value))
{
return value;
}
}
// Extract constructors for instantiation.
var constructors = serviceType.GetConstructors();
// If there's more than one constructor, throw an error.
if (constructors.Length > 1)
{
throw new ArgumentException($"{type.Name} has more than one constructor!");
}
// For the found constructor, extract parameter types and recursively create instances to call the constructor.
var constructorParameters = constructors[0].GetParameters()
.Select(e => GetService(e.ParameterType, traversedTypes.Concat(new List<Type> { type }).ToHashSet(), scopedObjects))
.ToArray();
// After obtaining constructor parameters, use reflection to invoke the constructor and create an instance.
var service = constructors[0].Invoke(constructorParameters);
// Based on the lifetime, store the instance appropriately.
switch (lifetime)
{
case LifetimeEnum.Singleton:
_singletons.Add(type, service);
break;
case LifetimeEnum.Scoped:
scopedObjects.Add(type, service);
break;
case LifetimeEnum.Transient:
default:
break;
}
return service;
}
// If there are IDisposable objects for scoped instances, create a scope to dispose of them properly.
public object GetService(Type type)
{
using var scope = CreateScope();
return scope.GetService(type);
}
public T GetService<T>() where T : class => (T) GetService(typeof(T));
// Create a scope linked to the current provider.
public IServiceScope CreateScope() => new ServiceScope(this);
// Dispose of other IDisposable objects during dispose.
public void Dispose()
{
// Avoid infinite recursion during Dispose.
if (_isDisposed)
{
return;
}
_isDisposed = true;
// Dispose of all singleton instances that are IDisposable.
foreach (var (_, instance) in _singletons)
{
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
}
// Nested private class to hide the implementation of IServiceScope.
private sealed class ServiceScope : IServiceScope
{
private readonly ServiceProvider _serviceProvider;
private readonly Dictionary<Type, object> _scoped = new();
public ServiceScope(ServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
public object GetService(Type type) => _serviceProvider.GetService(type, new HashSet<Type>(), _scoped);
public T GetService<T>() where T : class => (T) GetService(typeof(T));
public void Dispose()
{
foreach (var (_, instance) in _scoped)
{
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
}
/* We can use the new attribute on a class with information provided in the constructor.
* Some components can be injected without an interface, like here, but generally,
* it's preferred to inject an interface into other components rather than a specific implementation.
*/
[Injectable(LifetimeEnum.Transient)]
public class InjectedService
{
private readonly ISingletonService _singletonService;
private readonly IScopedService _scopedService;
private readonly ITransientService _transientService;
private readonly IServiceProvider _serviceProvider;
private readonly ISecondTransientService _secondTransientService;
// All these interfaces will be injected by the ServiceProvider through the constructor.
public InjectedService(ISingletonService singletonService, IScopedService scopedService,
ITransientService transientService, IServiceProvider serviceProvider, ISecondTransientService secondTransientService)
{
_singletonService = singletonService;
_scopedService = scopedService;
_transientService = transientService;
_serviceProvider = serviceProvider;
_secondTransientService = secondTransientService;
}
public void Log()
{
// ..
}
}
using IServiceProvider serviceProvider = new ServiceProvider();
// Registering services using method chaining. We can inject interfaces with implementations.
serviceProvider.AddSingleton<ISingletonService, SingletonService>()
.AddScoped<IScopedService, ScopedService>()
.AddTransient<ITransientService, TransientService>()
.AddTransient<ISecondTransientService, SecondTransientService>()
// Or inject by searching for attributes on given types.
.AddInjected();
// The class injected by attribute is instantiated on demand here.
var injectedService = serviceProvider.GetService<InjectedService>();