Every .NET project eventually accumulates a wall of AddScoped, AddTransient, and AddSingleton registrations. Add a new service, remember to register it. Rename an interface, update the registration. Scrutor eliminates this by scanning assemblies and registering services by convention.
Setup
dotnet add package Scrutor
Scrutor extends IServiceCollection with a Scan method that provides a fluent API for assembly scanning.
Basic Convention-Based Registration
Register all classes that implement an interface matching the pattern I{ClassName}:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.InNamespaces("MyApp.Services"))
.AsMatchingInterface()
.WithScopedLifetime());
This finds OrderService and registers it as IOrderService, CustomerService as ICustomerService, and so on. The AsMatchingInterface convention matches each class to an interface with the same name prefixed with I.
Scanning Multiple Assemblies
builder.Services.Scan(scan => scan
.FromAssembliesOf(typeof(OrderService), typeof(PaymentGateway))
.AddClasses()
.AsImplementedInterfaces()
.WithScopedLifetime());
AsImplementedInterfaces registers each class against all interfaces it implements, not just the matching one.
Filtering Classes
Control which classes get registered:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes
.Where(type => type.Name.EndsWith("Service")))
.AsImplementedInterfaces()
.WithScopedLifetime());
Or exclude specific types:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes
.Where(type => !type.IsAbstract)
.Where(type => type.Namespace?.StartsWith("MyApp.Services") == true))
.AsImplementedInterfaces()
.WithScopedLifetime());
Using Attributes
Scrutor includes a ServiceDescriptor attribute for explicit control:
[ServiceDescriptor(typeof(IOrderService), ServiceLifetime.Scoped)]
public class OrderService : IOrderService
{
// ...
}
Scan for decorated classes:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes
.WithAttribute<ServiceDescriptorAttribute>())
.AsImplementedInterfaces()
.WithScopedLifetime());
Registration Strategies
By default, Scrutor skips types that are already registered. Control this behaviour:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses()
.AsImplementedInterfaces()
.WithScopedLifetime()
.UsingRegistrationStrategy(RegistrationStrategy.Replace(ServiceLifetime.Scoped)));
Strategies include:
Skip— do not register if the service type is already registered (default)Replace— replace existing registrationsAppend— add alongside existing registrations
Decoration
Scrutor's other major feature is service decoration. Wrap an existing registration with additional behaviour:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, CachingOrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();
The decorator chain resolves in reverse order of registration. When you inject IOrderService, you get LoggingOrderService wrapping CachingOrderService wrapping OrderService:
public class CachingOrderService : IOrderService
{
private readonly IOrderService _inner;
private readonly IDistributedCache _cache;
public CachingOrderService(IOrderService inner, IDistributedCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Order?> GetByIdAsync(int id)
{
var cacheKey = $"order:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null)
return JsonSerializer.Deserialize<Order>(cached);
var order = await _inner.GetByIdAsync(id);
if (order is not null)
{
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(order),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
}
return order;
}
}
Combining Scanning and Decoration
A practical setup might look like this:
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
// Register all services
.AddClasses(classes => classes.InNamespaces("MyApp.Services"))
.AsMatchingInterface()
.WithScopedLifetime()
// Register all repositories
.FromAssemblyOf<OrderRepository>()
.AddClasses(classes => classes.InNamespaces("MyApp.Repositories"))
.AsMatchingInterface()
.WithScopedLifetime());
// Add decorators for specific services
builder.Services.Decorate<IOrderService, CachingOrderService>();
When to Use Scrutor
Scrutor works best when your project follows consistent naming conventions. If every service implements a matching interface and lives in a predictable namespace, assembly scanning eliminates boilerplate. If your registrations are highly varied with different lifetimes and special configurations, explicit registration may be clearer.
The decoration feature is valuable regardless of whether you use scanning. It's the cleanest way to add cross-cutting behaviour to a specific service without modifying the original class.