Scrutor añade dos cosas que le faltan al contenedor de inyección de dependencias integrado de .NET: el escaneo de ensamblados, que registra todos nuestros servicios en una sola línea en lugar de decenas, y la decoración, que envuelve un servicio registrado con logging, almacenamiento en caché o validación sin tocarlo.

El repositorio de Scrutor describe el proyecto en una sola línea, “Extensiones de escaneo de ensamblados y decoración para Microsoft.Extensions.DependencyInjection”, y ese es exactamente su alcance. No es un contenedor de inyección de dependencias. Extiende IServiceCollection, así que no cambia nada de nuestra configuración actual.

Para descargar el código fuente de este artículo, puedes visitar nuestro repositorio de GitHub.

¿Qué es Scrutor?

Scrutor es un paquete NuGet que añade las dos características que a la mayoría de los equipos les faltan en la inyección de dependencias (DI) integrada de .NET: el escaneo de ensamblados y la decoración. El escaneo encuentra las implementaciones de nuestros servicios por convención y las registra con una sola llamada a Scan(), que sustituye a decenas de líneas AddScoped() escritas a mano que, de lo contrario, nos olvidamos de actualizar al añadir una clase.

La decoración envuelve un servicio ya registrado en otra implementación de la misma interfaz, para logging, almacenamiento en caché o validación, sin modificar la clase original ni su registro.

Scrutor no es un contenedor de inyección de dependencias y no sustituye a Microsoft.Extensions.DependencyInjection. Lo registra todo mediante métodos de extensión de IServiceCollection, así que los registros existentes, los tiempos de vida y el comportamiento del contenedor siguen exactamente igual. El paquete tiene licencia MIT, se mantiene activamente y la versión actual es Scrutor 7.0.0.

Si necesitamos módulos, intercepción o inyección de propiedades, eso es trabajo para un contenedor completo como Autofac. Para el escaneo y la decoración, basta con Scrutor sobre el contenedor integrado.

Instalación y primer escaneo

Instalamos Scrutor desde NuGet en un proyecto de API web creado con dotnet new webapi --use-controllers:

dotnet add package Scrutor

Después, escaneamos el ensamblado actual y registramos cada clase que encontramos para las interfaces que implementa:

builder.Services.Scan(scan => scan
    .FromAssembliesOf(typeof(Program))
    .AddClasses()
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Así, cada clase concreta y pública del ensamblado de la aplicación se registra para cada interfaz que implementa, con un tiempo de vida scoped.

Todo lo demás en este artículo es un refinamiento de esta única llamada: acotar qué clases seleccionamos, elegir cómo se mapean a las interfaces y controlar sus tiempos de vida.

Escaneo de ensamblados con Scrutor

Scrutor nos ayuda a simplificar el código de inyección de dependencias, ya que busca tipos dentro de los ensamblados de forma dinámica y los registra en tiempo de ejecución. Gracias al escaneo de ensamblados, podemos automatizar en parte el código de registro de dependencias.

Además, podemos usar el escaneo de ensamblados para construir sistemas extensibles. Con el escaneo, podemos crear un programa capaz de encontrar y cargar módulos adicionales en tiempo de ejecución.

Automatizar el registro de dependencias

En el proyecto de API web de ASP.NET, definamos una entidad User:

public class User
{
    public required int Id { get; init; }
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
}

Después, creemos una interfaz de servicio para gestionar la entidad User:

public interface IUserService
{
    User GetUser(int id);
}

En el espacio de nombres IntroductionToScrutorInDotNet.Services.Implementations, definamos una implementación de IUserService:

public class UserService : IUserService
{
    public User GetUser(int id)
    {
        return new User
        {
            Id = id,
            FirstName = "John",
            LastName = "Doe"
        };
    }
}

Por último, creemos un UsersController que nos permita consultar el usuario con una solicitud HTTP Get:

using Microsoft.AspNetCore.Mvc;

[Route("[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet("{id:int}")]
    public IActionResult GetUser(int id)
    {
        return Ok(_userService.GetUser(id));
    }
}

Ahora, tenemos que conectar UsersController con UserService, y podemos hacerlo con nuestro framework de inyección de dependencias.

En lugar de registrar UserService a mano, podemos usar el método de extensión Scan() de Scrutor para registrar todos los servicios automáticamente:

builder.Services.Scan(selector => selector
    .FromAssembliesOf(typeof(Program))
    .AddClasses(
        classSelector =>
            classSelector.InNamespaces("IntroductionToScrutorInDotNet.Services.Implementations")
    )
    .AsImplementedInterfaces()
);

Aquí, llamamos al método de extensión Scan() de Scrutor y le pasamos como argumento una acción de selección. El método escanea los ensamblados y selecciona los servicios que hay que registrar según el selector que le proporcionamos.

En primer lugar, tenemos que seleccionar el ensamblado en el que se encuentran los tipos que buscamos; aquí le indicamos a Scrutor el ensamblado que contiene Program mediante FromAssembliesOf(typeof(Program)). A continuación, acotamos todavía más la selección, para registrar solo clases, llamando al método AddClasses().

Scrutor 6.0.0 eliminó el antiguo método auxiliar FromCallingAssembly(), así que indicamos el ensamblado de forma explícita. FromAssembliesOf(typeof(Program)) también sigue funcionando si más adelante movemos este registro a un método de extensión de otro ensamblado y le pasamos typeof(Program) como argumento, un caso en el que FromCallingAssembly() habría escaneado el ensamblado equivocado.

El método AddClasses() también recibe un selector como parámetro, y lo usamos para seleccionar solo las clases del espacio de nombres IntroductionToScrutorInDotNet.Services.Implementations.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Por último, llamamos a AsImplementedInterfaces() para registrar los tipos seleccionados de modo que sustituyan a todas las interfaces que implementan. Lo hacemos porque UsersController depende de IUserService y no directamente de UserService.

Con la gran variedad de métodos que ofrece Scrutor, podemos ser muy precisos con los servicios que queremos registrar.

Registrar servicios de otro ensamblado

Tomemos otro proyecto de biblioteca de clases de .NET, IntroductionToScrutorInDotNet.Customers, que contiene tipos para gestionar la entidad Customer:

  • La definición de la entidad Customer
  • La interfaz ICustomerService
  • La implementación CustomerService de la interfaz ICustomerService

Copiamos este proyecto desde el repositorio de ejemplo junto a la carpeta del proyecto de API web y añadimos una referencia a él:

dotnet add reference ../IntroductionToScrutorInDotNet.Customers/IntroductionToScrutorInDotNet.Customers.csproj

CustomersController necesita usar ICustomerService para crear y devolver un cliente a partir de un User determinado:

using IntroductionToScrutorInDotNet.Customers.Services;
using Microsoft.AspNetCore.Mvc;

[Route("[controller]")]
[ApiController]
public class CustomersController : ControllerBase
{
    private readonly ICustomerService _customerService;

    public CustomersController(ICustomerService customerService)
    {
        _customerService = customerService;
    }

    [HttpPost]
    public IActionResult CreateCustomer(User user)
    {
        var fullName = string.Join(' ', user.FirstName, user.LastName);
        var customerId = Random.Shared.Next(1000);

        return Created(
            $"/Customers/{customerId}",
            _customerService.CreateCustomer(customerId, fullName)
        );
    }
}

Con Scrutor, después de añadir using IntroductionToScrutorInDotNet.Customers.Services; al principio de Program.cs, podemos registrar los tipos del ensamblado del proyecto IntroductionToScrutorInDotNet.Customers:

builder.Services.Scan(selector => selector
    .FromAssemblyOf<ICustomerService>()
    .AddClasses(classSelector =>
        classSelector.AssignableTo<ICustomerService>())
    .AsMatchingInterface());

Aquí, le pedimos a Scrutor que escanee solo el ensamblado en el que se encuentra la interfaz ICustomerService, mediante el método FromAssemblyOf<ICustomerService>(). Después, de todas las implementaciones de ese ensamblado, seleccionamos solo las que se pueden asignar a ICustomerService.

Por último, llamamos al método AsMatchingInterface() para indicarle a Scrutor que registre las implementaciones seleccionadas para las interfaces cuyo nombre coincide con el de la clase de implementación.

Trabajar con genéricos

Ahora, queremos añadir un repositorio para obtener la lista de Users. Primero, definimos la interfaz genérica del repositorio:

public interface IRepository<T>
{
    IEnumerable<T> GetAll();
}

Después, implementamos el repositorio para que use una lista como almacenamiento subyacente de la entidad User:

public class UserRepository : IRepository<User>
{
    private readonly List<User> _users = new()
    {
        new User
        {
            Id = 1,
            FirstName = "John",
            LastName = "Doe"
        },
        new User
        {
            Id = 2,
            FirstName = "Janine",
            LastName = "Doe"
        }
    };

    public IEnumerable<User> GetAll()
    {
        return _users;
    }
}

Actualicemos UsersController, conservando sus atributos y la acción GetUser(), para que use el repositorio y obtenga todos los usuarios que tenemos en el sistema:

public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly IRepository<User> _userRepository;

    public UsersController(IUserService userService, IRepository<User> userRepository)
    {
        _userService = userService;
        _userRepository = userRepository;
    }

    [HttpGet]
    public IActionResult GetUsers()
    {
        return Ok(_userRepository.GetAll());
    }
}

Por último, le indicamos a Scrutor que registre todas las implementaciones del tipo genérico abierto IRepository<> para las interfaces que implementan:

builder.Services.Scan(selector => selector
    .FromAssembliesOf(typeof(Program))
    .AddClasses(classSelector => classSelector.AssignableTo(typeof(IRepository<>)))
    .AsImplementedInterfaces());

Esto registra UserRepository para sustituir cualquier dependencia de IRepository<User>.

Especificar el tiempo de vida de las dependencias

Como en cualquier framework de gestión de dependencias, especificar el tiempo de vida de las instancias que crea el contenedor es una parte esencial del registro de dependencias. Con Scrutor, podemos usar especificadores de tiempo de vida:

builder.Services.Scan(selector => selector
    .FromAssemblyOf<ICustomerService>()
    .AddClasses(classSelector =>
        classSelector.AssignableTo<ICustomerService>())
    .AsMatchingInterface()
    .WithTransientLifetime()
);

En este ejemplo, llamamos a WithTransientLifetime() para registrar con un tiempo de vida transient todas las implementaciones que coinciden. Hay métodos similares para los tiempos de vida habituales de .NET, por ejemplo WithSingletonLifetime() o WithScopedLifetime().

Manejar varias implementaciones

Veamos ahora cómo podemos establecer la estrategia de registro que queramos para cada selector. La estrategia de registro es la forma en que Scrutor maneja los casos en los que existen varias implementaciones de la misma interfaz. RegistrationStrategy está en el espacio de nombres Scrutor, así que también añadimos using Scrutor; al principio de Program.cs:

builder.Services.Scan(scan =>
    scan.FromAssemblyOf<ICustomerService>()
        .AddClasses(classes => classes.InExactNamespaceOf<ICustomerService>())
        .UsingRegistrationStrategy(RegistrationStrategy.Skip)
        .AsImplementedInterfaces()
);

En este código, al usar el método UsingRegistrationStrategy() con el parámetro RegistrationStrategy.Skip, le indicamos a Scrutor que ignore las implementaciones adicionales de cualquier interfaz una vez registrada la primera.

Otras estrategias de registro disponibles son:

  • RegistrationStrategy.Append: añade un nuevo registro para los servicios que ya existen
  • RegistrationStrategy.Throw: lanza una excepción al intentar registrar un servicio que ya existe

Encadenar varios registros

Hasta ahora, hemos configurado Scrutor por separado para cada parte de la aplicación. Por simplicidad y claridad, es posible registrarlo todo con una sola llamada al método Scan().

Gracias a que los distintos métodos se pueden encadenar, podemos sustituir todas las configuraciones de Scrutor que hemos escrito por una sola llamada:

builder.Services.Scan(selector =>
    selector
        .FromAssembliesOf(typeof(Program))
        .AddClasses(
            classSelector =>
                classSelector.InNamespaces("IntroductionToScrutorInDotNet.Services.Implementations")
        )
        .AsImplementedInterfaces()
        .AddClasses(classSelector => classSelector.AssignableTo(typeof(IRepository<>)))
        .UsingRegistrationStrategy(RegistrationStrategy.Skip)
        .AsImplementedInterfaces()
        .FromAssemblyOf<ICustomerService>()
        .AddClasses(classSelector =>
            classSelector.AssignableTo<ICustomerService>())
        .AsMatchingInterface()
        .WithTransientLifetime()
);

Patrón decorador con Scrutor

Podemos usar el patrón de diseño decorador para ampliar la funcionalidad de un objeto existente sin cambiar su código. Para ello, un decorador se diseña como un envoltorio alrededor de los objetos que hay que ampliar, de modo que puede interceptar las llamadas a los métodos del objeto envuelto.

Implementar un decorador sencillo

Para mostrar cómo podemos aprovechar el método de extensión Decorate() de Scrutor para gestionar el registro de objetos decoradores, definamos un decorador de nuestro repositorio que registre un mensaje en la consola cuando accedamos a una lista de entidades.

Para ello, tenemos que implementar la misma interfaz que implementa el repositorio:

public class RepositoryLoggerDecorator<T> : IRepository<T>
{
    private readonly IRepository<T> _decoratedRepository;

    public RepositoryLoggerDecorator(IRepository<T> decoratedRepository)
    {
        _decoratedRepository = decoratedRepository;
    }

    public IEnumerable<T> GetAll()
    {
        Console.WriteLine("The list of all users has been retrieved from the DB");

        return _decoratedRepository.GetAll();
    }
}

El decorador debe recibir en su constructor la instancia del repositorio decorado; en nuestro caso, es la instancia de IRepository<T> que vamos a envolver. En el método GetAll(), registramos un mensaje en la consola y después llamamos al método GetAll() de _decoratedRepository.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Registrar decoradores con Scrutor

Pasemos a registrar el decorador en el contenedor de dependencias con el método de extensión Decorate<,>() de Scrutor:

builder.Services.Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>();

El primer argumento de tipo del método Decorate<,>() indica el tipo de servicio que queremos decorar, y el segundo argumento de tipo es el tipo del decorador.

Una vez registrado el decorador con Scrutor, RepositoryLoggerDecorator<User> intercepta todas las llamadas a los métodos de IRepository<User>. Si ejecutamos la aplicación y llamamos al endpoint HTTP Get para obtener todos los usuarios, vemos que el mensaje de log se escribe en la consola.

En este ejemplo, hemos usado el método Console.WriteLine() para registrar el mensaje; en un caso real, deberíamos usar ILogger<T> y un framework de logging.

Errores comunes con Scrutor

Las llamadas a Scrutor anteriores son cortas, pero algunos comportamientos sorprenden a los equipos la primera vez que se topan con ellos. Todos los que siguen se han comprobado con Scrutor 7.0.0.

Cada llamada a Scan() añade registros nuevos de forma predeterminada. Si ejecutamos dos escaneos que encuentran UserRepository, quedan dos registros de IRepository<User>, y IEnumerable<IRepository<User>> resuelve entonces ambos.

La solución es establecer una estrategia explícita: UsingRegistrationStrategy(RegistrationStrategy.Skip), o RegistrationStrategy.Throw durante el desarrollo para detectar pronto los escaneos que se solapan.

Los registros escaneados son transient a menos que indiquemos lo contrario. Un repositorio que dábamos por hecho que era scoped se crea cada vez que se resuelve, hasta que añadimos un tiempo de vida explícito.

La solución es encadenar el tiempo de vida que realmente queremos, como WithScopedLifetime(); si los tiempos de vida de la inyección de dependencias importan para un servicio, nunca hay que confiar en el valor predeterminado.

Decorar un servicio que nunca se registró lanza una excepción. Llamar a Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>() antes de que alguna llamada a Scan() o Add*() registre IRepository<User> lanza una Scrutor.DecorationException con el mensaje “Could not find any registered services for type ‘IntroductionToScrutorInDotNet.Repositories.IRepository<IntroductionToScrutorInDotNet.Entities.User>'”.

La solución está en el orden: ejecuta primero todos los registros con Scan() y Add*(), y deja todas las llamadas a Decorate() para el final.

Los genéricos abiertos se decoran mediante la sobrecarga no genérica. Una vez que el escaneo ha registrado el tipo cerrado IRepository<User>, tanto Decorate<IRepository<User>, RepositoryLoggerDecorator<User>>() como la forma genérica abierta resuelven el decorador:

builder.Services.Decorate(typeof(IRepository<>), typeof(RepositoryLoggerDecorator<>));

La sobrecarga para genéricos abiertos es la que conviene usar cuando registramos muchos tipos cerrados de una misma interfaz genérica y no queremos nombrarlos uno a uno.

Scrutor vs Autofac vs DI integrada

Scrutor se sitúa entre el contenedor integrado y uno completo como Autofac. La tabla compara dónde queda cada opción:

CriterioDI integradaIntegrada + ScrutorAutofac
Escaneo de ensambladosNoSíSí
DecoradoresLambdas de fábrica escritas a manoUna llamada a Decorate()Sí
Inyección de propiedadesNoNoSí
Módulos / contenedores secundariosNoNoSí
Servicios con claveSí (.NET 8+)Sí (el mismo contenedor)Sí
Dependencia adicionalNingunaUn paquete pequeño con licencia MITCambiar todo el contenedor
Cuándo elegirlaPocos servicios, registro explícitoQuieres convenciones, no un contenedor nuevoNecesitas intercepción, módulos o tiempos de vida avanzados

Las directrices de inyección de dependencias de Microsoft lo dicen sin rodeos: “Recomendamos usar el contenedor integrado, a menos que necesites una característica concreta que no admita”. Las características que mencionan son la inyección de propiedades, los contenedores secundarios, la gestión personalizada del tiempo de vida, la compatibilidad con Func<T> para la inicialización diferida y el registro basado en convenciones. Scrutor ocupa el término medio: mantenemos el contenedor integrado, con sus servicios con clave en .NET y su integración de primer nivel con ASP.NET Core, y solo añadimos las convenciones.

Cambiar por completo a Autofac en un proyecto .NET aporta intercepción y módulos a cambio de una segunda API de registro que todos los miembros del equipo tienen que aprender.

Cuándo recurrir a Scrutor

Recurrimos a Scrutor cuando el código de registro deja de aportar información, cuando Program.cs es un muro de líneas AddScoped() que nadie lee y que todo el mundo se olvida de actualizar. El escaneo lo resuelve con una sola llamada.

También recurrimos a él en cuanto queremos un decorador sin escribir a mano el patrón de fábrica con inyección de dependencias.

No recurrimos a él para sustituir el contenedor. Si lo que necesitamos es intercepción, módulos o inyección de propiedades, eso es terreno de Autofac. Para el escaneo y la decoración, Scrutor es la herramienta más pequeña que sirve.

Probado con .NET 10.0.10 y Scrutor 7.0.0.