Updated on
Scrutor adds the two things missing from .NET’s built-in dependency injection container: assembly scanning, which registers all our services in one line instead of dozens, and decoration, which wraps a registered service with logging, caching, or validation without touching it.
The project describes itself in one line — “Assembly scanning and decoration extensions for Microsoft.Extensions.DependencyInjection” (khellang/Scrutor) — and that is exactly the scope. It is not a dependency injection container. It extends IServiceCollection, so nothing about our existing setup changes.
What Is Scrutor?
Scrutor is a NuGet package that adds the two features most teams miss in .NET’s built-in dependency injection: assembly scanning and decoration. Scanning finds our service implementations by convention and registers them in a single Scan() call, replacing dozens of hand-written AddScoped() lines that we otherwise forget to update when we add a class.
Decoration wraps an already-registered service in another implementation of the same interface, for logging, caching, or validation, without modifying the original class or its registration.
Scrutor is not a dependency injection container and does not replace Microsoft.Extensions.DependencyInjection. Every method it offers is an extension on IServiceCollection, so existing registrations, lifetimes, and container behavior stay exactly as they are. The package is MIT-licensed and actively maintained, and the current release is Scrutor 7.0.0.
If we need modules, interception, or property injection, that is a job for a full container like Autofac. For everything else, Scrutor on top of the built-in container is enough.
Install and First Scan
We install Scrutor from NuGet:
dotnet add package Scrutor
Then we scan the current assembly and register every class it finds against the interfaces it implements:
builder.Services.Scan(scan => scan
.FromAssembliesOf(typeof(Program))
.AddClasses()
.AsImplementedInterfaces()
.WithScopedLifetime());
This registers every concrete, public class in our application assembly against each interface it implements, with a scoped lifetime.
Everything else in this article is a refinement of this one call: narrowing which classes we pick up, choosing how they map to interfaces, and controlling their lifetimes.
Using Scrutor for Assembly Scanning
Scrutor helps to simplify our dependency injection code by dynamically searching types inside assemblies and registering them at runtime. Leveraging assembly scanning, we can partially automate our dependency registration code.
Additionally, we can use assembly scanning to build extensible systems. With scanning, we can build a program able to find and load additional modules at run time.
Automating Dependency Registration
In our ASP.NET web API project, let’s define a User entity:
public class User
{
public required int Id { get; init; }
public required string FirstName { get; init; }
public required string LastName { get; init; }
}
After that, let’s create a service interface to manage the User entity:
public interface IUserService
{
User GetUser(int id);
}
In the IntroductionToScrutorInDotNet.Services.Implementations namespace, let’s define an implementation for the IUserService:
public class UserService : IUserService
{
public User GetUser(int id)
{
return new User
{
Id = id,
FirstName = "John",
LastName = "Doe"
};
}
}
Finally, let’s create a UsersController to allow us to query the user using an HTTP Get request:
[Route("[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id:int}")]
public IActionResult GetUser(int id)
{
return Ok(_userService.GetUser(id));
}
}
Now, we need to wire the UsersController with the UserService, and we can do that using our dependency injection framework.
Instead of manually registering the UserService, we can use Scrutor’s Scan() extension method to register all the services automatically:
builder.Services.Scan(selector => selector
.FromAssembliesOf(typeof(Program))
.AddClasses(
classSelector =>
classSelector.InNamespaces("IntroductionToScrutorInDotNet.Services.Implementations")
)
.AsImplementedInterfaces()
);
Here, we call Scrutor’s Scan() extension method, providing a selector action as an argument. The method will perform assembly scanning and select services to register based on the selector we provide.
First of all, we need to select the assembly where our target types reside; here we point Scrutor at the assembly that contains Program using FromAssembliesOf(typeof(Program)). Next, we narrow the selection even further, to only register classes, by calling the AddClasses() method.
Scrutor 7.0.0 removed the older FromCallingAssembly() helper, so we name the assembly explicitly. FromAssembliesOf(typeof(Program)) also keeps working if we later move this registration into an extension method in another assembly, where FromCallingAssembly() would have scanned the wrong one.
The AddClasses() method also has a selector as a parameter, and we use that to only select classes in the IntroductionToScrutorInDotNet.Services.Implementations namespace.
Finally, we register our selected types as a substitution for all the interfaces they implement by calling AsImplementedInterfaces(). This is because our UsersController depends on the IUserService and not directly on the UserService.
Using the wide variety of extension methods that Scrutor provides, we can be very precise about the services we want to register.
Registering Services in Another Assembly
Let’s consider another .NET class library project, IntroductionToScrutorInDotNet.Customers, that contains types to manage the Customer entity:
- The definition of the
Customerentity - The interface
ICustomerService - The implementation
CustomerServicefor theICustomerServiceinterface
Our CustomersController needs to use the ICustomerService to create and return a customer for a certain User:
[Route("[controller]")]
[ApiController]
public class CustomersController : ControllerBase
{
private readonly ICustomerService _customerService;
public CustomersController(ICustomerService customerService)
{
_customerService = customerService;
}
[HttpPost]
public IActionResult CreateCustomer(User user)
{
var fullName = string.Join(' ', user.FirstName, user.LastName);
var customerId = Random.Shared.Next(1000);
return Created(
$"/Customers/{customerId}",
_customerService.CreateCustomer(customerId, fullName)
);
}
}
With Scrutor, we can register the types from the IntroductionToScrutorInDotNet.Customers project assembly:
builder.Services.Scan(selector => selector
.FromAssemblyOf<ICustomerService>()
.AddClasses(classSelector =>
classSelector.AssignableTo<ICustomerService>())
.AsMatchingInterface());
Here, we are asking Scrutor to scan only the assembly in which the ICustomerService interface resides, using the FromAssemblyOf<ICustomerService>() extension method. Then, among all the implementations in that assembly, we select only those that are assignable to ICustomerService.
Finally, we instruct Scrutor to register the selected implementations to the interfaces that match the implementation class name by calling the AsMatchingInterface() method.
Working With Generics
Now, we want to add a repository to get the list of Users. First, we define the generic repository interface:
public interface IRepository<T>
{
IEnumerable<T> GetAll();
}
Then, we implement the repository to use a list as backing storage for our User entity:
public class UserRepository : IRepository<User>
{
private readonly List<User> _users = new()
{
new User
{
Id = 1,
FirstName = "John",
LastName = "Doe"
},
new User
{
Id = 2,
FirstName = "Janine",
LastName = "Doe"
}
};
public IEnumerable<User> GetAll()
{
return _users;
}
}
Let’s update our UsersController to use the repository to get all the users we have in our system:
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly IRepository<User> _userRepository;
public UsersController(IUserService userService, IRepository<User> userRepository)
{
_userService = userService;
_userRepository = userRepository;
}
[HttpGet]
public IActionResult GetUsers()
{
return Ok(_userRepository.GetAll());
}
}
Last, we tell Scrutor to register all implementations of the open generic type IRepository<> as their implemented interfaces:
builder.Services.Scan(selector => selector
.FromAssembliesOf(typeof(Program))
.AddClasses(classSelector => classSelector.AssignableTo(typeof(IRepository<>)))
.AsImplementedInterfaces());
This will register our UserRepository to substitute any dependency on IRepository<User>.
Specifying the Lifetime of Dependencies
As with any dependency management framework, specifying the lifetime of the instances created by the container is an essential part of dependency registration. With Scrutor, we can use lifetime specifiers:
builder.Services.Scan(selector => selector
.FromAssemblyOf<ICustomerService>()
.AddClasses(classSelector =>
classSelector.AssignableTo<ICustomerService>())
.AsMatchingInterface()
.WithTransientLifetime()
);
In this example, we register all the matching implementations with a transient lifetime scope by calling WithTransientLifetime(). There are similar extension methods corresponding to the common .NET lifetime scopes, for example WithSingletonLifetime() or WithScopedLifetime().
Handling Multiple Implementations
Now let’s see how we can set our desired registration strategy for each selector. The registration strategy is how Scrutor handles the cases where multiple implementations exist for the same interface:
builder.Services.Scan(scan =>
scan.FromAssemblyOf<ICustomerService>()
.AddClasses(classes => classes.InExactNamespaceOf<ICustomerService>())
.UsingRegistrationStrategy(RegistrationStrategy.Skip)
.AsImplementedInterfaces()
);
In this code, by using the UsingRegistrationStrategy() method with the RegistrationStrategy.Skip parameter, we are telling Scrutor to ignore additional implementations of any interface once the first one has been registered.
Other registration strategies available include:
RegistrationStrategy.Append: Appends a new registration for existing servicesRegistrationStrategy.Throw: Throws when trying to register an existing service
Chaining Multiple Registrations
For now, we have been configuring Scrutor for each part of our application separately. For the sake of simplicity and clarity, it’s possible to register everything with a single call to the Scan() method.
Thanks to the chaining ability of the different extension methods, we can replace all the Scrutor configurations we have written with a single call:
builder.Services.Scan(selector =>
selector
.FromAssembliesOf(typeof(Program))
.AddClasses(
classSelector =>
classSelector.InNamespaces("IntroductionToScrutorInDotNet.Services.Implementations")
)
.AsImplementedInterfaces()
.AddClasses(classSelector => classSelector.AssignableTo(typeof(IRepository<>)))
.UsingRegistrationStrategy(RegistrationStrategy.Skip)
.AsImplementedInterfaces()
.FromAssemblyOf<ICustomerService>()
.AddClasses(classSelector =>
classSelector.AssignableTo<ICustomerService>())
.AsMatchingInterface()
.WithTransientLifetime()
);
Decorator Pattern With Scrutor
We can use the decorator design pattern to extend the functionality of an existing object without changing its code. For this purpose, a decorator is designed as a wrapper around the objects that need to be extended, so it can intercept calls to the wrapped object’s methods.
Implementing a Simple Decorator
To demonstrate how we can leverage Scrutor’s Decorate() extension method to manage the registration of decorator objects, let’s define a decorator of our repository that logs to the console when we access a list of entities.
To do this, we need to inherit from the same interface that our repository implements:
public class RepositoryLoggerDecorator<T> : IRepository<T>
{
private readonly IRepository<T> _decoratedRepository;
public RepositoryLoggerDecorator(IRepository<T> decoratedRepository)
{
_decoratedRepository = decoratedRepository;
}
public IEnumerable<T> GetAll()
{
Console.WriteLine("The list of all users has been retrieved from the DB");
return _decoratedRepository.GetAll();
}
}
The decorator should take in its constructor the instance of the decorated repository; in our case, it’s the instance of IRepository<T> that we will wrap. In the GetAll() method, we log a message to the console and then call the _decoratedRepository‘s GetAll() method.
Registering Decorators With Scrutor
Let’s proceed to register our decorator with our dependency container using Scrutor’s Decorate<,>() extension method:
builder.Services.Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>();
The first type argument of the Decorate<,>() method represents the type of service we want to decorate, and the second type argument is the type of the decorator.
After we register the decorator with Scrutor, all calls to IRepository<User> methods are intercepted by the RepositoryLoggerDecorator<User>. If we run the application and call the HTTP Get endpoint to get all users, we can see that the log message is written to the console.
In this example, we have used the Console.WriteLine() method to log; in a real-life scenario, we should use ILogger<T> and a logging framework.
Common Pitfalls With Scrutor
The Scrutor calls above are short, but a few behaviors surprise teams the first time they hit them. Each of the following is verified against Scrutor 7.0.0.
Every Scan() call appends by default. Running two scans that both match UserRepository leaves two registrations of IRepository<User>, and IEnumerable<IRepository<User>> then resolves both.
The fix is to set an explicit strategy: UsingRegistrationStrategy(RegistrationStrategy.Skip), or RegistrationStrategy.Throw during development to catch overlapping scans early.
Scanned registrations are transient unless we say otherwise. A repository we assumed was scoped is created on every resolve until we add an explicit lifetime.
The fix is to chain the lifetime we actually want, such as WithScopedLifetime(); if dependency injection lifetimes matter for a service, never rely on the default.
Decorating a service that was never registered throws. Calling Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>() before any Scan() or Add*() registers IRepository<User> throws a Scrutor.DecorationException with the message “Could not find any registered services for type ‘IRepository<User>'”.
The fix is ordering: run all Scan() and Add*() registrations first, and put every Decorate() call last.
Open generics decorate through the non-generic overload. Once scanning has registered the closed IRepository<User>, both Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>() and the open-generic form resolve the decorator:
builder.Services.Decorate(typeof(IRepository<>), typeof(RepositoryLoggerDecorator<>));
The open-generic overload is the one to reach for when we register services as open generics and cannot name a single closed type.
Scrutor vs Autofac vs Built-in DI
Scrutor sits between the built-in container and a full-featured one like Autofac. The table compares where each option lands:
| Criterion | Built-in DI | Built-in + Scrutor | Autofac |
|---|---|---|---|
| Assembly scanning | No | Yes | Yes |
| Decorators | Manual factory lambdas | One Decorate() call | Yes |
| Property injection | No | No | Yes |
| Modules / child containers | No | No | Yes |
| Keyed services | Yes (.NET 8+) | Yes (same container) | Yes |
| Extra dependency | — | One small MIT package | Full container swap |
| When to choose | Few services, explicit registration | You want conventions, not a new container | You need interception, modules, or advanced lifetimes |
Microsoft’s dependency injection guidelines say it outright: “We recommend using the built-in container unless you need a specific feature that it doesn’t support.” The features they name are property injection, child containers, custom lifetime management, Func<T> support for lazy initialization, and convention-based registration. Scrutor occupies the middle ground: we keep the built-in container, with its keyed services in .NET and first-class ASP.NET Core integration, and add only the conventions.
A full swap to Autofac in a .NET project buys interception and modules at the price of a second registration API that every team member has to learn.
When to Reach for Scrutor
We reach for Scrutor when registration code stops being informative, when Program.cs is a wall of AddScoped() lines that nobody reads and everybody forgets to update. Scanning fixes that in one call.
We also reach for it the moment we want a decorator without hand-writing the factory pattern with dependency injection.
We don’t reach for it to replace the container. If the requirement is interception, modules, or property injection, that is Autofac territory. For everything in between, Scrutor is the smallest tool that works.
Tested with .NET 10.0.10 and Scrutor 7.0.0.
