Implementing a Custom multi tenant View Engine in MVC

Quite often websites need to support themes or allow multi tenant access within a single application. Views in MVC do a good job of separating display logic, but in a standard out of the box scenario returning alternate views for different tenants is not always straightforward.

One approach would be to pro-grammatically select the view that should be used on Action Result:

public ActionResult Home()
{
    var theme = // Do stuff to get theme location
    string view = $"~/Views/Theme/{theme}/Home.cshtml";
    return View(view);
}

The drawback is that the view must exist for all tenants; for a large site this means duplicated code effort and a maintenance headache. It also hard codes the view to a single location which further restricts flexibility.

The goal is to share common view markup while allowing customisation if required. This can be achieved by implementing a custom view engine and modifying the default location formats.

The way the application determines the theme to use is out of scope here but it could be based on URL, session or some other means.

Only a handful of methods need to be overridden in the custom view engine, in this example the standard RazorViewEngine is extended but any other view engine would work:

public class MyCustomRazorViewEngine : RazorViewEngine
{

    public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
    {
        return base.FindPartialView(controllerContext, partialViewName, false);
    }

    public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
    {
        return base.FindView(controllerContext, viewName, masterName, false);
    }

    protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
    {
        return base.CreatePartialView(controllerContext, ReplacePath(controllerContext, partialPath));
    }

    protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
    {
        return base.CreateView(controllerContext, ReplacePath(controllerContext, viewPath), ReplaceThemePath(controllerContext, masterPath));
    }

    protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
    {
        return base.FileExists(controllerContext, ReplacePath(controllerContext, virtualPath));
    }

    private string ReplacePath(ControllerContext controllerContext, string path)
    {
       var theme = // Do stuff to get theme;
       return path.Replace("%1", theme);
    }
}

The ReplacePath method simply modifies the location based on whatever mechanism the application uses to determine the theme.

On application start the new custom view engine is registered and configured for the Custom view location formats, this is where the replacement token comes in:

ViewEngines.Engines.Clear();

var viewLocationFormats = new[] {
    "~/Views/Theme/%1/{1}/{0}.cshtml",
    "~/Views/{1}/{0}.cshtml"
};

var partialViewLocationFormats = new[] {
    "~/Views/Theme/%1/Shared/{0}.cshtml",
    "~/Views/Theme/%1/Partials/{0}.cshtml",
    "~/Views/Shared/{0}.cshtml",
    "~/Views/Partials/{0}.cshtml",
};

var engine = new MyCustomRazorViewEngine();

engine.ViewLocationFormats = viewLocationFormats;
engine.PartialViewLocationFormats = partialViewLocationFormats;

ViewEngines.Engines.Add(engine);

One downside to this is that the ViewEngine would normally cache the location once it finds a match, internally this is being keyed on controller and action. Because the path is being modified to return different views for the same key the caching scheme does not distinguish between the themed views.

This means the first match will always be returned for all themes if the cache is used. A work around is to force useCache to false in the overridden methods so there is a slight performance impact with this setup.

This was tested with MVC