In this post I describe how to use the open source library Scrutor by Kristian Hellang to add assembly scanning capabilities to the ASP.NET Core DI container. Scrutor is not a dependency injection (DI) container itself, instead it adds additional capabilities to the built-in container. The library has been going for over 2 years now - see this post for the original announcement and motivation for the library by Kristian.
This post ended up being pretty long, so, for your convenience, a table of contents:
- The ASP.NET Core DI container
- Scrutor vs third-party DI containers
- Assembly scanning with Scrutor
- Summary
The ASP.NET Core DI container
ASP.NET Core uses dependency injection throughout the core of the framework. Consequently, the framework includes a simple DI container that provides the minimum capabilities required by the framework itself. This container can be found in the Microsoft.Extensions.DependencyInjection NuGet package.
There are also many third-party .NET DI libraries that provide many more capabilities and features. I wrote quite a while back about using StructureMap in ASP.NET Core (though if you're using StructureMap, you should probably take a look at Lamar instead), but there are many containers to choose from, for example:
These containers all provide different features and focuses: attribute-based configuration, convention based registration, property injection, performance… the list goes on.
A common feature is to provide automatic registration of services by scanning the types in the assembly, and looking for those that match a convention. This can greatly reduce the boilerplate configuration required in your Startup.ConfigureServices
method.
For example, if your registrations at the moment look like this:
services.AddScoped<IFoo, Foo>();
services.AddScoped<IBar, Bar>();
services.AddScoped<IBaz, Baz>();
then you might be able to simplify your DI configuration using assembly scanning to be more like this:
services.Scan(scan =>
scan.FromCallingAssembly()
.AddClasses()
.AsMatchingInterface());
Scrutor vs third-party DI containers
Scrutor is not a new DI container. Under the hood it uses the built-in ASP.NET Core DI container. This has both pros and cons for you as an app developer:
Pros:
- It's simple to add to an existing ASP.NET Core application. You can easily add Scrutor to an app that's using the built in container. As the same underlying container is used, you can be confident there won't be any unexpected changes in service resolution.
- You can use Scrutor with other DI containers. As Scrutor uses the built-in DI container, and as most third-party DI containers provide adapters for working with ASP.NET Core, you could potentially use both Scrutor and another container in a single application. I can't see that being a common scenario, but it might make migrating an app to use a third-party container easier than moving between two different third-party containers.
- It's very likely to remain supported and working, even if the built-in DI container changes. Hopefully this won't be a concern, but if the ASP.NET Core team make breaking changes to the DI container, Scrutor seems less likely to be affected than third party containers, as it uses the built-in DI container directly, as opposed to providing an alternative container implementation.
Cons:
- Reduced functionality. As it uses the built-in container, Scrutor will always be limited by the functionality of the built-in container. The built-in container is intentionally kept very simple, and is unlikely to gain significant extra features.
I'm sure there's more cons, but the reduced functionality is a really big one! If you're used to one of the more full-featured DI containers, then you'll likely want to stick with those. On the other hand, if you're currently only using the built-in container, Scrutor is worth a look to see if it can simplify your code.
To install Scrutor, run dotnet add package Scrutor
from the .NET CLI, or Install-Package Scrutor
from the Package Manager Console. In the next section, I'll show some of the assembly scanning primitives you can use with Scrutor.
Assembly scanning with Scrutor
The Scrutor API consists of two extension methods on IServiceCollection
: Scan()
and Decorate()
. In this post I'm just going to be looking at the Scan
method, and some of the options it provides.
The Scan
method takes a single argument: a configuration action in which you define four things:
- A selector - which implementations (concrete classes) to register
- A registration strategy - how to handle duplicate services or implementations
- The services - which services (i.e. interfaces) each implementation should be registered as
- The lifetime - what lifetime to use for the registrations
For example a Scan
method which looks in the calling assembly, and adds all concrete classes as transient services would look like the following:
services.Scan(scan => scan
.FromCallingAssembly() // 1. Find the concrete classes
.AddClasses() // to register
.UsingRegistrationStrategy(RegistrationStrategy.Skip) // 2. Define how to handle duplicates
.AsSelf() // 2. Specify which services they are registered as
.WithTransientLifetime()); // 3. Set the lifetime for the services
So we have something concrete to discuss, lets imagine we have the following services and implementations in an assembly:
public interface IService { }
public class Service1 : IService { }
public class Service2 : IService { }
public class Service : IService { }
public interface IFoo {}
public interface IBar {}
public class Foo: IFoo, IBar {}
The previous Scan()
code would register Service1
, Service2
, Service
and Foo
as themselves, equivalent to the following statements using the built in container:
services.AddTransient<Service1>();
services.AddTransient<Service2>();
services.AddTransient<Service>();
services.AddTransient<Foo>();
In the next section I'll look at some of the options available to you for point 1 - choosing which implementations to register.
Selecting and filtering the implementations to register
The very first configuration step in the end assembly scan is to define which concrete classes you want to register in your application. Scrutor provides a lot of different ways to search for types, focussing primarily on scanning assemblies for types, and filtering the list. In this section I describe the various options available.
If you've ever used the assembly scanning capabilities of other DI containers like StructureMap or Autofac then this should feel quite familiar to you.
Specifying the types explicitly
The simplest type selector involves providing the types explicitly. For example, to register Service1
and Service2
as transient services:
services.Scan(scan => scan
.AddTypes<Service1, Service2>()
.AsSelf()
.WithTransientLifetime());
This is equivalent to
services.AddTransient<Service1>();
services.AddTransient<Service2>();
There are three AddTypes<>
methods available, with 1, 2, or 3 generic parameters available. You probably won't find you use this approach very often, but it can be handy now and again. In practice, you're more likely to use assembly scanning to find the types automatically.
Scanning an assembly for types
The real selling point for Scrutor is its methods to scan your assemblies, and automatically register the types it finds. Scrutor has several variations which allow you to pass in instances of Assembly
to scan, to retrieve the list of Assembly
instances based on your app's dependencies, or to use the calling or executing assembly. Personally I prefer the methods that allow you to pass a Type
, and have Scrutor find all the classes in the assembly that contains the Type
. For example:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses()
.AsSelf()
.WithTransientLifetime());
The code above will find the assembly that contains IService
and scan for all of the classes it contains. For the current version of Scrutor, 3.0.0 at the time of writing (named Third Essential Scarecrow 😂) , the following assembly scanning methods are available:
FromAssemblyOf<>
,FromAssembliesOf
- Scan the assemblies containing the providedType
orType
sFromCallingAssembly
,FromExecutingAssembly
,FromEntryAssembly
- Scan the calling, executing, or entry assembly! See theAssembly
static methods for details on the differences between them.FromAssemblyDependencies
- Scan all assemblies that the providedAssembly
depends onFromApplicationDependencies
,FromDependencyContext
- Scan runtime libraries. I don't really know anything aboutDependencyContext
s so you're on your own with these ones!
Filtering the classes you find
Whichever assembly scanning approach you choose, you need to call AddClasses()
afterwards, to select the concrete types to add to the container. This method has several overloads you can use to filter out which classes are selected:
AddClasses()
- Add all public, non-abstract classesAddClasses(publicOnly)
- Add all non-abstract classes. SetpublicOnly=false
to addinternal
/private
nested classes tooAddClass(predicate)
- Run an arbitrary action to filter which classes include. This is very useful and used extensively, as shown below.AddClasses(predicate, publicOnly)
- A combination of the previous two methods.
The ability to run a predicate for every concrete class discovered is very useful. You can use this predicate in many different ways. For example, to only include classes which can be assigned to (i.e. implement) a specific interface, you could do:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses(classes => classes.AssignableTo<IService>())
.AsImplementedInterfaces()
.WithTransientLifetime());
Or you could restrict to only those classes in a specific namespace:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses(classes => classes.InNamespaces("MyApp"))
.AsImplementedInterfaces()
.WithTransientLifetime());
Alternatively, you can use an arbitrary filter based on the Type
itself:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses(classes => classes.Where(type => type.Name.EndsWith("Repository"))
.AsImplementedInterfaces()
.WithTransientLifetime());
Once you've defined your concrete class selector, you can optionally define your replacement strategy.
Handling duplicate services with a ReplacementStrategy
Scrutor lets you control how to handle the case where a service (e.g. IService
) has already been registered in the DI container by specifying a ReplacementStrategy
. There are currently five different replacement strategies you can use:
Append
- Don't worry about duplicate registrations, add new registrations for existing services. This is the default behaviour if you don't specify a registration strategy.Skip
- If the service is already registered in the container, don't add a new registration.Replace(ReplacementBehavior.ServiceType)
- If the service is already registered in the container, remove all previous registrations for that service before creating a new registration.Replace(ReplacementBehavior.ImplementationType)
- If the implementation is already registered in the container, remove all previous registrations where the implementation matches the new registration, before creating a new registration.Replace(ReplacementBehavior.All)
- Apply both of the previous behaviours. If the service or the implementation have previously been registered, remove all of those registrations first.
To choose a replacement strategy, use the UsingRegistrationStrategy()
method after specifying your type selector:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses()
.UsingRegistrationStrategy(RegistrationStrategy.Skip)
.AsSelf()
.WithTransientLifetime());
Getting your head around the difference between the Replacement strategies (the last three in particular) can be a little tricky, so I'll provide a quick example. Imagine the DI container already contains the following registrations:
services.AddTransient<ITransientService, TransientService>();
services.AddScoped<IScopedService, ScopedService>();
Subsequently, during scanning, Scrutor then finds the following classes to register as transient service/implementation pairs:
public class TransientService : IFooService {}
public class AnotherService : IScopedService {}
What will be the final result with each of the replacement strategies? With Append
, the answer is easy, you get everything:
services.AddTransient<ITransientService, TransientService>(); // From previous registrations
services.AddScoped<IScopedService, ScopedService>(); // From previous registrations
services.AddTransient<IFooSerice, TransientService>();
services.AddScoped<IScopedService, AnotherService>();
With Skip
, the duplicate IScopedService
is ignored, but we append the TransientService
/IFooService
pair:
services.AddTransient<ITransientService, TransientService>(); // From previous registrations
services.AddScoped<IScopedService, ScopedService>(); // From previous registrations
services.AddTransient<IFooSerice, TransientService>();
On to the trickier ones. Replace(ReplacementBehavior.ServiceType)
replaces by service type (i.e. interface), so in this case the IScopedService
ScopedService
registration would be replaced by AnotherService
:
services.AddTransient<ITransientService, TransientService>(); // From previous registrations
services.AddTransient<IFooSerice, TransientService>();
services.AddScoped<IScopedService, AnotherService>(); // Replaces ScopedService
If you replace by implementation type, Replace(ReplacementBehavior.ImplementationType)
, then the TransientService
registration is changed from an ITransientService
to an IFooService
. The duplicate IScopedService
is appended:
services.AddScoped<IScopedService, ScopedService>(); // From previous registrations
services.AddTransient<IFooSerice, TransientService>(); // Changed from ITransientService to IFooService
services.AddScoped<IScopedService, AnotherService>();
Finally, if you use Replace(ReplacementBehavior.All)
, both of the previous registrations would be removed, and replaced by the new ones:
services.AddTransient<IFooSerice, TransientService>();
services.AddScoped<IScopedService, AnotherService>();
The replacement strategry is probably one of the hardest aspects to get your head araound. Where possible, I would try and avoid it by avoiding manually registering any classes which would also be discovered as part of a Scan.
Registering implementations as a service
We've discussed how to find the implementations to add, and an implementation strategry, but we still need to choose how the classes are registered in the container.
Scrutor provides many different options for how to register a given service. I'll walk through each of them in turn, show how to use it, and show what the equivalent "manual" registration would look like.
You call each method after AddClasses()
if you're using the default registration strategy, or after UsingRegistrationStrategy()
if not:
services.Scan(scan => scan
.FromAssemblyOf<IService>()
.AddClasses()
.AsSelf() // Specify how the to register the implementations/services
.WithSingletonLifetime());
For the examples in this section, I'll assume Scrutor has found the following class as part of its assembly scanning:
public class TestService: ITestService, IService {}
Registering an implementation as itself
For classes that don't implement an interface, or which you wish to be directly available for injection, you can use the AsSelf()
method. In our example, this is equivelent to the following manual registration:
services.AddSingleton<TestService>();
Registering services which match a standard naming convention
A pattern I see a lot is where each concrete class, Class
, has an equivalent interface named IClass
. You can register all the serclasses that match this pattern using the AsMatchingInterface()
method. For our example, this is equivelent to the following:
services.AddSingleton<ITestService, TestService>();
Registering an implementation as all implemented interfaces
If a class implements more than one interface, sometimes you will want the class to be registered with all of those services. You can achieve this with Scrutor using AsImplementedInterfaces()
:
services.AddSingleton<ITestService, TestService>();
services.AddSingleton<IService, TestService>();
As I discussed in my last post, it's important to understand that these registrations could lead to bugs where you have two instances of the "singleton". If that's not what you want, read on!
Registering an implementation using forwarded services
I discussed in my previous post how registering an implementation more than once in the DI container could lead to multiple instances of "singleton" or "scoped" services. The ASP.NET Core DI container does not support "forwarded" service types, so you typically have to achieve it manually using an object factory. For example:
services.AddSingleton<TestService>();
services.AddSingleton<ITestService>(x => x.GetRequiredService<TestService>());
services.AddSingleton<IService>(x => x.GetRequiredService<TestService>());
With Scrutor, you can now (as of 3.0.0) easily use this pattern by using the AsSelfWithInterfaces()
method.
Registering an implementation as an arbitrary service
The final registration option is to specify a specific service, e.g. IMyService
using the As<T>()
function, e.g. As<IMyService>()
. This registers all classes found as that service:
services.AddSingleton<IMyService, TestService>();
Note that if you try and register a concrete type with a service that it can't provide, you'll get an
InvalidOperationException
at runtime.
That covers all of the different registration options. You'll probably find you use a variety of different methods for the different services in your app (with the exception of the As<T>()
function, which I haven't personally found a need for).
The final aspect to consider when registering services in DI containers is the lifetime. Luckily, as Srutor uses the built-in DI container, that's pretty self explanatory if you're familiar with I in ASP.NET Core.
Specifying the lifetime of the registered classes
Whenever you register a class in the ASP.NET Core DI container, you need to specify the lifetime of the service. Scrutor has methods that correspond to the three lifetimes in ASP.NET Core:
WithTransientLifetime()
- Transient is the default lifetime if you don't specify one.WithScopedLifetime()
- Use the same service scoped to the lifetime of a request.WithSingletonLifetime()
- Use a single instance of the service for the lifetime of the app.
With all these pieces (scanning, filtering, registration strategy, registration type, and lifetime) you can automate registering your services with the DI container.
Scrutor also allows you to decorate your classes with [ServiceDescriptor]
attributes to define how services should be registered, but as that seems like an abomination, I won't go into it here 😉.
A more important aspect is looking at how you can combine different rules for different classes together in the same Scan()
method.
Chaining multiple selectors together
It's very unlikely that you'll want to register all the classes in your application using the same lifetime or registration strategy. Instead, it's far more likely that you'll want to register subsets of your app using the same strategy.
Scrutor's API allows you to chain together multiple scans of an assembly, specifying the rules for a subset of classes at a time. This feels very natural to me and allows you to write things like the following:
services.Scan(scan => scan
.FromAssemblyOf<CombinedService>()
.AddClasses(classes => classes.AssignableTo<ICombinedService>()) // Filter classes
.AsSelfWithInterfaces()
.WithSingletonLifetime()
.AddClasses(x=> x.AssignableTo(typeof(IOpenGeneric<>))) // Can close generic types
.AsMatchingInterface()
.AddClasses(x=> x.InNamespaceOf<MyClass>())
.UsingRegistrationStrategy(RegistrationStrategy.Replace()) // Defaults to ReplacementBehavior.ServiceType
.AsMatchingInterface()
.WithScopedLifetime()
.FromAssemblyOf<DatabaseContext>() // Can load from multiple assemblies within one Scan()
.AddClasses()
.AsImplementedInterfaces()
);
All in all, scrutor lets you achieve everything you could do manually with the ASP.NET Core DI container, but in a more succinct way. If you miss assembly scanning from third party DI containers, but want to stick with the but-in DI container for whatever reason, I strongly suggest checking out Scrutor.
A whole aspect I haven't discussed in this post is Decoration. I'll cover that in my next post, but in the mean time you can see examples of how to use it on the project Readme.
Summary
Scrutor adds assembly scanning capabilities to the Microsoft.Extensions.DependencyInjection DI container, used in ASP.NET Core. It is not a third-party DI container, but rather extends the built-in container by making it easier to register your services.
To register your services, call Scan()
on the IServiceCollection
in Startup.ConfigureServices
. You must define four things:
- A selector - how to find the types to register (typically by scanning an assembly)
- A registration strategy - how to handle duplicate services (by default, Scrutor will add new registrations for duplicate services)
- The services - which services (i.e. interfaces) each implementation should be registered as
- The lifetime - what lifetime to use for the registrations (transient by default)
You can chain multiple scans together, to apply different rules to subsets of your classes. If Scrutor sounds interesting to you, check it out on GitHub, download the NuGet package, and follow Kristian on Twitter!