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 givehelloworld
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 toAccountController
forHelloWorld
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 fortenant2
(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;}
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; }}
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}");
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 }; }}
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; }}
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); }}
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}");
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}'"); }}
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:
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.