Building a multi-tenant SaaS application presents unique challenges, especially when it comes to data isolation. One of the most common and robust approaches is the “database-per-tenant” model, where each customer’s data resides in its own dedicated database. This ensures strong security and simplifies scaling.
The core problem, however, is telling your application which database to connect to for any given HTTP request. A static connection string in appsettings.json won’t work. We need a dynamic, request-aware mechanism. This is where ASP.NET Core middleware shines. By intercepting requests early, we can identify the tenant, retrieve their specific connection string, and configure services for the remainder of the request pipeline.
In this post, we’ll walk through building a middleware-based solution to manage tenant-aware connection strings in an ASP.NET Core application.
The Challenge with Static Configuration
In a typical single-tenant application, you register your DbContext like this:
// Program.cs - The traditional way
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
This works because the connection string is known at startup and never changes. In a multi-tenant world, the connection string depends on who is making the request, a detail that is only available once the HTTP request arrives.
The Middleware Solution Strategy
Our goal is to create a seamless system where our application code (like API endpoints) can use a DbContext without needing to know anything about multi-tenancy. The logic should be centralized and handled automatically.
Here’s our plan:
- Identify the Tenant: We’ll use a custom HTTP header,
X-Tenant-ID, to identify the tenant for each request. - Create a Scoped Context: We’ll define a service to hold the current tenant’s information, specifically their connection string. This service will have a “scoped” lifetime, meaning a new instance is created for each request.
- Build the Middleware: This middleware will read the
X-Tenant-IDheader, look up the tenant’s connection string, and populate our scoped context. - Configure EF Core: We’ll configure Entity Framework Core’s
DbContextto get its connection string from our scoped context, making it dynamically tenant-aware.
Step 1: Define a Tenant Context
First, let’s create a simple class to hold the tenant’s connection string. This service will be registered with a scoped lifetime, ensuring that each HTTP request gets its own isolated instance.
// TenantContext.cs
public class TenantContext
{
public string? ConnectionString { get; set; }
}
Step 2: Create a Tenant Lookup Service
Next, we need a service that can find a tenant’s details based on their ID. In a real-world application, this service would query a central “master” database that stores tenant configurations. For this example, we’ll use a simple in-memory dictionary.
// ITenantService.cs
public interface ITenantService
{
Task<string?> GetConnectionStringAsync(string tenantId);
}
// InMemoryTenantService.cs
public class InMemoryTenantService : ITenantService
{
private readonly Dictionary<string, string> _tenants = new()
{
// In a real app, this would come from a database.
["tenant1"] = "Server=.;Database=Tenant1Db;Trusted_Connection=True;TrustServerCertificate=True;",
["tenant2"] = "Server=.;Database=Tenant2Db;Trusted_Connection=True;TrustServerCertificate=True;"
};
public Task<string?> GetConnectionStringAsync(string tenantId)
{
_tenants.TryGetValue(tenantId, out var connectionString);
return Task.FromResult(connectionString);
}
}
Callout: Performance and Caching In a production environment, your
ITenantServiceshould implement caching. Constantly hitting a database to look up tenant information on every single request can become a performance bottleneck. A distributed cache like Redis or even a simple in-memory cache with a reasonable expiration time can significantly improve performance.
Step 3: Implement the Tenant Resolution Middleware
This is the core of our solution. The middleware intercepts incoming requests, identifies the tenant, and sets up the TenantContext for the rest of the application to use.
// TenantResolutionMiddleware.cs
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ITenantService tenantService, TenantContext tenantContext)
{
// Attempt to get the tenant ID from the X-Tenant-ID header.
if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var tenantIdHeader) &&
!string.IsNullOrWhiteSpace(tenantIdHeader))
{
var tenantId = tenantIdHeader.ToString();
var connectionString = await tenantService.GetConnectionStringAsync(tenantId);
if (string.IsNullOrEmpty(connectionString))
{
// Tenant ID provided, but not found.
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("Tenant not found.");
return;
}
// Set the connection string in our scoped context.
tenantContext.ConnectionString = connectionString;
}
// Continue down the pipeline.
await _next(context);
}
}
Callout: Security First Always validate the tenant identifier. If an invalid or missing tenant ID is provided, the request should be rejected immediately with an appropriate status code (like 404 Not Found or 403 Forbidden). This prevents any possibility of one tenant’s request accidentally accessing another’s data.
Step 4: Putting It All Together in Program.cs
Now, we wire everything up in our application’s entry point. This involves registering our services with the dependency injection container and adding the middleware to the request pipeline.
The key part is how we register the ApplicationDbContext. We use a factory function that accesses the IServiceProvider to resolve our scoped TenantContext.
// Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// 1. Register the tenant lookup service (as a singleton is fine for our example)
builder.Services.AddSingleton<ITenantService, InMemoryTenantService>();
// 2. Register our scoped context to hold the connection string
builder.Services.AddScoped<TenantContext>();
// 3. Register the DbContext with a factory
builder.Services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
{
// Resolve the TenantContext for the current request
var tenantContext = serviceProvider.GetRequiredService<TenantContext>();
var connectionString = tenantContext.ConnectionString;
if (string.IsNullOrEmpty(connectionString))
{
// This could happen if middleware didn't run or tenant not found.
// We throw an exception to prevent using the DbContext in an invalid state.
throw new InvalidOperationException("Tenant connection string is not set.");
}
options.UseSqlServer(connectionString);
});
builder.Services.AddControllers();
var app = builder.Build();
// 4. Add the middleware to the pipeline.
// It must be placed before any components that need database access (e.g., MVC, Minimal APIs).
app.UseMiddleware<TenantResolutionMiddleware>();
app.MapGet("/data", (ApplicationDbContext db) =>
{
// The injected 'db' instance is now automatically configured
// with the correct tenant's connection string.
return Results.Ok(new { DatabaseName = db.Database.GetDbConnection().Database });
});
app.Run();
// A placeholder DbContext for demonstration
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
}
Step 5: Testing the Solution
You can now run the application and test it with a tool like curl or Postman.
Request for Tenant 1:
curl -X GET http://localhost:5000/data -H "X-Tenant-ID: tenant1"
Response:
{
"databaseName": "Tenant1Db"
}
Request for Tenant 2:
curl -X GET http://localhost:5000/data -H "X-Tenant-ID: tenant2"
Response:
{
"databaseName": "Tenant2Db"
}
Request with an invalid tenant:
curl -X GET http://localhost:5000/data -H "X-Tenant-ID: invalid-tenant"
Response: (Status Code: 404 Not Found)
Tenant not found.
As you can see, our API endpoint remains completely clean. It simply asks for an ApplicationDbContext, and the DI container, guided by our middleware, provides an instance configured with the correct connection string for the active tenant.
This middleware-based approach provides a clean, scalable, and maintainable solution for managing dynamic connection strings in a multi-tenant ASP.NET Core application. It centralizes the tenant resolution logic and keeps your business logic free from infrastructure concerns.