Implementing multi-tenancy in ASP.Net: Middleware
In the first article in this series, we explored the need for multitenancy and the different forms thereof. The second blog post explored setting up of the database. The third article looked at resolving the tenant on the fly so that we can connect to the connect database.
In this last part, we will add middleware (more like a middle-man in the picture above) to the request pipeline, get the connection string, then add it to the pipeline so that is it available late in the request. You can find the code for this article on this repo.
Here are all the articles
- Implementing multi-tenancy in ASP.Net
- Implementing multi-tenancy in ASP.Net: Setting up Database Contexts
- Implementing multi-tenancy in ASP.Net: Resolving the tenant
- Implementing multi-tenancy in ASP.Net: Middleware
Let’s dive right in.
What is middleware?
Middleware in the context of ASP.NET Core is software that’s assembled into an application pipeline to handle requests and responses. Each component:
- Chooses whether to pass the request to the next component in the pipeline.
- Can perform work before and after the next component in the pipeline.
Middleware components are executed in the order they are added to the pipeline. Here’s how you might add middleware to your application in the Program.cs
file:
Tenant resolution middleware
We now create a middleware to resolve the tenant for each API request.
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ITenantResolver tenantResolver)
{
try
{
if (!context.Items.ContainsKey("ConnectionString"))
{
var tenant = await tenantResolver.ResolveAsync();
if (tenant != null)
{
var connectionString = tenant.ConnectionString;
context.Items["ConnectionString"] = Regex.Unescape(connectionString);
}
}
await _next(context);
}
catch (Exception)
{
throw;
}
}
}
The TenantResolutionMiddleware
is a custom middleware component in ASP.NET Core that's responsible for resolving the tenant's connection string for each HTTP request. Here's an explanation of its structure and functionality:
Constructor:
public TenantResolutionMiddleware(RequestDelegate next)
: The constructor accepts a RequestDelegate
named _next
, which represents the next middleware in the pipeline. This delegate is stored in a private read-only field for later use.
InvokeAsync Method:
public async Task InvokeAsync(HttpContext context, ITenantResolver tenantResolver)
: This is the method that's called by the ASP.NET Core framework to execute the middleware. It takes the current HttpContext
and an implementation of the ITenantResolver
interface as parameters.
Middleware Logic:
- The middleware begins by checking if the
HttpContext.Items
dictionary already contains a "ConnectionString" key. This dictionary is used to share data within the scope of a single request. If the connection string is already present, it skips the resolution logic to avoid unnecessary work. - If the connection string is not present in
HttpContext.Items
, the middleware calls theResolveAsync
method on the injectedITenantResolver
to determine the current tenant based on the incoming request. - Once the tenant is resolved, the middleware retrieves the tenant’s
ConnectionString
property. It then unescapes this connection string usingRegex.Unescape
to ensure any escape characters (like double backslashes in a file path) are correctly interpreted. - The unescaped connection string is then stored in
HttpContext.Items
with the key "ConnectionString". This allows other parts of the application to access the connection string for the current request without needing to resolve the tenant again. - After the tenant resolution logic, the middleware invokes the next middleware in the pipeline using
await _next(context)
.
Usage:
- This middleware would be configured in the
Startup
class'sConfigure
method using theapp.UseMiddleware<TenantResolutionMiddleware>()
call, ensuring that it's part of the request processing pipeline.
By using this middleware, the application ensures that each HTTP request has access to the correct tenant’s database connection string.
Using the tenant-specific TenantDbContext
For simplicity, we are not implementing the repository pattern or the unit of work. We are going to commit a cardinal sin and inject the TenantDbContext
(not directly, though) into the ListingController 😱.
The TenantDbContext
needs to be configured with the correct connection string or other settings specific to the tenant that's currently being served. To achieve that, we use a factory.
public interface ITenantDbContextFactory
{
TenantDbContext GetDbContext();
}
An implementation of this interface would take a tenant-specific connection string and use it to create and configure an instance of TenantDbContext
.
public class TenantDbContextFactory : ITenantDbContextFactory
{
private readonly IHttpContextAccessor _httpContextAccessor;
private TenantDbContext? _dbContext;
public TenantDbContextFactory(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public TenantDbContext GetDbContext()
{
if (_dbContext is not null) return _dbContext;
var connectionString = _httpContextAccessor.HttpContext.Items["ConnectionString"] as string;
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("Connection string not found in HttpContext.");
}
var optionsBuilder = new DbContextOptionsBuilder<TenantDbContext>();
optionsBuilder.UseSqlServer(connectionString);
_dbContext = new TenantDbContext(optionsBuilder.Options);
return _dbContext;
}
}
As we did in the previous article, lets extend our , and add the method:
public static IServiceCollection AddFactories(this IServiceCollection services)
{
services.AddScoped<ITenantDbContextFactory, TenantDbContextFactory>();
return services;
}
…then add to the Program.cs
file.
using MultiTenancyDemo.Data;
using MultiTenancyDemo.Extensions;
using MultiTenancyDemo.Middleware;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddMyOptions();
builder.Services.AddResolvers();
builder.Services.AddFactories();
builder.Services.AddDbContext<PlatformDbContext>();
builder.Services.AddDbContext<TenantDbContext>();
We are now ready to use the factory in a controller.
Using the factory in a controller
Lets create a Listings Controller
namespace MultiTenancyDemo.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ListingsController : ControllerBase
{
private readonly TenantDbContext _context;
public ListingsController(ITenantDbContextFactory dbContextFactory)
{
_context = dbContextFactory.GetDbContext();
}
[HttpGet]
public async Task<ActionResult> GetListings()
{
var listings = await _context.Listings.ToListAsync();
return Ok(listings);
}
}
}
That’s it. Well done if you have made it this far. All comments are welcome.