Building multi-tenant applications, a common pattern for Software as a Service (SaaS) products, introduces a fundamental challenge: how do you know which tenant is making the current request? Every incoming call to your API or web app must be correctly associated with a specific tenant to ensure data isolation and deliver a customized experience. The answer lies in a clean, powerful pattern: per-request tenant resolution using custom middleware.
In this guide, we’ll walk through creating a robust tenant resolution strategy in ASP.NET Core. We’ll build custom middleware that identifies the tenant from the incoming request and makes that information available to the rest of the application through dependency injection.
The Goal: What is Tenant Resolution?
At its core, tenant resolution is the process of inspecting an incoming HttpContext to extract a unique tenant identifier. Once you have this identifier, you can use it to look up the tenant’s details (like their database connection string, theme information, or feature flags) and load them into a request-scoped context.
There are several common strategies for identifying a tenant:
- Hostname/Subdomain:
tenant1.yourapp.com,tenant2.yourapp.com - URL Path:
yourapp.com/tenants/tenant1/ - HTTP Header:
X-Tenant-Id: tenant1 - Query String:
yourapp.com/products?tenantId=tenant1
The beauty of the middleware approach is that it centralizes this logic, making your application cleaner and easier to maintain, regardless of the strategy you choose.
Why Middleware is the Right Tool for the Job
The ASP.NET Core request pipeline is a series of components, called middleware, that process HTTP requests and responses. Placing your tenant resolution logic in a custom middleware offers several key advantages:
- Early Execution: It runs at the beginning of the request pipeline, ensuring the tenant is identified before any controllers, authentication, or business logic is executed.
- Centralized Logic: All tenant resolution logic lives in one place, preventing it from being scattered across your controllers or services.
- Separation of Concerns: The middleware’s only job is to identify the tenant. Your application logic can then simply consume this information without worrying about how it was obtained.
Step-by-Step Implementation
Let’s build a middleware that resolves a tenant based on the hostname. We’ll use modern C# and .NET 8 features.
Step 1: Define the Tenant Model and Context
First, we need a simple model to hold our tenant’s information and a context class to hold the resolved tenant for the duration of a single request.
// Tenant.cs
public record Tenant(string Id, string Host);
// TenantContext.cs
// This will be registered as a scoped service.
public class TenantContext
{
public Tenant? Current { get; set; }
}
Using a record for the Tenant is a great choice for a simple data-transfer object due to its conciseness and built-in immutability. The TenantContext is a simple class that will be populated by our middleware.
Step 2: Build the Tenant Resolution Middleware
Next, we create the middleware. It will inspect the request’s host, find a matching tenant, and populate our TenantContext.
// TenantResolutionMiddleware.cs
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
// A hardcoded list for demonstration purposes.
// In a real app, you'd fetch this from a database or configuration.
private static readonly List<Tenant> Tenants =
[
new Tenant("acme", "acme.localhost"),
new Tenant("globex", "globex.localhost")
];
public async Task InvokeAsync(HttpContext context, TenantContext tenantContext)
{
var host = context.Request.Host.Host;
// Find the tenant that matches the request host.
var tenant = Tenants.FirstOrDefault(t => t.Host.Equals(host, StringComparison.OrdinalIgnoreCase));
if (tenant is not null)
{
// Set the tenant in our scoped context.
tenantContext.Current = tenant;
}
// It's crucial to call the next middleware in the pipeline.
await _next(context);
}
}
In this middleware, we have a hardcoded list of tenants. In a production application, you would replace this with a call to a database, a configuration service, or a cached repository. The key action is populating the tenantContext service, which we’ll register as scoped.
Callout: Handling Unresolved Tenants In the example above, if a tenant isn’t found, we simply proceed without setting one. In a real-world scenario, you’d want to handle this more explicitly. You could short-circuit the pipeline by returning an error response, preventing unauthorized or invalid requests from ever reaching your application logic.
if (tenant is null) { context.Response.StatusCode = StatusCodes.Status404NotFound; await context.Response.WriteAsync("Tenant not found."); return; // Stop processing the request. }
tenantContext.Current = tenant; await _next(context);
Step 3: Register the Services and Middleware
Now, we need to wire everything up in Program.cs. We’ll register TenantContext as a scoped service and add our TenantResolutionMiddleware to the request pipeline.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1. Register the TenantContext as a scoped service.
// This ensures a new instance is created for each request.
builder.Services.AddScoped<TenantContext>();
builder.Services.AddControllers();
// Add other services like Swagger/OpenAPI if needed
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// 2. Add our custom middleware to the pipeline.
// It must be placed before components that need tenant info (like Authorization or Controllers).
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.Run();
The order is critical here. The TenantResolutionMiddleware must be registered before any middleware that depends on knowing the tenant, such as UseAuthorization or endpoint routing.
Step 4: Accessing the Tenant in Your Application
With everything set up, accessing the current tenant from anywhere in your application is straightforward. Simply inject the TenantContext into your controllers or services.
Here’s an example of an API controller that returns the current tenant’s ID.
// TenantsController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("[controller]")]
public class TenantsController(TenantContext tenantContext) : ControllerBase
{
private readonly TenantContext _tenantContext = tenantContext;
[HttpGet("me")]
public IActionResult GetCurrentTenant()
{
var tenant = _tenantContext.Current;
if (tenant is null)
{
return NotFound("Tenant could not be resolved for this request.");
}
return Ok($"The current tenant is: {tenant.Id}");
}
}
Because TenantContext is a scoped service, the instance injected into the controller is the exact same instance that was populated by our middleware for this specific request.
Conclusion
Using custom middleware for per-request tenant resolution is a clean, scalable, and maintainable pattern for building multi-tenant applications in ASP.NET Core. It centralizes the identification logic, decouples it from your business services, and leverages the power of the built-in dependency injection system to make tenant information available wherever it’s needed.
By following this approach, you create a solid foundation that can be extended with more complex resolution strategies, caching, and tenant-specific configurations, setting your SaaS application up for success.