Usando convenciones para mejorar IHandlerSelector

Hace poco puse un ejemplo de cómo utilizar handlers selectors para controlar los módulos activos en una aplicación y terminaba diciendo esto:

El ejemplo de IHandlerSelector que hemos visto es muy simple y adolece de un problema: falta de flexibilidad. Si mañana quiero añadir otro servicio dependiende de algún parámetro, tengo que tocar el IHandlerSelector. Próximamente veremos como se puede evitar eso aplicando algunas convenciones a nuestro código.

Pues bien, ha llegado el momento, así que vamos a ver cómo podemos mejorar lo que teníamos. Para ello, vamos a basarnos en un concepto bastante útil que es convention over configuration. La idea es que vamos a seguir una convención que nos permita identificar los servicios que dependen de un determinado módulo y diferenciar las implementaciones reales de las implementaciones vacías.

Empecemos por identificar de qué servicios depende de un determinado módulo. Hay varias formas de hacer esto, como usando una lista de servicios en alguna parte de la aplicación, utilizando un atributo o implementando un interface específico. Todas esas son viables, pero vamos a jugar con una convención:

Todos los servicios que dependen de un módulo cumplen que:

  • Su espacio de nombres está contenido en Modules
  • Su espacio de nombres acaba con el nombre del módulo

Es decir, que si tenemos un módulo que es el de Email, los interfaces de sus servicios y sus implementaciones estarán en un espacio de nombre tal que Company.Application.Modules.Email. La parte inicial del espacio de nombres puede ser cualquiera, pero el final debe corresponderse con el nombre del módulo. Esto nos llevaría a organizar el código así:

Estructura de carpetas

Ahora que ya sabemos identificar qué servicios forman parte de un módulo, necesitamos distinguir entre las implementaciones reales que usaremos cuando el módulo está habilitado y las implementaciones vacias que se usarán cuando el módulo esté desactivado. Una convención muy sencilla es la siguiente:

  • Todas implementaciones vacías son clases cuyo nombre empieza por Null.

Para facilitarnos trabajar con estas convenciones, vamos a definir un par de extension methods que dejen el código más legible:

public static class TypeModulesExtensions
{
    public static bool IsModuleService(this Type type)
    {
       var @namespace = type.Namespace ?? "";
       return Regex.IsMath(@namespace, @"\.Modules\..+");
    }
   
    public static string GetModule(this Type type)
    {
       if (!type.IsModuleService())
           throw new ArgumentException("Type is not a Module Service");
           
       return type.Namespace.Substring(type.Namespace.IndexOf(".Modules.") + ".Modules.".Length);
    }
   
    public static string IsNullImplementation(this Type type)
    {
       return type.Name.StartsWith("Null");
    }
   
    public static string IsNotNullImplementation(this Type type)
    {
       return !type.IsNullImplementation();
    }
}

Como con esta arquitectura podemos definir módulos «casi dinámicamente», simplemente incluyendo nuevas clases en los espacios de nombres adecuados, necesitamos que la clase que indica si un módulo está activo o no sea algo más flexible que el ILicenseHolder que usamos en el ejemplo anterior. El interface de la clase que define si un módulo está activo o no quedaría:

public interface IModuleConfiguration
{
    bool IsActive(string module);
}

Ya tenemos casi todas las piezas el puzzle. Sólo nos falta el IHandlerSelector:

public class ModuleHandlerSelector : IHandlerSelector
{
    private readonly IModuleConfiguration configuration;
   
    public ModuleHandlerSelector(IModuleConfiguration configuration)
    {   
       this.configuration = configuration;
    }

    public bool HasOpinionAbout(string key, Type service)
    {
       return service.IsModuleService();
    }
   
    public IHandler SelectHandler(string key, Type service, IHandler[] handlers)
    {
       var module = service.GetModuleName();
       var isActive = configuration.IsActive(module);
       
       if (isActive)
           return handlers.First(h => h.ComponentModel.Implementation.IsNotNullImplementation());
           
       return handlers.First(h => h.ComponentModel.Implementation.IsNullImplementation());
    }
}

Por dejarlo ya todo cerrado, la forma de configurar todo esto con un IWindsorInstaller sería:

public class ModulesInstaller : IWindsorInstaller
{
    public void Install(IWindsorContainer container, IConfigurationStore store)
    {
       container.Register(Classes.FromAssemblyNamed("MyAssembly")
                           .Where(type => type.IsModuleService())
                           .WithService.DefaultInterface();

       var selector = new ModuleHandler(new ModuleConfiguration());
       container.Kernel.AddHandlerSelector(selector);
    }
}

La idea de mezclar IHandlerSelectors con convenciones para que sepan lo que tienen que seleccionar es bastante potente, no sólo para el caso de módulos opcionales que puedan estar desactivados o no. Pensad también en distintas aplicaciones que se ejecuten sobre un núcleo común, en el que la personalización necesaria para cada aplicación se realiza mediante la inyección de distintas implementaciones de un mismo servicio. O en una misma aplicación personalizada para varios clientes. O en distintas capas de acceso a datos. Las posibilidades son enormes.

He dejado el código fuente de este post en un proyecto de pruebas en github.