avatarAbnoan Muniz

Summary

The web content discusses the use of Scrutor, a library that simplifies dependency injection in .NET 7 applications by automating the registration of services based on conventions and criteria.

Abstract

The article "Automatic Dependency Injection with Scrutor in .NET 7" delves into the Scrutor library's capabilities to streamline the process of registering services for dependency injection. It explains how Scrutor can automatically scan and register types that adhere to specific patterns, such as implementing a particular interface or carrying a distinct attribute, thus reducing manual registration efforts. The author provides examples of how to use Scrutor's methods to register services with different lifetimes (singleton, scoped, transient) and strategies (skip, append, replace), and emphasizes best practices for service registration, such as establishing clear conventions, embracing namespace segregation, and documenting registration logic. The article also addresses common issues encountered when using Scrutor and offers troubleshooting tips to ensure a robust dependency injection setup.

Opinions

  • The author suggests that consistent naming conventions and namespace organization are crucial for effective use of Scrutor.
  • Scoped lifetimes are recommended as a default choice for service registrations, aligning with the request/response nature of web applications.
  • Explicit registrations for critical services are advised to enhance clarity and ease debugging.
  • The use of attributes and predicates for filtering types during registration is encouraged for customization and maintainability.
  • The author emphasizes the importance of strategic service registration to avoid over-registration and unnecessary memory usage.
  • Regular reviews of service registrations are recommended as part of the refactoring process to ensure they reflect the application's architecture.
  • Integration testing is advocated to verify the correctness of the dependency injection configuration.
  • Caution is advised when updating Scrutor and related packages to maintain compatibility and avoid disruptions.
  • The author highlights the benefits of using Scrutor's registration strategies to control how multiple implementations of an interface are handled.
  • Performance considerations are important when scanning assemblies, and developers should be selective about which assemblies to scan.
  • The article concludes by promoting Scrutor as a tool that can enhance the quality and productivity of .NET code, suggesting that developers should consider integrating it into their projects.

Automatic Dependency Injection with Scrutor in .NET 7

Simplifying .NET Dependency Injection

Cover created by the Author.

In specific scenarios, manually registering each service required for injection into our classes can be laborious and repetitive. For instance, if we possess an IRepository interface with multiple implementing classes like CustomerRepository, ProductRepository, OrderRepository, etc, each would need individual registration within the ServiceCollection.

Scrutor is a library that simplifies the registration process for dependency injection, leveraging the Microsoft.Extensions.DependencyInjection framework. It facilitates the loading of assemblies and registration of types that meet specific criteria, such as implementing a particular interface or carrying a distinct attribute.

In this article, we will explore how to utilize Scrutor to streamline dependency injection neatly and succinctly in .NET 7.

Prerequisites

  • Visual Studio 2022 (.NET 7.0)
  • NuGet Packages: Scrutor

Solution

To employ Scrutor, you should invoke the Scan method on the IServiceCollection instance and supply a lambda expression that defines how to identify and register your services. For example, the following code will inspect the current assembly and register all public, non-abstract classes that implement an interface sharing the name but minus the initial "I":

public static IServiceCollection AddClassesMatchingInterfaces(this IServiceCollection services, string @namespace)
{
    var assemblies = DependencyContext.Default.GetDefaultAssemblyNames()
                    .Where(assembly => assembly.FullName.StartsWith(@namespace))
                    .Select(Assembly.Load);

    services.Scan(scan => scan.FromAssemblies(assemblies)
                              .AddClasses()
                              .UsingRegistrationStrategy(RegistrationStrategy.Skip)
                              .AsMatchingInterface()
                              .WithScopedLifetime());

    return services;
}

This is analogous to individually registering each service like so:

services.AddScoped<ICustomerService, CustomerService>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IBusinessService, BusinessService>();

This approach keeps your Program.cs much tidier:

var builder = WebApplication.CreateBuilder(args);

// Additional setup...

builder.Services.AddClassesMatchingInterfaces(nameof(Sample));

var app = builder.Build();

// Application configuration...

app.Run();

Scrutor can save a substantial amount of code, making your registrations more consistent and maintainable.

You can also use other methods to refine the types, such as TakingAttribute, AssignableTo, or InNamespace:

services.Scan(scan => scan
    .FromAssembliesOf(typeof(IUserService))
    .AddClasses(classes => classes.HavingAttribute<CustomServiceAttribute>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

A noteworthy feature of Scrutor is the ability to register services AsSelf, meaning the service is registered using its own type as the service type rather than an interface or base class. This can be useful when you want to resolve the service by its concrete type or when there is no interface or base class for your service.

services.Scan(scan => scan
    .FromAssembliesOf(typeof(IUserService))
    .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service")))
    .AsSelf()
    .WithScopedLifetime());

The process of registering dependency injection can be customized using different methods and options, for example:

  • Specify different assemblies or namespaces to scan.
  • Filter classes by name, attribute, or a custom predicate.
  • Register classes as themselves, as a specific interface, or as all implemented interfaces.
  • Use different lifetimes (singleton, scoped, transient) or custom functions.
  • Overwrite or decorate existing registrations.

Best Practices for Service Registration Using Scrutor

When employing Scrutor for service registration in your .NET projects, adherence to best practices ensures that your dependency injection setup is robust and maintainable. Below are a few recommendations to optimize your use of Scrutor for organizing service registrations.

Start with a Clear Convention

Establish naming and structuring conventions for your interfaces and implementations. Consistent patterns, such as suffixing interfaces with Service and implementations with ServiceImpl can significantly streamline the automatic registration process with Scrutor.

Embrace Namespace Segregation

Organize your classes and interfaces into appropriate namespaces that reflect their domain or functionality. This segregation simplifies service scanning and makes your registrations more predictable.

Use Scoped Lifetimes Judiciously

Default to scoped lifetimes unless there's a compelling reason for singleton or transient. This choice often aligns well with the request/response nature of web applications and ensures that services are disposed of properly.

Prefer Explicit Registrations

When possible, use explicit registrations for critical services. This enhances clarity and can make debugging more accessible if the automatic registration does not behave as expected.

Leverage Assembly Scanning

Scrutor can scan entire assemblies to find and register services. Use this feature to register services in bulk where appropriate, but be mindful of inadvertently registering unnecessary services.

Customize with Filters

Scrutor provides a flexible way to register services using custom filters. You can filter the types to be registered based on specific criteria, such as class names, attributes, or any other custom logic encapsulated in a predicate.

Predicates: You can provide a predicate function that takes a Type and returns a bool to indicate whether the type should be registered. This allows you to register only those types that match certain conditions.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

In the above code, Where is used to filter classes. Only those classes from the calling assembly whose names end with "Service" will be registered as implementing their interfaces with a scoped lifetime.

Attributes: You can also filter types based on whether they have a specific attribute applied. This is useful when you want to register services that have been marked in a certain way, for example, with a custom [RegisterDependency] attribute.

Suppose you have a custom attribute named RegisterDependencyAttribute. You can filter registrations to include only those classes that are decorated with this attribute:

[AttributeUsage(AttributeTargets.Class)]
public class RegisterDependencyAttribute : Attribute
{
    public ServiceLifetime Lifetime { get; }

    public RegisterDependencyAttribute(ServiceLifetime lifetime)
    {
        Lifetime = lifetime;
    }
}

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.WithAttribute<RegisterDependencyAttribute>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

In this code snippet, WithAttribute extension method filters the classes to only those that have the RegisterDependencyAttribute applied. The registration is then done based on the implemented interfaces with a scoped lifetime, though you could further refine this to use the lifetime specified in the attribute if desired.

Avoid Service Registration Overload

Be strategic about what you register. Over-registering can lead to a bloated service collection and increased memory usage. Aim for a balance between convenience and necessary registrations.

Keep Registrations Maintainable

Regularly review your service registrations as part of your refactoring process. Ensure that obsolete services are removed and that changes in your application's architecture are reflected.

Document Your Registration Logic

While Scrutor reduces the need for boilerplate code, it's crucial to document the logic behind your registration patterns, especially for teams that may need to become more familiar with Scrutor's scanning conventions.

Test Your DI Configuration

Implement integration tests that verify the correctness of your dependency injection setup. This can catch issues where services are not registered properly or have unintended lifetimes.

Update With Caution

Be cautious when updating Scrutor and other related packages. Ensure compatibility and test thoroughly to avoid disruptions caused by changes in the library.

Use Registration Strategies

In Scrutor, registration strategies define how the library handles multiple registrations of the same service type. Here's an explanation of the Skip, Append, and Replace strategies:

Skip: When using the Skip registration strategy, Scrutor will ignore any subsequent registrations of a service if it has already been registered. This strategy is useful when you want to ensure that only the first registration of a service is used and any others found during the scanning process are disregarded. This can prevent accidental overrides of intended service implementations.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.AssignableTo<IRepository>())
    .UsingRegistrationStrategy(RegistrationStrategy.Skip)
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Append: The Append strategy allows multiple registrations of the same service type. With this strategy, Scrutor adds each new registration alongside the previous ones. When resolving services, all registrations will be available, typically in the form of an IEnumerable<T> where T is the service type. This is particularly useful when injecting a collection of services that share the same interface.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.AssignableTo<IRepository>())
    .UsingRegistrationStrategy(RegistrationStrategy.Append)
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Later, you might resolve all implementations like this:
// IEnumerable<IRepository> repositories = serviceProvider.GetServices<IRepository>();

Replace: The Replace strategy will override previous registrations with the latest one. If a service type is already registered and is reencountered with the Replace strategy, the new registration will supersede the existing one. This strategy is handy when you want to ensure that the most recent registration is the one utilized by the application, which can be the case when you're replacing default implementations with custom ones.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.AssignableTo<IRepository>())
    .UsingRegistrationStrategy(RegistrationStrategy.Replace)
    .AsImplementedInterfaces()
    .WithScopedLifetime());

In this scenario, if IRepository it is already registered, it will be replaced by the latest found implementation during the scan.

Selecting the appropriate registration strategy is critical to achieving the desired behavior in your service resolution process. It allows for greater control and customization of the dependency injection framework behavior to suit the specific needs of your application.

Consider Assembly Loading Implications

When loading assemblies for scanning, be aware of the potential performance impact and ensure that only necessary assemblies are loaded to avoid unnecessary overhead.

Troubleshooting Common Issues with Scrutor

When integrating Scrutor for dependency injection in .NET projects, you may face several common issues. Below are some frequent challenges and their solutions.

Service Not Registered

One common issue is that a service expected to be registered by Scrutor isn't available for injection.

Solution: Ensure that the assembly containing the service is being scanned. Check if the service's accessibility level is public and if it implements the interface you are trying to resolve.

services.Scan(scan => scan
    .FromAssemblyOf<SomeKnownType>()
    .AddClasses() // Make sure classes are public
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Incorrect Service Lifetime

Sometimes, services may be registered with an unintended lifetime, causing issues like a singleton holding state unexpectedly.

Solution: Verify the lifetime specified in the Scrutor chain. Use attributes or predicates to control the lifetime of each service if they differ.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses()
    .AsImplementedInterfaces()
    .WithLifetime(ServiceLifetime.Singleton)); // Be explicit about the desired lifetime

Multiple Implementations Cause Confusion

If multiple implementations of an interface are registered, and you're expecting to inject a specific one, this can lead to the wrong implementation being injected.

Solution: Use the Replace strategy or control registration order using Skip or Append appropriately. Alternatively, resolve an IEnumerable of the service to get all implementations.

services.Scan(scan => scan
    .FromCallingAssembly()
    .AddClasses(classes => classes.Where(type => type.Name.Contains("Preferred")))
    .UsingRegistrationStrategy(RegistrationStrategy.Replace)
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Assembly Scanning Performance

Scanning a large number of assemblies can impact startup performance.

Solution: Limit the assemblies to scan by being more selective, or consider using lazy loading for services where appropriate.

services.Scan(scan => scan
    .FromAssemblies(AppDomain.CurrentDomain.GetAssemblies().Where(a => a.FullName.StartsWith("MyApp")))
    .AddClasses()
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Decorators Not Applied as Expected

When trying to apply decorators to services, they may not work as expected if the registration order is incorrect or if the service type is not registered properly.

Solution: Ensure that the decorated service is registered before applying the decorator. Use the Decorate extension method provided by Scrutor.

services.AddTransient<IService, MyService>();
services.Decorate<IService, MyServiceDecorator>();

Changes Not Reflected in Runtime

After updating service registration logic, sometimes the changes do not seem to be reflected when the application runs.

Solution: Clear the build output and rebuild the project. Dependency injection is set up at application startup, and stale build artifacts can cause old configurations to persist.

Final Thoughts

Scrutor enables scanning and registering dependency injections with minimal code and supports various customization options to meet your needs. If you're utilizing dependency injection in your .NET applications, it is advisable to try out Scrutor and see how it can enhance the quality and productivity of your code.

If you like the article and would like to support me, make sure to:

  • 👏 Clap & highlight to feature it.
  • 💬 Share your thoughts in the comments.
  • 👉 Follow me on Medium
  • 🔔 Get in Touch: LinkedIn

Take advantage of my other stories! 👇🔽

C Sharp Programming
Software Development
Programming
Scrutor
Dependency Injection
Recommended from ReadMedium