Skip to main content
SEMastery
ASP.NETintermediate

Improving ASP.NET Core Dependency Injection with Scrutor

Learn how Scrutor makes ASP.NET Core dependency injection easier with assembly scanning and decoration, explained in simple, beginner-friendly steps.

11 min readUpdated December 23, 2025

When you build an ASP.NET Core app, you end up writing a lot of lines like services.AddScoped<IThing, Thing>();. One line for every service. At first this feels fine. But as your app grows to fifty, a hundred, or two hundred services, that list in Program.cs becomes long and easy to break. You forget one line, and your app crashes at runtime with a confusing error.

Scrutor is a small, free library that fixes this. It does not replace the built-in dependency injection (DI) container. It just makes the container smarter. In this post we will learn what Scrutor does, why it helps, and how to use it step by step.

A real-life analogy: the tiffin delivery system

Think about the famous tiffin (lunchbox) delivery in a big Indian city. Imagine a manager who has to write down, by hand, every single delivery: "Box from this house goes to that office." If there are 5,000 boxes, writing each line by hand is slow and full of mistakes. One missed line means one hungry person at lunch.

Now imagine a smarter rule: "Pick up every box from this area that has the office address sticker, and deliver it." The manager no longer writes 5,000 lines. They write one rule, and the system finds all the matching boxes automatically.

That smart rule is exactly what Scrutor's assembly scanning does. Instead of registering every service by hand, you give one rule, and Scrutor finds and registers all the matching classes for you.

The second Scrutor feature, decoration, is like adding a paper wrapper around each tiffin box that stamps the time and keeps it warm, without changing the food inside. You add extra behaviour around a service without touching the original code.

What problem are we solving?

Let us look at the normal way first. Say you have these services in a typical app.

public interface IOrderService { Task PlaceOrderAsync(); }
public class OrderService : IOrderService
{
    public Task PlaceOrderAsync() => Task.CompletedTask;
}
 
public interface IEmailService { Task SendAsync(string to); }
public class EmailService : IEmailService
{
    public Task SendAsync(string to) => Task.CompletedTask;
}
 
public interface IInvoiceService { Task CreateAsync(); }
public class InvoiceService : IInvoiceService
{
    public Task CreateAsync() => Task.CompletedTask;
}

To use them, the normal registration looks like this.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
// ... and many, many more lines as the app grows

Each new service means another line. Miss one, and you get a runtime error like Unable to resolve service for type 'IOrderService'. That error only appears when a user hits the page, not when you build. So a small slip can sneak into production.

The manual registration pain

Add class
Add interface
Write AddScoped line
Forget a line
Runtime crash

Steps

1

Add class

You write a new service class

2

Add interface

It implements an interface

3

Write AddScoped line

You must remember to register it

4

Forget a line

Easy to miss in a long list

5

Runtime crash

App fails when the page is hit

Every new service forces another manual line and a chance to forget.

Installing Scrutor

Scrutor lives on NuGet. You add it like any other package. The current major version is 7.

// In a terminal, inside your project folder:
// dotnet add package Scrutor

That is the only setup. There is no new container to wire up, no Program.cs rewrite. Scrutor adds extra extension methods to the IServiceCollection you already use.

One nice thing worth saying clearly: Scrutor is free and open source under the MIT licence. Some popular .NET libraries, like MediatR and MassTransit, have moved to commercial licences in recent versions. Scrutor has not. You can use it in any project without paying.

Feature 1: Assembly scanning

Assembly scanning means "look through my compiled code (the assembly) and automatically register classes that match a rule."

Here is the simplest, most common rule. We tell Scrutor: find all classes, match each one to the interface it implements, and register them as scoped.

builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
        .AddClasses()
        .AsImplementedInterfaces()
        .WithScopedLifetime());

Read it like an English sentence:

  • FromAssemblyOf<OrderService>() — start from the assembly (project) that contains OrderService.
  • AddClasses() — take all public, non-abstract classes in it.
  • AsImplementedInterfaces() — register each class against the interfaces it implements.
  • WithScopedLifetime() — give them all a scoped lifetime.

Those four lines replace dozens of manual AddScoped lines. When you add a new service tomorrow, you write zero registration code. Scrutor finds it on the next run.

How Scrutor walks your assembly and registers matching classes

Filtering which classes to register

Scanning everything is sometimes too much. You may want only certain classes. Scrutor lets you filter with a simple condition. A very popular pattern is to register only classes whose name ends in Service, or only classes assignable to a marker interface.

builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
        .AddClasses(classes => classes.Where(t => t.Name.EndsWith("Service")))
        .AsImplementedInterfaces()
        .WithScopedLifetime()
    .AddClasses(classes => classes.AssignableTo<IRepository>())
        .AsImplementedInterfaces()
        .WithTransientLifetime());

Here we used two rules in one scan:

  1. Classes ending in Service become scoped.
  2. Classes that implement IRepository become transient.

This keeps the registration tidy and intentional, while still being automatic.

Choosing the right lifetime

Lifetime decides how long the container keeps one instance of your service. Picking the wrong one causes bugs. Here is a quick guide.

LifetimeOne instance perGood forWatch out for
SingletonWhole appCaches, config, stateless helpersNever hold per-request or per-user data
ScopedOne web requestEF Core DbContext, most servicesDo not inject into a singleton
TransientEvery time askedLight, cheap, stateless objectsCan create many objects fast

When you scan, you pick one lifetime for the whole rule. That is why splitting your scan into a few rules (one for services, one for repositories) is helpful. It lets each group get the correct lifetime.

The three service lifetimes and how long each instance lives

Feature 2: Decoration

The second power of Scrutor is decoration. This is based on a classic idea called the Decorator pattern. The goal is to add behaviour around an existing service without changing that service's code.

Imagine you already have a working IOrderService. Now your team wants every order call to be logged, and the result cached. You do not want to edit OrderService and fill it with logging and caching code. That would mix three jobs into one class and make it messy.

Instead, you write thin wrapper classes. Each wrapper takes the original service in its constructor, does its small extra job, then calls the original.

public class LoggingOrderService : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger<LoggingOrderService> _logger;
 
    public LoggingOrderService(IOrderService inner, ILogger<LoggingOrderService> logger)
    {
        _inner = inner;
        _logger = logger;
    }
 
    public async Task PlaceOrderAsync()
    {
        _logger.LogInformation("Placing order...");
        await _inner.PlaceOrderAsync();
        _logger.LogInformation("Order placed.");
    }
}

Notice the wrapper implements IOrderService too, and it holds an inner IOrderService. It logs, then forwards the real call. Now register it with Scrutor.

builder.Services.AddScoped<IOrderService, OrderService>();
 
// Wrap the real service with logging:
builder.Services.Decorate<IOrderService, LoggingOrderService>();
 
// You can stack another wrapper on top, for caching:
builder.Services.Decorate<IOrderService, CachingOrderService>();

When something asks for IOrderService, the container now hands back the caching wrapper, which holds the logging wrapper, which holds the real OrderService. Each layer does its job, then passes the call down. This is much cleaner than one line of Decorate versus the older, hand-written lambda approach.

A decorated service: the call passes through each wrapper to the real one

How decoration wraps a service

Real service
Add logging wrapper
Add caching wrapper
Container returns outer wrapper

Steps

1

Real service

OrderService does the core work

2

Add logging wrapper

Decorate adds logging around it

3

Add caching wrapper

Decorate adds caching around that

4

Container returns outer wrapper

Callers get the full stack

Each wrapper adds one concern, then calls the inner service.

Why decoration is so useful

Decoration shines for cross-cutting concerns. These are jobs that many services share but that are not the core job of any one service. Common examples:

  • Logging every call.
  • Caching results.
  • Retrying on failure.
  • Measuring how long a call takes.
  • Checking permissions before running.

You write each concern once as a wrapper, then attach it with one Decorate line. The core services stay focused and clean. This keeps your code following the Single Responsibility Principle, where each class has one reason to change.

ApproachWhere the extra code livesEasy to remove?Clutter in core class?
Edit the service directlyInside OrderServiceHardYes, lots
Use a decoratorIn a small wrapper classYes, delete one lineNo

Putting it all together

Here is a small but complete Program.cs that uses both features. It scans for services, registers a real implementation, and decorates it.

var builder = WebApplication.CreateBuilder(args);
 
// 1. Scan and auto-register everything ending in "Service" or "Repository".
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
        .AddClasses(c => c.Where(t =>
            t.Name.EndsWith("Service") || t.Name.EndsWith("Repository")))
        .AsImplementedInterfaces()
        .WithScopedLifetime());
 
// 2. Add cross-cutting behaviour with decorators.
builder.Services.Decorate<IOrderService, LoggingOrderService>();
 
var app = builder.Build();
app.MapControllers();
app.Run();

The result is short, readable, and grows with you. New services are picked up automatically. New concerns are added with one line.

Full pipeline: scan registers services, then decorators wrap chosen ones

Common mistakes to avoid

Scanning is powerful, so a few sensible cautions help.

  • Do not scan classes that need special setup. If a class needs a factory, options, or a custom lifetime per case, register it by hand. Scanning is for the simple majority.
  • Watch lifetimes carefully. A scan applies one lifetime to a whole group. Split scans so each group gets the right one. Never inject a scoped service into a singleton.
  • Decorate after the real registration. The Decorate call must come after the service it wraps is already registered, or Scrutor cannot find it to wrap.
  • Keep wrappers thin. A decorator should do one small thing and then call the inner service. Heavy logic inside a wrapper defeats the purpose.

Decision: scan or register by hand?

New service
Is setup simple?
Scan it
Register by hand

Steps

1

New service

You add a class and interface

2

Is setup simple?

No factory, no special options

3

Scan it

Let Scrutor pick it up

4

Register by hand

For tricky custom setups

A quick way to choose the right tool for each service.

When should you reach for Scrutor?

Scrutor is a great fit when:

  • Your project has many services and the registration list is getting long.
  • You follow a naming convention like SomethingService or use marker interfaces.
  • You want clean cross-cutting concerns through decorators instead of messy inline code.

It may be overkill when your app is tiny, with only a handful of services. In that case, a few plain AddScoped lines are perfectly clear, and adding a library is not worth it. Use the right tool for the size of the job.

Quick recap

  • Scrutor is a free, open-source library that extends the built-in ASP.NET Core DI container. It is not a replacement container.
  • Assembly scanning registers many services with one rule, instead of one line each. Use Scan, FromAssemblyOf, AddClasses, AsImplementedInterfaces, and a lifetime method.
  • Filtering lets you scan only classes you want, by name or by interface.
  • Decoration wraps a service to add behaviour like logging, caching, or retries, without editing the original class. Use Decorate and stack wrappers as needed.
  • Pick the correct lifetime (singleton, scoped, transient) for each group, and never inject a scoped service into a singleton.
  • Use scanning for the simple majority of services, and register tricky ones by hand.
  • Unlike MediatR and MassTransit, which now use commercial licences, Scrutor remains free under the MIT licence.

References and further reading

Related Posts