Singletons with DI Container
04/08/2023
4 minutes
A typical discussion topic about Dependency Injection (DI) Containers is creating singleton classes. Although many discussions come to the conclusion that the singleton pattern can be converted directly to singleton with a DI container, that is not entirely true.
First concern is that many DI containers support creating a child or scoped containers. Singleton registrations are instantiated with different rules based on the contract of the container. In that case it is possible that a child container creates its own singleton instance - but this is generally not the case. Today most containers differentiate scoped registrations from singletons to handle this case.
Second concern is that most DI containers require the user to implement the singleton type with a public constructor. This ensures that the DI container can invoke the constructor. However, it also implies that any user code may just as well instantiate further objects from the type. Even worse, the declaration of the type does not reflect its intended use.
The third concern is that a user can create multiple DI containers in an application. Each container can have its own singletons, as nothing restricts not to register the same (singleton) type with multiple containers.
Generic Registrations
In this post, I will investigate how generic type registrations behave. For the sake of this investigation I will use one of the most common containers, Microsoft.Extensions.DependencyInjection (MEDI).
At the time of writing this post I use .NET 6 and the corresponding version of MEDI. I created a simple interface IAdder<T>
has a single operation DoWork() to add things to the input parameter.
public interface IAdder<T> { T DoWork(T a); }
The actual implementation of this type simply returns the input parameter, it is not a concern of this post. The interface and corresponding implementation is added to the container as a generic, singleton registration:
var services = new ServiceCollection(); services.TryAddSingleton(typeof(IAdder<>), typeof(Adder<>)); var provider = services.BuildServiceProvider();
Next, services are resolved:
var intAdder = provider.GetRequiredService<IAdder<int>>(); var doubleAdder = provider.GetRequiredService<IAdder<double>>(); var myTypeAdder = provider.GetRequiredService<IAdder<MyType>>(); var myLargeTypeAdder = provider.GetRequiredService<IAdder<MyLargeType>>();
One might wonder, are these types of the same instance or not? C# has open and closed generic types. The registration is made with an open generic type, but the resolution happens for closed generic types. This means, that these five types (including the open generic one) are all different types for the CLR. When a resolution is made, we will get different object instances, corresponding to the closed generic type being resolved. Resolving the same type (ie. IAdder<int>
twice) though results getting the same object instance.
var intAdder = provider.GetRequiredService<IAdder<int>>(); var intAdder2 = provider.GetRequiredService<IAdder<int>>(); Console.WriteLine(intAdder == intAdder2);
The code above prints 'True' on the console.
In this sense a singleton registration might be a bit confusing, as it is only singleton when the generic type parameters are closed. While the way it behaves makes sense, the way it is expressed in code requires some after thoughts.
Good to know - JIT
Next, let me observe the generated code for these types. MyType
and MyLargeType
types are a reference types (classes).
I used dumpheap and DumpMT commands in WinDBG to find the address of the generated code. !dumpheap
lists all objects on the heap.
Output of the dumpheap
command:
MT Count TotalSize Class Name 00007fff4ff28430 1 24 System.IO.SyncTextReader 00007fff4ff23548 1 24 Adder`1[[MyLargeType, MEDIGenerics]] 00007fff4ff234b0 1 24 Adder`1[[MyType, MEDIGenerics]] 00007fff4ff232e8 1 24 Adder`1[[System.Double, System.Private.CoreLib]] 00007fff4ff1ee40 1 24 Adder`1[[System.Int32, System.Private.CoreLib]]
Filtering based on the closed generic type, I used the object's address and !DumpMT
command to list the method descriptor table:
!DumpMT -md 00007fff4de5eb38
The address of the JITted code for Adder<MyType>
and Adder<MyLargeType>
:
00007FFF4DDD9DC0 00007fff4de731c8 JIT Adder`1[[System.__Canon, System.Private.CoreLib]].DoWork(System.__Canon) 00007FFF4DDD9DC8 00007fff4de731d8 JIT Adder`1[[System.__Canon, System.Private.CoreLib]]..ctor()
Address of the JITed code for Adder<double>
:
00007FFF4DDD9D98 00007fff4de730d0 JIT Adder`1[[System.Double, System.Private.CoreLib]].DoWork(Double) 00007FFF4DDD9DA0 00007fff4de730e0 JIT Adder`1[[System.Double, System.Private.CoreLib]]..ctor()
Address of the JITed code for Adder<int>
:
00007FFF4DDD6920 00007fff4de5eb18 JIT Adder`1[[System.Int32, System.Private.CoreLib]].DoWork(Int32) 00007FFF4DDD6928 00007fff4de5eb28 JIT Adder`1[[System.Int32, System.Private.CoreLib]]..ctor()
It shows that, while the JITted source code for reference types is the same, for value types we get different code generated.