Multitenancy Techniques for the UI in ASP.NET Core
Introduction
I've been writing some posts on multitenancy with ASP.NET Core. So far we have:
- Tenant identification
- Data filtering (for EF Core)
- UI customisation (this article)
- Business logic
This time I'm going to talk about the User Interface (UI) and give some suggestions for:
- Loading contents inside of a view conditionally per tenant
- Loading different views for different tenants
- Using code for making tenant-dependent choices
We shall be referencing the same ITenantIdProvider abstraction shown before. All the views I'll be talking about are, of course, Razor views, which can be views in a MVC app or Razor Pages.
Loading Different Views per Tenant
Views are used to render HTML contents in a MVC project. The actual service that is responsible for locating the views files is IViewLocationExpander. We will implement our own version that sticks another path to the list of paths used to search for views (the original ones come from RazorViewEngineOptions.ViewLocationFormats):
public sealed class TenantViewLocationExpander : IViewLocationExpander
{
private string? _tenantId;
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
{
foreach (var location in viewLocations)
{
yield return location.Replace("{0}", _tenantId + "/{0}");
yield return location;
}
}
public void PopulateValues(ViewLocationExpanderContext context)
{
var tenantIdProvider = context.ActionContext.HttpContext.RequestServices.GetService<ITenantIdProvider>();
_tenantId = tenantIdProvider.GetTenantId();
}
}
So, for each physical path, we also return another one with a tenant folder included.
Let's not forget to register our implementation in RazorViewEngineOptions.ViewLocationExpanders:
builder.Services.Configure<RazorViewEngineOptions>(options => { options.ViewLocationExpanders.Insert(0, new TenantViewLocationExpander()); });
With this, we add another path to the list of locations where ASP.NET Core (or rather, the view engine) will look for views:
- /Views/<controller>/<tenant>
- /Views/<controller>
- /Views/Shared
Because we are adding the new one first, if there is a folder with the tenant id name inside the Views and the <controller> folder (e.g., /Views/Home/Foo), then the views shall be tentatively loaded from there first transparently. If the tenant folder does not exist, or the view itself, it will fallback to the default locations.
Using Tag Helpers to Conditionally Show Contents
Another option we have is, inside a view, render parts of the content conditionally, depending on the current tenant. For that we shall use a tag helper. Tag helpers allow declaring custom tags (or modifying the behaviour of existing ones). For more information on authoring tag helpers please read this.
[HtmlTargetElement("tenant")]
public sealed class TenantTagHelper(ITenantIdProvider tenantIdProvider): TagHelper
{
public string? Name { get; set; }
public bool Except { get; set; }
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var tenants = Name?.Split(',') ?? [];
var tenant = tenantIdProvider.GetTenantId();
if (Except)
{
if (tenants.Any(t => t == tenant))
{
output.SuppressOutput();
}
}
else
{
if (!tenants.Any(t => t == tenant))
{
output.SuppressOutput();
}
}
return base.ProcessAsync(context, output);
}
}Tag helpers need to be registered before using addTagHelper (where MyAssembly is the name of the MVC project assembly):
@addTagHelper *, MyAssemblyAnd then can be used as, for example:
<tenant name="Foo">Foo here</tenant> <tenant name="Bar">Bar here</tenant> <tenant name="Foo,Other">Foo also here</tenant> <tenant name="Bar" except="true">Not Bar here</tenant>
This tag helper creates a new tag, <tenant>, that takes the following parameters as attributes:
- name: a comma-separated list of tenant ids
- except: a boolean value that indicates whether or not the logic should be reversed (default is false)
Two ways to use it:
- For a comma-separated list of tenant ids in name, the content will be rendered if any of them matches the current tenant
- If Except is set to true, then the logic reverses, and the content will only be rendered for all tenants except those in name
For tenant "Foo", this will show:
Foo here Foo also here Not Bar here
Problems with this approach include that we need to know the tenant ids for which to apply the logic and that we have to be verbose, even though we can use the Except to negate just for a handful of tenants.
Using Code
Of course, in a Razor view, you can also inject ITenantIdProvider and perform conditional logic depending on the tenant:
@inject ITenantIdProvider TenantIdProvider
@{
var tenantId = TenantIdProvider.GetTenantId();
}
@if (tenantId == "Foo")
{
//do something
}
else
{
//do something else
}
Of course, with this approach you must hardcode tenant ids in the code, which may or may not be acceptable. Another option would be to call some service that knows how to behave for certain tenants:
@inject ITenantStrategyService TenantStrategy
@if (TenantStrategy.ShouldProcess())
{
//ok for the current tenant
}
else
{
//something else
}
ITenantStrategyService is, of course, some hypothetical service that knows what the current tenant is and encapsulates logic for dealing with it (and the other tenants).
Conclusion
Many ways exist that produce similar results, but these are my suggestions, which I hope you find useful. I will carry on writing about multitenancy, so stay tuned. As always, if you have any questions or disagreement, just drop a comment!
Comments
Post a Comment