Multi-site Optimizely Solutions: Dependency Injection in CMS 12

I have written multiple posts about how I architect and develop multi-site solutions in Episerver (now Optimizely). With the release of Optimizely CMS 12, which now runs on .NET 5, not much has changed with how I approach this, but the previous way I handled dependency injection is no longer supported.

In this post, I want to show how I'm now handling when multiple sites implement the same service (site-specific services).

This post builds off the original post in the "Multi-site Episerver Solutions using MVC Areas" series, so many of the namespaces and class names are defined in that post. If you haven't read that post yet (or the entire series), you should do so before continuing with this one.

The Setup

As we did before, let's first set the stage. It's fairly common in Optimizely to create and register a service implementation for dependency injection. In a single-site solution, this is a fairly trivial task.

We have this interface:

public interface IService
{
    string GetValue();
}

We implement the interface:

public class MyService : IService
{
    public string GetValue()
    {
        return "The value";
    }
}

We can register the implementation within Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddTransient<IService, MyService>();
    ...
}

We retrieve the service implementation (in different ways, like constructor injection):

public class MyClass
{
    private readonly IService _service;

    public MyClass(IService service)
    {
        _service = service;
    }
}

The Problem

Now, in a multi-site solution, you may need every site to implement the same service interface:

public class SiteAService : IService
{
    public string GetValue()
    {
        return "Site A value";
    }
}

public class SiteBService : IService
{
    public string GetValue()
    {
        return "Site B value";
    }
}

And then we register it for dependency injection:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddTransient<IService, SiteAService>();
    services.AddTransient<IService, SiteBService>();
    ...
}

Can you see the problem? When we call for the implementation of IService, we'll only get the implementation for "Site B". If we are in the context of "Site A", how do we get the service implementation for "Site A"?

The Solution

The way I previously solved this problem was by using named instances, but this was only supported through Episerver's StructureMap abstraction. In Optimizely CMS 12, dependency injection is now provided directly within .NET 5, and unfortunately named instances are not supported.

So how do we find the appropriate service for our site? Let's build a multi-site service resolver! To do this, we'll need to introduce some new conventions into how we build our services, but generally the approach is pretty straight-forward.

Service Updates

Let's first start with a simple interface called IMultiSiteService:

public interface IMultiSiteService
{
    string AreaName { get; }
}

Then for the interfaces of our site-specific services, we will inherit from that new interface:

public interface IService : IMultiSiteService
{
    string GetValue();
}

And when we implement each site-specific service, we'll just need to provide a value for AreaName:

public class SiteAService : IService
{
    public string AreaName => SiteAConstants.AreaName

    public string GetValue()
    {
        return "Site A value";
    }
}

public class SiteBService : IService
{
    public string AreaName => SiteBConstants.AreaName

    public string GetValue()
    {
        return "Site B value";
    }
}

As you see, we're reusing the AreaName from our SiteAConstants and SiteBConstants classes.

Multi-site Service Resolver

Now, we just need a way to get the site-specific service. This is where the MultiSiteServiceResolver is used:

namespace MySolution.Business.Services.Resolvers
{
    public class MultiSiteServiceResolver : IMultiSiteServiceResolver
    {
        private readonly IServiceProvider _serviceProvider;

        public MultiSiteServiceResolver(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }

        public T Resolve<T>(string areaName)
            where T : IMultiSiteService
        {
            IEnumerable<T> services = _serviceProvider.GetServices<T>();

            return services.SingleOrDefault(service => service.AreaName == areaName);
        }
    }

    public interface IMultiSiteServiceResolver
    {
        T Resolve<T>(string areaName) where T : IMultiSiteService;
    }
}

One of the primary reasons this works is because of the GetServices() method, which is part of the Microsoft.Extensions.DependencyInjection namespace. This method returns an enumeration of services of the requested type. From there, since we know our service implements IMultiSiteService, we can just return the service implementation that matches the provided AreaName. If no services are found, it just returns null.

Before we can use this, we'll need to make sure we register the resolver for dependency injection:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddTransient<IService, SiteAService>();
    services.AddTransient<IService, SiteBService>();

    services.AddTransient<IMultiSiteServiceResolver, MultiSiteServiceResolver>();
    ...
}

Retrieving

Now that we have the resolver and it's registered for dependency injection, we can access the site-specific service in our class constructor like this:

public class MyClass
{
    private readonly IService _service;

    public MyClass(IMultiSiteServiceResolver multiSiteServiceResolver)
    {
        _service = multiSiteServiceResolver.Resolve<IService>(SiteAConstants.AreaName);
    }
}

Now, this example assumes we know which site-specific service we need. But often when we need the implementation of the service, we'll likely not know which AreaName we're working with. In most cases, we'll want the service implementation that's specific to the current site that is being viewed. So we can again rely on getting the StartPage of the current site.

This part of the approach is not different to how it was previously done. We'll still create the IAreaStartPage interface and have each StartPage implement that interface.

So now when we want to get the service, it would look something like this:

var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
var areaName = contentLoader.Get<IAreaStartPage>(SiteDefinition.Current.StartPage).AreaName;

var resolver = ServiceLocator.Current.GetInstance<IMultiSiteServiceResolver>();
var service = resolver.Resolve<IService>(areaName);

(Note: I'm using ServiceLocator here for brevity, but I'd highly suggest you use a different way to get the service instances.)