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.
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 growsEach 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
Steps
Add class
You write a new service class
Add interface
It implements an interface
Write AddScoped line
You must remember to register it
Forget a line
Easy to miss in a long list
Runtime crash
App fails when the page is hit
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 ScrutorThat 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 containsOrderService.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.
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:
- Classes ending in
Servicebecome scoped. - Classes that implement
IRepositorybecome 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.
| Lifetime | One instance per | Good for | Watch out for |
|---|---|---|---|
| Singleton | Whole app | Caches, config, stateless helpers | Never hold per-request or per-user data |
| Scoped | One web request | EF Core DbContext, most services | Do not inject into a singleton |
| Transient | Every time asked | Light, cheap, stateless objects | Can 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.
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.
How decoration wraps a service
Steps
Real service
OrderService does the core work
Add logging wrapper
Decorate adds logging around it
Add caching wrapper
Decorate adds caching around that
Container returns outer wrapper
Callers get the full stack
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.
| Approach | Where the extra code lives | Easy to remove? | Clutter in core class? |
|---|---|---|---|
| Edit the service directly | Inside OrderService | Hard | Yes, lots |
| Use a decorator | In a small wrapper class | Yes, delete one line | No |
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.
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
Decoratecall 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?
Steps
New service
You add a class and interface
Is setup simple?
No factory, no special options
Scan it
Let Scrutor pick it up
Register by hand
For tricky custom setups
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
SomethingServiceor 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
Decorateand 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
- Scrutor on GitHub (official repository and README)
- Scrutor on NuGet
- Microsoft Learn: Dependency injection in .NET
- Andrew Lock: Using Scrutor to automatically register your services
- Milan Jovanovic: Improving ASP.NET Core Dependency Injection With Scrutor
- Code Maze: Introduction to the Scrutor Library in .NET
Related Posts
Why I Switched to Primary Constructors for DI in C#
A friendly guide on why primary constructors made my C# dependency injection cleaner, with simple examples, diagrams, tables, and honest trade-offs.
Scheduling Background Jobs with Quartz.NET in ASP.NET Core
Learn Quartz.NET step by step in ASP.NET Core: jobs, triggers, cron schedules, dependency injection, and database persistence, explained for beginners.
TickerQ: The Modern .NET Job Scheduler That Beats Quartz and Hangfire
Learn TickerQ, the fast, reflection-free .NET job scheduler with cron and time jobs, EF Core storage, retries, and a live dashboard, explained for beginners.
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Master Configuration in ASP.NET Core with the Options Pattern
Learn the ASP.NET Core options pattern step by step: bind appsettings, use IOptions, IOptionsSnapshot, IOptionsMonitor, and validate config safely.
Using Scoped Services From Singletons in ASP.NET Core
Learn the safe way to use scoped services inside a singleton in ASP.NET Core using IServiceScopeFactory, with simple examples and clear diagrams.