ASP.NET Core Multi-tenancy: Creating theme-able applications

This is my third post in a series on building multi-tenant applications with ASP.NET Core.

The first post covered the fundamentals of multi-tenancy, tenant resolution and introduced the SaasKit library. In the second post I explained how to control the lifetime of your tenant instances using a caching tenant resolver.

In this post things get more interesting as we look at how to support theming and overriding views for individual tenants.

Theming is quite common in multi-tenant or SaaS applications. It enables you to provide your users (tenants) similar functionality but give them control over the design of their site. In Fabrik our customers can choose from a number of themes, each of which can be further customised:

Fabrik Themes

Fabrik Customise Theme

Themes were a native feature of ASP.NET Web Forms (perhaps they still are) but were limited to stylesheets and skins (property configurations for ASP.NET controls). ASP.NET MVC was more flexible and it became possible to create theme-specific views by building a custom view-engine. Doing it properly however was fairly involved, especially if you wanted your view locations to be cached correctly.

Theming in ASP.NET Core is much easier and there are a few ways it can be achieved. We'll start with a very simple approach, using theme-specific layout pages.

The Sample Application

I'm building upon the sample MVC application I used in the other posts.

First the tenant class (AppTenant) needs to be extended to support the concept of themes:

public class AppTenant
{
    public string Name { get; set; }
    public string[] Hostnames { get; set; }
    public string Theme { get; set; }
}

Setting a tenant's theme is done by updating appsettings.json:

"Multitenancy": {
  "Tenants": [
    {
      "Name": "Tenant 1",
      "Hostnames": [
        "localhost:6000",
        "localhost:6001"
      ],
      "Theme": "Cerulean"
    },
    {
      "Name": "Tenant 2",
      "Hostnames": [
        "localhost:6002"
      ],
      "Theme": "Darkly"
    }
  ]
}

For this example I downloaded a couple of Bootstrap themes from Bootswatch and placed them in /wwwroot/css/themes:

AspNetMvcSample\wwwroot\css\site.css
AspNetMvcSample\wwwroot\css\site.min.css
AspNetMvcSample\wwwroot\css\themes
AspNetMvcSample\wwwroot\css\themes\cerulean.css
AspNetMvcSample\wwwroot\css\themes\darkly.css

Gulp was also updated to only include stylesheets in the root of wwwroot\css otherwise it would bundle all of the theme stylesheets together. In gulpfile.js:

paths.css = paths.webroot + "css/*.css";

Creating Theme Layout Pages

A simple approach to theming in ASP.NET Core MVC is to create a layout page for each theme that references specific stylesheets and scripts.

ViewStart (_ViewStart.cshtml) is a special file in MVC that can be used to define common view code that will be executed at the start of each View’s rendering. A common use of ViewStart is to set the layout of your views in a single location. ASP.NET Core MVC builds on this concept further introducing _ViewImports.cshtml, a new file that can be used to add using statements and tag helpers into your views. You can also use it to inject common application dependencies.

We'll take a copy of the default layout page and create versions for each theme:

_Layout.cshtml
_Layout_Cerulean.cshtml
_Layout_Darkly.cshtml

Each theme layout references the associated stylesheet downloaded from Bootswatch:

  <environment names="Development">
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
    <link rel="stylesheet" href="~/css/site.css" />
    <link rel="stylesheet" href="~/css/themes/darkly.css" />
  </environment>
  <environment names="Staging,Production">
    <link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.5/css/bootstrap.min.css"
          asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
          asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" />

    <link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/css/themes/darkly.css" />
  </environment>

_ViewStart.cshtml is then updated to set the layout based on the theme configured for the current tenant. The AppTenant instance is injected into the view using the @inject helper:

@inject AppTenant Tenant;
@{
    Layout = $"_Layout_{Tenant.Theme}";
}

You can run the application by running the following from a command-prompt or terminal:

dnx web

This will start the Kestrel web server and listen on the ports mapped to the tenants in appsettings.json.

When you browse to http://localhost:6000 (Tenant 1 - Cerulean theme) you should see the following:

Tenant 1 theme

Browsing to http://localhost:6002 (Tenant 2 - Darkly theme) displays a different theme:

Tenant 2 theme

Creating Theme Views

The above method works great if the pages in your application are the same for all of your themes, i.e. there is no change in page markup. In Fabrik, our themes differ significantly so only changing stylesheets and scripts isn't enough.

The solution is to provide theme specific versions of your views. You can either provide a themed version for every view or just an override, falling back to "default" views where necessary.

To do this in the previous of ASP.NET MVC we had to build our own view-engine and as I mentioned earlier, this was quite complicated to do correctly.

The Razor view-engine in ASP.NET Core MVC makes this easier with View Location Expanders. This new feature enables you to control the paths in which the view-engine will search for views. It will also take care of building and maintaining a view location cache, even when using dynamic variables.

To create a View Location Expander you must create a class that implements IViewLocationExpander:

public interface IViewLocationExpander
{
    IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations);
    void PopulateValues(ViewLocationExpanderContext context);
}

The ExpandViewLocations method is invoked to determine the potential locations for a view. PopulateValues determines the values that will be used by the view location expander. This is where you can add information that can be used to dynamically build your view location paths. ExpandViewLocations will only be invoked if the values returned from PopulateValues have changed since the last time the view was located - essentially this means the caching is taken care of for you. Well done ASP.NET team!

Before we create our own View Location Expander we need to restructure our application. I prefer to keep my themes separate so I'm going with the following directory structure:

/themes
  /cerulean
    /shared
      _layout.cshtml
  /darkly
    /home
      about.cshtml
    /shared
      _layout.cshtml
/views
  /home
    /...
  /shared
    /...

Note that I'm following the same view directory naming convention used by the default view-engine.

In the above example we want our theme layout pages to override the default (/views/shared/_layout.cshtml) and when using the Darkly theme, the about.cshtml should override the default version.

Finally, I'm going to reset _ViewStart.cshtml back to it's original code, which sets the default layout page to _Layout:

@{
    Layout = "_Layout";
}

Creating the View Location Expander

By default, the Razor view-engine will search the following paths for the views:

/Views/{1}/{0}.cshtml
/Views/Shared/{0}.cshtml

{1} - Controller Name
{0} - View/Action Name

So when invoking the Index action from HomeController it will look in the following locations:

/Views/Home/Index.cshtml
/Views/Shared/Index.cshtml

For the layout page, /Views/Shared/_Layout.cshtml will be used.

When you create your own view location expander, you'll be passed the view location paths returned by other expanders in the pipeline. We want to preserve the defaults but add our theme locations ensuring that they take priority.

public class TenantViewLocationExpander : IViewLocationExpander
{
    private const string THEME_KEY = "theme";

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        context.Values[THEME_KEY] = context.ActionContext.HttpContext.GetTenant<AppTenant>()?.Theme;
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        string theme = null;
        if (context.Values.TryGetValue(THEME_KEY, out theme))
        {
            viewLocations = new[] {
                $"/Themes/{theme}/{{1}}/{{0}}.cshtml",
                $"/Themes/{theme}/Shared/{{0}}.cshtml",
            }
            .Concat(viewLocations);
        }


        return viewLocations;
    }
}

In PopulateValues we use a SaasKit extension method to retrieve the current tenant instance from HttpContext and add the tenant theme to ViewLocationExpanderContext.Values.

In ExpandViewLocations we retrieve the current theme from the context and use it to append two new view location paths as per the convention described earlier. The returned locations include our theme locations and the defaults, in that order.

Wiring it up

Add the following to the ConfigureServices method in Startup.cs:

services.Configure<RazorViewEngineOptions>(options =>
{
    options.ViewLocationExpanders.Add(new TenantViewLocationExpander());
});

Now run the application. You should see the same results as the screenshots from before with the appropriate theme layout page (and therefore styles) being rendered for each tenant.

To demonstrate view overrides, we'll create a themed version of the About view in the Darkly theme:

@{
    ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>This is the Darkly theme.</p>

If i navigate to Tenant 1's About Page (Cerulean theme), it will show the default:

Tenant 1 About

Tenant 2 however will display the themed version:

Tenant 2 About

Overriding Views for specific tenants

At some point it's inevitable that your tenants will want bespoke customisations. This is a common problem in multi-tenant applications as changing one feature or theme usually impacts all of the tenants using your service.

In Fabrik, bespoke customisations are usually stylistic so we typically handle bespoke sites (like this one) with custom themes that are locked to a specific tenant.

However, often a customer simply wants to change a few elements on the page or adjust it's layout. We can apply the techniques above to support tenant-specific view overrides:

public class TenantViewLocationExpander : IViewLocationExpander
{
    private const string THEME_KEY = "theme", TENANT_KEY = "tenant";

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        context.Values[THEME_KEY] 
            = context.ActionContext.HttpContext.GetTenant<AppTenant>()?.Theme;

        context.Values[TENANT_KEY] 
            = context.ActionContext.HttpContext.GetTenant<AppTenant>()?.Name.Replace(" ", "-");
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        string theme = null;
        if (context.Values.TryGetValue(THEME_KEY, out theme))
        {
            IEnumerable<string> themeLocations = new[]
            {
                $"/Themes/{theme}/{{1}}/{{0}}.cshtml",
                $"/Themes/{theme}/Shared/{{0}}.cshtml"
            };

            string tenant;
            if (context.Values.TryGetValue(TENANT_KEY, out tenant))
            {
                themeLocations = ExpandTenantLocations(tenant, themeLocations);
            }

            viewLocations = themeLocations.Concat(viewLocations);
        }


        return viewLocations;
    }

    private IEnumerable<string> ExpandTenantLocations(string tenant, IEnumerable<string> defaultLocations)
    {
        foreach (var location in defaultLocations)
        {
            yield return location.Replace("{0}", $"{{0}}_{tenant}");
            yield return location;
        }
    }
}

In the above code PopulateValues has been updated to also add the normalised tenant name (Tenant 1 > tenant-1).

In ExpandViewLocations we append the tenant-specific search locations. With the above conventions, if we navigate to /Home/Index for Tenant 1 (Cerulean theme), the following locations will be searched:

/Themes/Cerulean/Home/Index_tenant-1.cshtml
/Themes/Cerulean/Home/Index.cshtml
/Themes/Cerulean/Shared/Index_tenant-1.cshtml
/Themes/Cerulean/Shared/Index.cshtml
/Views/Home/Index.cshtml
/Views/Shared/Index.cshtml

To test it out, we'll create a very basic override of the Index view at /Themes/Cerulean/Home/Index_tenant-1.cshtml.

Now when navigating to http://localhost:6000 (Tenant 1) we get the custom tenant view:

Tenant 1 Custom View

Thoughts on Asset location

Ideally we'd keep our theme assets (stylesheets, scripts and views) in one place e.g. /themes/darkly/assets. The default static file configuration is to only serve static files from wwwroot which is why in this example I decided to keep the theme assets separate.

While it is possible to serve static files from additional locations in ASP.NET Core, I'm not sure I'd want to configure this for every theme.

A better approach therefore would be to use a task runner like gulp to minify/bundle your theme assets and copy the output to the wwwroot directory.

Wrapping Up

This post covered how to build theme-able multi-tenant applications in ASP.NET Core MVC. We looked at a simple approach to theming by dynamically setting the View Layout page using ViewStart. Finally we introduced View Location Expanders in the new Razor view-engine and how these can be used to support more complex theming scenarios such as theme-specific views and overriding views for individual tenants.

Questions?

Join the SaasKit chat room on Gitter.


More content like this?

If you don't have anything to contribute but are interested in where SaasKit is heading, please subscribe to the mailing list below. Emails won't be frequent, only when we have something to show you.


Ben Foster

About Me

I'm a software engineer and aspiring entrepreneur with 12+ years experience in the tech industry and have worked with startups and SMB’s in areas such as healthcare, recruitment and e-commerce (I even worked in enterprise, once). I founded my first startup Fabrik in 2011.

I'm available for consulting. Just drop me a message on Twitter.

Creative Commons Licence