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}:

Example.cs
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

Example.cs
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:

Example.cs
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(classes => classes
        .Where(type => type.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Or exclude specific types:

Example.cs
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:

Example.cs
[ServiceDescriptor(typeof(IOrderService), ServiceLifetime.Scoped)]
public class OrderService : IOrderService
{
    // ...
}

Scan for decorated classes:

Example.cs
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:

Example.cs
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses()
    .AsImplementedInterfaces()
    .WithScopedLifetime()
    .UsingRegistrationStrategy(RegistrationStrategy.Replace(ServiceLifetime.Scoped)));

Strategies include:

Decoration

Scrutor's other major feature is service decoration. Wrap an existing registration with additional behaviour:

Example.cs
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:

Example.cs
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:

Example.cs
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.