Click here to Skip to main content
15,391,277 members
Articles / Containers
Article
Posted 18 Jan 2022

Stats

8.9K views
59 downloads
18 bookmarked

ThunderboltIoc: .NET Dependency Injection without Reflection!

Rate me:
Please Sign up or sign in to vote.
4.90/5 (8 votes)
12 Feb 2022MIT16 min read
Introduction and documentation for new ThunderboltIoc framework that achieves DI in .NET without reflection
This article introduces the new ThunderboltIoc framework that achieves dependency injection in .NET without reflection. You will find the documentation as well as a quick-start guide to walk you through the framework.

Introduction

One of the very first IoC frameworks for .NET that has no reflection. An IoC that casts its services before thunder casts its bolts. 😄

The goal is to create a code-generation based IoC container for .NET. Almost all containers today rely on reflection to provide a functioning IoC, which is a costly operation. Regardless of the valued attempts to improve the performance of these containers over the years, these containers would not match the performance of a code-generation based IoC, if that could be achieved. It is quite irritating to witness the cost we pay for trying to clean our code by -among other things- using a reflection-based IoC framework and being able to do nothing about it. The idea is to let the user (the developer) write their code as they please, and let the IoC take care of code generation during compilation to provide a working and performant solution in the target assembly.

I have finally managed to find the time to start working on the solution. Fortunately for me and for the community, I read about source generators in .NET before starting. Having watched the power of source-generators combined with the power of Roslyn, I decided to pick that path over other approaches such as T4 templates and CodeDom for code generation, and I'm glad I made that choice. Mine might not be the first solution to follow this approach, but I like to think of it as the most powerful and flexible one to-date.

Below is a documentation as well as features overview and a quick-start guide to walk you through the framework. You may find the full source code on the GitHub repository.

I'm also open to your suggestions. Feel free to contact me by leaving a message below.

P.S.: The phrase "if that could be achieved" did exist in my original pretext in March 2021. Even though I managed to make this true today, I decided to keep it here as further motivation for the reader.

Table of Contents

  1. Installation
    1. Make sure that you're using C# version 9.0 or later
    2. Implement ThunderboltRegistration as a partial class
  2. Quick Start
    1. Anywhere in your assembly FooAssembly
    2. At your startup code
    3. Using your services
  3. Features Overview
  4. Benchmarks
  5. Service Lifetimes
    1. Singleton
    2. Scoped
    3. Transient
  6. Service registration
    1. Services registered for you by default
    2. Explicit registration
      1. Same service and implementation type
      2. Different service and implementation types
      3. The service has a factory to determine the resulting instance
      4. The service has runtime logic to determine its implementation
    3. Attribute Registration
      1. ThunderboltIncludeAttribute
      2. ThunderboltExcludeAttribute
    4. Regex-based Attribute (convention) Registration
      1. ThunderboltRegexIncludeAttribute
        1. Registering services that they themselves represent their own implementation
        2. Registering services that have different type for their implementation
          1. IFooService: FooService: For the common scenario where we may want to match IFooService with FooService
      2. ThunderboltRegexExcludeAttribute
  7. How to Resolve/Get Your Instances
    1. Obtaining an IThunderboltContainer
    2. Obtaining an IThunderboltScope
  8. Supported Project Types
  9. Known Limitations
  10. Planned for Future Versions
  11. Finally!
  12. History

1. Installation

ThunderboltIoc's installation is a simple as installing the nuget to the target assemblies. No further configuration is needed. For the sake of registering your services, however, you're going to need to implement (i.e., create a class that inherits) ThunderboltRegistration as a partial class in each project where you may want to register services. You may find the package at Nuget.org: using dotnet:

dotnet add package ThunderboltIoc

or using Nuget Package Manager Console:

Install-Package ThunderboltIoc

1.1. Make Sure That You're Using C# Version 9.0 or Later

In each of your projects where ThunderboltIoc is referenced, make sure that the C# version used is 9.0 or later. In your *.csproj add:

XML
<PropertyGroup>
    <LangVersion>9.0</LangVersion>
</PropertyGroup>

Please note that C# 9.0 is supported only in Visual Studio 2022, and Visual Studio 2019 starting from version 16.7.

1.2. Implement ThunderboltRegistration as a partial Class

This step is not needed if you don't have any types/services that you would like to register in this assembly. However, if you do (which is likely), you should create a partial class that implements the ThunderboltRegistration abstract class. More on the registration can be found in the relevant section of this document.

2. Quick Start

After installation, here's a minimal working example:

2.1. Anywhere in your Assembly FooAssembly

C#
public partial class FooThunderboltRegistration : ThunderboltRegistration
{
	protected override void Register(IThunderboltRegistrar reg)
	{
		reg.AddSingleton<BazService>();
		reg.AddScoped<IBarService, BarService>();
		reg.AddTransientFactory<Qux>(() => new Qux());
	}
}

2.2. At Your Startup Code

Where this code may execute before any attempt to resolve/get your services.

C#
ThunderboltActivator.Attach<FooThunderboltRegistration>();

2.3. Using Your Services

The simplemost way to get your services would be as follows:

C#
BazService bazService = ThunderboltActivator.Container.Get<BazService>();

3. Features Overview

  • Achieving dependency injection in .NET without reflection, based on Roslyn source generators, with a simple and intuitive API
  • Being able to register your services with three different lifetimes: Singleton, Scoped and Transient
  • Explicit registration where you instruct the framework to register a particular service
  • The ability to register services while specifying user-defined factories for their creation while maintaining their lifetimes
  • The ability to register services while specifying user-defined logic to determine the type of the service implementation
  • Attribute-based service registration where you can use attributes to register or exclude types
  • Registration by convention where we can register services by naming convention using regular expressions

4. Benchmarks

The src/benchmarks project uses BenchmarkDotNet to conduct measured performance comparison between ThunderboltIoc and the following dependency injection frameworks:

  • Microsoft.Extensions.DepdendencyInjection (on nuget.org) - benchmarks legend: MicrosoftDI The industry-standard dependency injection framework for .NET provided by Microsoft that offers basic set of features at a high performance. It relies on runtime expression compilation for services creation.

  • Grace (on nuget.org) - Known for its wide range of features offered at a superior performance. It depends on System.Reflection.Emit runtime code generation for creating services.

    It is worth mentioning that iOS does not support runtime code generation and therefore Grace's options are limited when it comes to Xamarin apps or client-side apps in general.

  • Autofac (on nuget.org) - Famous and known for its rich features. It uses raw reflection to instantiate services, but that comes at a grievous cost as the benchmarks show.

Despite being new, ThunderboltIoc attempts to combine the best of each of these frameworks and avoid the worst, with an optimum memory usage. The benchmarks run multiple times with different run strategies and measure the performance of each framework in terms of startup and runtime, where startup is what it takes to fully create and configure the container, and runtime is what it takes to get/resolve/locate few services.

I will first share an example run of the benchmarks on my machine, and then few insights on what the numbers mean.

Click to enlarge Click to enlarge

Click to enlarge images

The data above is sorted by the mean column from the fastest to the slowest. The mean however can be (and is) affected by few outliers, which is why I have included baseline ratio and median columns. Some of the medians in the data above provide evidence on the fact their corresponding means are affected by outliers.

However, in order to have a more accurate insight as to which framework performs better in which scenario, we should look at the ratio column. ThunderboltIoc will always have one and in each scenario, the other frameworks will have proportional values where a smaller value means better performance in this scenario and a bigger value means worse performance in that scenario.

For those who might not be familiar with BenchmarksDotNet, the ratio is often confused with final means proportional to each other. That is not true. Instead, in each run, the ratio is calculated and stored for this particular operation, and in the end, the ratio displayed will be the mean of all the ratios calculated. This provides better immunity against outliers.

It is also worth mentioning that the allocated memory for ThunderboltIoc prevails (or equals the minimum) in each scenario.

5. Service Lifetimes

Thunderbolt's service lifetimes are very similar (or in fact, almost identical) to those of Microsoft.Extensions.DependencyInjection that was first introduced with .NET Core.

5.1. Singleton

Specifies that a single instance of the service will be created throughout your program’s lifecycle.

5.2. Scoped

Specifies that a new instance of the service will be created for each scope. If no scope was available at the time of resolving the service (i.e., the IThundernoltResolver used was not an IThunderboltScope), a singleton service will be returned.

If a service gets resolved using an IThunderboltScope as the IThunderboltResolver, each and every scoped dependency of that service will be resolved using the same IThunderboltScope (unless it was a singleton service whose scope dependencies had been resolved earlier).

5.3. Transient

Specifies that a new instance of the service will be created every time it is requested.

6. Service Registration

As well as services registered for you by default, here are three ways in which you may register your assemblies:

  • Explicit registration
  • Attribute registration
  • Regex-based attribute registration

All the three of them are valid to use either alone or in conjunction with any other. In fact, the source generator generates explicit registrations for attribute registrations.

6.1. Services Registered for You by Default

  • IThunderboltContainer: The container itself is registered as a singleton service. In fact, even ThunderboltActivator.Container that you may use to get the container returns the same singleton instance.

  • IThunderboltScope: This is registered as a transient service, meaning, every time you try to resolve/get an IThunderboltScope, a new IThunderboltScope will be created. This is the same as IThunderboltContainer.CreateScope().

    It is worth mentioning that an IThunderboltScope is an IDisposable, however, that doesn't mean that disposing an IThunderboltScope would dispose scoped instances (at least in the current release).

  • IThunderboltResolver: This is registered as a transient service and returns IThunderboltResolver that was used to get this instance. This will only ever return IThunderboltContainer or IThunderboltScope.

6.2. Explicit Registration

After you have created a partial class that inherits ThunderboltRegistration, you will have to override the abstract void Register. This method gives you a single argument of type IThunderboltRegistrar (reg), which you can use to register your services using the Add{serviceLifetime} methods.

For explicit registration, code generation is going to happen for every Add{serviceLifetime} call that happens inside (except for factory registrations), regardless of whether or not you implement a logic in your Register method (i.e., even if you implement a logic where a particular call to an Add method is not reachable, code generation would still consider it anyway).

Explicit registration supports four different scenarios for registering your services.

6.2.1. Same Service and Implementation Type

For this scenario, you may register your services like:

C#
reg.AddSingleton<FooService>();
reg.AddScoped<BarService>();
reg.AddTransient<BazService>();

6.2.2. Different Service and Implementation Types

For this scenario, you may register your services like:

C#
reg.AddSingleton<IFooService, FooService>();
reg.AddScoped<IBarService, BarService>();
reg.AddTransient<IBazService, BazService>();

6.2.3. The Service has a Factory to Determine the Resulting Instance

For this scenario, you may register your services like:

C#
reg.AddSingletonFactory<FooService>();
reg.AddScopedFactory<BarService>();
reg.AddTransientFactory<BazService>();

In this scenario, no code generation happens at all as it is assumed that you are going to provide all the necessary details to get an instance of this service. Service lifetimes however would still apply to service registered using any of the factory signatures.

6.2.4. The service has runtime logic to determine its implementation

For this scenario, you may register your services like:

C#
reg.AddSingleton<IYearHalfService>(() =>
{
	if (DateTime.UtcNow.Month <= 6)
		return typeof(YearFirstHalfService);
	else
		return typeof(YearSecondHalfService);
});

In this scenario, it is important to know that code generation happens for every capture of the statement return typeof(TService) inside the scope. This means that your implementations must be known at the compile time. It is also important to notice that either this:

C#
Type fooType = typeof(FooType);
return fooType;

or that:

C#
return someFooVariable.GetType();

will not work.

Also, you cannot pass a function pointer to this signature. It will compile but it won't work. The only accepted syntaxes are lambda expressions (e.g., () => typeof(TService) or () => { return typeof(TService); }) and anonymous method expressions (e.g., delegate { return typeof(TService); }).

6.3. Attribute Registration

This is where you may register your services using attributes. It is important to know that even if you don't have any explicit registrations, you still need to create a partial class that inherits ThunderboltRegistration for attribute registration to work. Attribute registrations are managed via two attributes (+ the regex ones): ThunderboltIncludeAttribute and ThunderboltExcludeAttribute.

6.3.1. ThunderboltIncludeAttribute

Define this attribute at the top of the types you want to register. There are few signatures for this attribute but they all come down to three simple arguments.

  • serviceLifetime (required): Specifies the service lifetime you want to register for this service.
  • implementation (optional): If this service has another type as its implementation, this is how you may specify that.
  • applyToDerviedTypes (optional, default: false): Determines whether derived types should also be registered just like this service. It is important to note that captured derived types are limited only to the types that are accessible in the assembly that defines the attribute; meaning: if you have an assembly FooAssembly where you use ThunderboltIncludeAttribute (with applyToDerivedTypes: true), and another assembly BarAssembly that references FooAssembly but also defines some derived types, types in BarAssembly will not be registered (unless another ThunderboltIncludeAttribute is defined in BarAssembly as well).

6.3.2. ThunderboltExcludeAttribute

Specifies that this type will not be registered unless it gets registered explicitly via ThunderboltRegistration.Register (even if ThunderboltIncludeAttribute or ThunderboltRegexIncludeAttribute is present).

This attribute also has the optional parameter (default: false) applyToDerivedTypes which specifies that derived types accessible in this assembly should not be registered via attribute-registration attributes.

6.4. Regex-based Attribute (Convention) Registration

This is how you may register several services at once, using their naming convention. Just like non-regex attribute registration, it is important to create a partial class that inherits ThunderboltRegistration for this to work, even if you don't have any explicit registrations. Regex attribute registrations are managed via two attributes: (ThunderboltRegexIncludeAttribute and ThunderboltRegexExcludeAttribute).

Attributes used for regex-based registration are assembly-level attributes (they should be defined on the target assembly [assembly: ThunderboltRegexIncludeAttribute(...)]).

6.4.1. ThunderboltRegexIncludeAttribute

This is where you define your conventions for naming-convention-based registration. The first argument passed to this attribute is the service lifetime for all the services matched by this attribute.

The regex argument passed to the attribute should match all the types that you wish to register. Your pattern is tested against the full names of the types (global::Type.Full.Namespace.TypeName).

Beware that your pattern gets tested against all of the accessible types, meaning that you may want to write a pattern that does not include types defined under the System namespace for instance.

You may have several ThunderboltRegexIncludeAttribute on the same assembly to define different patterns and lifetimes.

6.4.1.1. Registering services that they themselves represent their own implementation

Using only the two arguments discussed above is going to be sufficient for this scenario.

6.4.1.2. Registering services that have different type for their implementation

For this scenario, two more arguments are going to be needed in addition to the lifetime and the regex.

  • implRegex: This should be a regular expression that matches all of your implementation types (and only your implementation types).
  • joinKeyRegex: By now, your regex argument should be matching a number of services, and your implRegex argument should be matching a number of service implementations. This parameter is used to select a particular join key from the results matched by both of your other arguments to determine which implementations correspond to which services. This is also a regular expression.
6.4.1.2.1 IFooService: FooService: For the common scenario where we may want to match IFooService with FooService, we may use the following:
C#
[assembly: ThunderboltRegexInclude(
	ThunderboltServiceLifetime.Scoped,
	regex: @"(global::)?(MyBaseNamespace)\.I[A-Z][A-z_]+Service",
	implRegex: @"(global::)?(MyBaseNamespace)\.[A-Z][a-z][A-z_]+Service",
	joinKeyRegex: @"(?<=(global::)?(MyBaseNamespace)\.I?)[A-Z][a-z][A-z_]+Service")]

6.4.2. ThunderboltRegexExcludeAttribute

This is where you may specify a regular expression that when matched with any type, does not get registered via attribute registration (and/or regex-based attribute registration).

7. How to Resolve/Get Your Instances

Getting service instances with respect to their registered lifetimes is as simple as using Get<TService>() on an IThunderboltResolver. An IThunderboltResolver may either be an IThunderboltContainer or an IThunderboltScope.

7.1. Obtaining an IThunderboltContainer

Provided you have attached at least one partial class that inherits ThunderboltRegistration via ThunderboltActivator.Attach, it is safe to use ThunderboltActivator.Container property to get the singleton IThunderboltContainer instance. If you already have an IThunderboltResolver, you may also get the same container using resolver.Get<IThunderboltContainer>().

7.2. Obtaining an IThunderboltScope

Using an IThunderboltContainer, you may create a new scope using container.CreateScope(). If you already have an IThunderboltResolver, you may create a new scope using resolver.Get<IThunderboltScope>().

8. Supported Project Types

All project types are inherently supported and your project wouldn't complain about working with ThunderboltIoc. However, up until the moment of writing this, there is no explicit integration with Microsoft.Extensions.DependencyInjection, which to some extent limits our options when working with .NET Core projects if we wanted to utilize Microsoft's DI.

It is intended for ThunderboltIoc to integrate with Microsoft.Extensions.DependencyInjection in the future, but it the meantime, there would be no harm in using both frameworks together side-by-side; that however wouldn't let us fully benefit from ThunderboltIoc's superior performance all the time.

It is perfectly safe to use ThunderboltIoc for any .NET C# project as a standalone IoC.

9. Known Limitations

The only services for which code generation can work are services that have exactly one constructor that is public. It is planned to lift this restriction in future versions.

10. Planned for Future Versions

10.1. Lift the Single Public Constructor Restriction

As mentioned in the section above, it is feasible and desired to remove this limitation.

10.2. Better Source Generation Exception Handling

Currently, in the best-case scenario and if you follow the documentation, we shouldn't worry about source generation exceptions. However, upon failing to adhere to the documentation, it is possible that an unhandled exception might arise. In such a case, the source generator might (or might not) fail at the whole process. When that happens, it is possible that no code gets generated at all (you would get a notification in the build output but you might not notice it).

It is planned to provide better exception handling so that failure to generate code for a particular service wouldn't cause the whole process to fail. It would also be nice to generate relevant warnings or errors.

10.3. Verify No Cyclic Dependencies Exist

Currently, it is possible to fall into an infinite resolve operation where one (or more) service's dependencies may directly or indirectly depend on the same service.

10.4. Analyzers

It would be beneficial to have static code analyzers that show you warnings about failing to adhere to the best practices discussed in the documentation. For instance, generate a warning that tells you that a class you created that inherits ThunderboltRegistration does not have the partial modifier.

10.5. Property Injection

As is the case with any feature-rich IoC framework, property injection is a dependency injection style that is not currently supported by this framework.

10.6. Automatic Object Disposal

Disposing an IThunderboltScope should in turn dispose every IDisposable scoped service saved in the corresponding IThunderboltScope.

10.7. Integration with Microsoft.Extensions.DependencyInjection

The goal is to provide an intuitive API to effectively replace the default IServiceProvider of Microsoft's DI. Currently, IThunderboltResolver already implements System.IServiceProvider but no present elegant integration exists.

Finally!

I hope this release make it up to your expectations! I'm really looking forward to hearing your feedback and suggestions in the comments below.

It is my intention to update this article should new releases come out. If interested, you may want to keep an eye on it.

History

  • 18th January, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License

Share

About the Author

Aly Elhaddad
Egypt Egypt
No Biography provided

Comments and Discussions

 
QuestionI would love to see a comparison to my 3k one. Pin
Paulo Zemek7-Feb-22 21:02
MemberPaulo Zemek7-Feb-22 21:02 
AnswerRe: I would love to see a comparison to my 3k one. Pin
Aly Elhaddad8-Feb-22 2:13
MemberAly Elhaddad8-Feb-22 2:13 
When it comes to comparing similar frameworks, there are usually four points that I like to pin down the comparison to: ease-of-use, complexity/performance, feature-set, and cost. We usually want a product that is fast, easy to use, full of features at a low cost. Each framework usually has stronger areas and weaker areas. Thunderbolt is an attempt to compete at the four areas altogether.
  • Ease of use:
    Thunderbolt supports three different ways for service registration: explicit registration, attribute registration, regex/convention registration. Each of these ways can be used alone or in conjuction with any other. Expandable IoC on the other hand seems to support only the explicit form.

    However, for Thunderbolt, one have to create a class per each project where the framework registers services. This may count as separation of concerns, but might also count as additional work, so I'll just leave this here. On the other hand, that is not a requirement for Expandable IoC as you can register your services wherever you want.

    A point that counts for Expandable is that it supports unregistering services whereas for Thunderbolt, once you register your service, you won't be able to unregister it (but why would one really want to unregister a service?).

    A point that counts for Thunderbolt is the use-case sceanrio where we have a complex dependency graph so let's say we have service A which depends on B, C. B depends on C and D. E depends on A and D. I imagine registering those things with Expandable wouldn't be as easy as one would wish. With Thunderbolt on the other hand, it's as simple as reg.Add...<A>(), reg.Add...<B>().. etc. Code generation will take care of the rest, and will also take care of cyclic dependencies at the compile-time (if A depends on B and B directly or indirectly depends on A).
    p.s that cyclic dependency analysis is done in dev branch but not shipped yet though.
  • Complexity/performance:
    This is where Thunderbolt really thrives. If you call resolver.Get<T>(), you get your T instance with literally zero type checks, zero dictionary look-ups and zero type casts. Expandable however seems to rely on un-typed delegates and performs several type checks and at least one dictionary look-up.
  • Feature-set:
    As mentioned in the ease-of-use part, Thunderbolt offers more ways to registering services. Thunderbolt also doesn't require developers to care about how each type is created. Expandable seems to offer similar experience but at the expense of using reflection. Thunderbolt is also set for supporting automatic object disposal at the end of scoped services. Thunderbolt will also have support for property injection.
    p.s these last two are also complete in the dev branch but not shipped yet.

    It is also planned for Thunderbolt to integrate with Microsoft.Extensions.DependencyInjection for a seamless experience working with ASP.Net Core.
  • Cost:
    For Expandable, it's the reflection cost if we're to use the extension methods' approach, and it's also the dictionary look-up cost (which is necessary for frameworks that don't use source generation). Those are runtime costs.

    For Thunderbolt, it adds a little bit more overhead during the compile-time. But our end-users wouldn't know about it, would they? Wink | ;)
Finally, I don't want my reply to be mis-interpreted as undermining another framework. In fact, Expandable is still nice for its era, and I do like your article. It also has the sense of 'how to create a dependency injection framework at home' which builds a nice understanding for those who don't know how these things work. Source-generators have only been around since the release of C# 9 (around September 2020) and I took advantage of that. That said, I'm open to your suggestions and would love to hear your feedback.

modified 8-Feb-22 8:32am.

GeneralRe: I would love to see a comparison to my 3k one. Pin
Paulo Zemek8-Feb-22 11:32
MemberPaulo Zemek8-Feb-22 11:32 
GeneralMy vote of 2 Pin
Member 847504119-Jan-22 8:17
MemberMember 847504119-Jan-22 8:17 
GeneralRe: My vote of 2 Pin
Aly Elhaddad19-Jan-22 10:22
MemberAly Elhaddad19-Jan-22 10:22 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.