Multi-tenant ASP.NET Core app - tenant resolution

Part 1 of 3

A multitenancy is a way to handle multiple tenants on a common infrastructure. Depending on your case, tenants may share the application itself, database, or other services. In this series, we will create ASP.NET Core multi-tenant app. This article describes a tenant resolution mechanism.

Parts of the series

Multi-tenant ASP.NET Core app - tenant resolution

Primary goals

You can download the final solution from my GitHub to follow along. This solution in based on ASP.NET Core 8.

Our goals:

  • The solution should be lightweight. We don't want to interfere with the framework.
  • On a request level, we know within which tenant it’s made.
  • The tenant name is included in the first segment of the URL. For instance, https://myapp.local/helloworld would give helloworld as a tenant name.
  • Everything after the first segment should lead us to the given app route. For example, https://myapp.local/helloworld/account will route us to AccountController for HelloWorld tenant.
  • Tenants will not have separated dependency container scopes.
  • We should be able to get tenant-specific settings.
  • User authenticated within tenant1 should not be a valid user for tenant2 (it will be done in part 2).

The shared dependency container scope may sound controversial in this case. When we inject a service registered as a singleton to tenant1, we will inject the same instance for tenant2's request. From my experience, "tenant singletons" which need to store some kind of state are not frequent. Usually, you can handle it by using internal ConcurrentDictionary with appropriate key-value pairs per tenant.

Creating tenant context

Firstly, let's start with creating the TenantContext class. It will be a simple object that holds the tenant's data and settings. We will also expose two separate interfaces responsible for getting and setting the current tenant. Why? Because we will only set up the tenant once during the request lifetime (using ITenantSetter). Everywhere else, we will use an interface named ITenantContext that allows getting the current tenant without the possibility to change it.

public interface ITenantContext{    Tenant CurrentTenant { get; }}
public interface ITenantSetter{    Tenant CurrentTenant { set; }}
public class TenantContext : ITenantContext, ITenantSetter{    public Tenant CurrentTenant { get; set; }}
public class Tenant(string name){    public string Name { get; set; } = name;}
C#

Secondly, we need to register it in a dependency container. This solution uses a built-in ASP.NET Core container that is completely sufficient for this case. To use the same instance of the TenantContext object within the given request, we need to register it with scoped (request) lifetime. What is tricky here is that the same instance needs to be registered under two different interfaces: ITenantContext and ITenantSetter.

public static class ServiceCollectionExtensions{    public static IServiceCollection AddMultitenancy(this IServiceCollection services)    {        services.AddScoped<TenantContext>();
        services.AddScoped<ITenantContext>(provider =>            provider.GetRequiredService<TenantContext>());
        services.AddScoped<ITenantSetter>(provider =>            provider.GetRequiredService<TenantContext>());
        return services;    }}
C#

After that, we invoke the AddMultitenancy extension method in the Program.cs.

We can also register controllers with views with MapControllerRoute (for later). The UseStaticFiles middleware is used to mitigate resolving favicon.ico as a tenant name.

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddControllersWithViews();services.AddMultitenancy();
var app = builder.Build();
app.UseDeveloperExceptionPage();app.UseStaticFiles();app.UseRouting();app.MapControllerRoute(    name: "default",    pattern: "{controller=Home}/{action=Index}");
C#
Multi-tenant app - favicon

Implementing tenant store

Subsequently, we need a tenants store. It will be a service that returns a Tenant object based on the tenant's name. We will hardcode two tenants for demo purposes.

Note that the logic inside this method can be quite complex and depends on where you store your tenants' configuration. It could be a database, JSON file, or dedicated resource on a cloud. I use Azure App Configuration to store tenants’ configurations. It is also a place to add tenant-specific configuration by extending the Tenant class with additional properties.

public interface ITenantStore{    Tenant GetTenant(string tenandId);}
public class TenantStore : ITenantStore{    public Tenant GetTenant(string tenandId)    {        return tenandId switch        {            "tenant1" => new("tenant1"),            "tenant2" => new("tenant2"),            _ => null        };    }}
C#

Let’s register it in the dependency container within the AddMultitenancy method.

public static class ServiceCollectionExtensions{    public static IServiceCollection AddMultitenancy(this IServiceCollection services)    {        services.AddScoped<TenantContext>();
        services.AddScoped<ITenantContext>(provider =>            provider.GetRequiredService<TenantContext>());
        services.AddScoped<ITenantSetter>(provider =>            provider.GetRequiredService<TenantContext>());
        services.AddScoped<ITenantStore, TenantStore>();        return services;    }}
C#

Resolving tenant per request

We need to set the tenant in TenantContext in the HTTP pipeline. Let’s create custom middleware with tenant resolution logic.

It gets the first segment of the request URL (from the Host header), invokes ITenantStore to get Tenant the object, and sets it within TenantContext using the ITenantSetter interface (it is an abstraction over TenantContext). If the tenant is not found, the app returns a 404 code.

When we get the tenant object we also set PathBase and Path properties on the HttpRequest object. After that, for instance, https://myapp.local/helloworld is considered a "root path" for other routes.

Note that Middlewares in ASP.NET Core are registered as singletons! Therefore ITenantSetter and ITenantStore are injected as arguments of the Invoke method, not via the constructor.

public class TenantResolutionMiddleware(RequestDelegate next){    public async Task Invoke(        HttpContext context, ITenantSetter tenantSetter, ITenantStore tenantStore)    {        (string tenantName, string realPath) = GetTenantAndPathFrom(context.Request);
        var tenant = tenantStore.GetTenant(tenantName);
        if (tenant == null)        {            context.Response.StatusCode = 404;            return;        }
        context.Request.PathBase = $"/{tenantName}";        context.Request.Path = realPath;        tenantSetter.CurrentTenant = tenant;
        await next(context);    }
    private static (string tenantName, string realPath)        GetTenantAndPathFrom(HttpRequest httpRequest)    {        // example: https://localhost/tenant1 -> gives tenant1        var tenantName = new Uri(httpRequest.GetDisplayUrl())            .Segments            .FirstOrDefault(x => x != "/")            ?.TrimEnd('/');
        if (!string.IsNullOrWhiteSpace(tenantName) &&            httpRequest.Path.StartsWithSegments($"/{tenantName}",                out PathString realPath))        {            return (tenantName, realPath);        }
        return (null, null);    }}
C#

The TenantResolutionMiddleware needs to be between UseRouting and UseStaticFiles in the Program.cs file. I suppose that we have no choice! 😄

app.UseDeveloperExceptionPage();app.UseStaticFiles();app.UseMiddleware<TenantResolutionMiddleware>();app.UseRouting();
app.MapControllerRoute(    name: "default",    pattern: "{controller=Home}/{action=Index}");
C#

Let’s try our solution

For demo purposes, I created a simple HomeController. The TenantContext is injected there via its constructor. Then in the Index method, we get the tenant’s name and return it as a response content.

public class HomeController(ITenantContext tenantContext) : Controller{    [HttpGet]    public ActionResult Index()    {        var tenantName = tenantContext.CurrentTenant.Name;        return Content($"Tenant: '{tenantName}'");    }}
C#

To invoke the Index action, we run the application and open URLs like https://localhost:5001/tenant1 or https://localhost:5001/tenant2. It routes to HomeController. Here are the results:

Multi-tenant app - tenant 1
Multi-tenant app - tenant 2
Multi-tenant app - tenant 3

Conclusion

To sum up, in this article we created foundations of multi-tenant application with a simple tenant resolution mechanism. Tenants are resolved by the first segment of the request URL. Their "context" is available within request's lifetime. In the next part of this series, we will configure cookie-based authentication.

While using this resolution strategy, you may encounter problems with static files. Consider request URL like https://localhost:5001/favicon.ico. In our solution, we added UseStaticFiles before TenantResolutionMiddleware so favicon.ico is not considered as a tenant name. In your app, you need to prepare your HTTP pipeline carefully and predict all cases like this.

Comments

Add comment
The comments system is based on GitHub Issues API. Once you add your comment to the linked issue on my GitHub repository, it will show up below.
© brokul.dev 2024