Many modern SaaS (Software as a Service) applications serve multiple customers from a single deployment. Instead of maintaining a separate application for each customer, a multi-tenant architecture allows multiple organizations (tenants) to securely share the same application while keeping their data isolated.
Building a multi-tenant application involves more than adding a TenantId column. Authentication, data isolation, dependency injection, caching, logging, and database design all require careful planning to ensure scalability and security.
In this article, you'll learn how to implement a production-ready multi-tenant architecture in ASP.NET Core, explore different tenancy models, and follow best practices for building secure and scalable SaaS applications.
What Is Multi-Tenancy?
A multi-tenant application serves multiple customers using a shared application instance.
Each tenant has:
Its own users
Business data
Configuration
Security boundaries
For example:
ASP.NET Core API
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Tenant A Tenant B Tenant C
│ │ │
▼ ▼ ▼
Customer Data Customer Data Customer Data
Although the application is shared, each tenant accesses only its own data.
Multi-Tenant Database Models
Choosing the right database strategy is one of the first architectural decisions.
| Model | Description | Best For |
|---|---|---|
| Shared Database, Shared Tables | All tenants share tables with a TenantId column | Small to medium SaaS applications |
| Shared Database, Separate Schemas | Each tenant has its own schema | Medium-sized SaaS platforms |
| Separate Database per Tenant | Every tenant has its own database | Enterprise or regulated environments |
Shared Tables
Example:
Customers
----------------------------
Id
TenantId
Name
Email
Every query filters data by TenantId.
Separate Databases
Each tenant has an independent database.
Advantages include:
Strong isolation
Easier backups
Independent scaling
The trade-off is increased operational complexity.
Identifying the Current Tenant
The application must determine which tenant is making the request.
Common approaches include:
Subdomain (
tenant1.example.com)Custom domain
Request header
JWT claim
URL segment
Example using a request header:
GET /api/products
X-Tenant-ID: tenant-a
Creating a Tenant Model
public class Tenant
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
}
This model represents the current tenant during request processing.
Tenant Resolution Middleware
Create middleware that extracts the tenant identifier.
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context,
TenantContext tenantContext)
{
var tenantId =
context.Request.Headers["X-Tenant-ID"]
.FirstOrDefault();
tenantContext.TenantId = tenantId;
await _next(context);
}
}
Register the middleware.
builder.Services.AddScoped<TenantContext>();
app.UseMiddleware<TenantMiddleware>();
Every request now has access to the resolved tenant.
Creating a Tenant Context
public class TenantContext
{
public string? TenantId { get; set; }
}
This service stores tenant information for the lifetime of a request.
Entity Design
Each entity should include the tenant identifier.
public class Product
{
public int Id { get; set; }
public string TenantId { get; set; } = "";
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
This enables tenant-based filtering throughout the application.
Global Query Filters
EF Core Global Query Filters automatically restrict data to the current tenant.
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Product>()
.HasQueryFilter(p =>
p.TenantId == _tenantContext.TenantId);
}
With this configuration, developers do not need to manually add Where() clauses to every query.
Creating Data
Always populate the tenant identifier before saving.
public async Task AddProduct(Product product)
{
product.TenantId = _tenantContext.TenantId!;
_context.Products.Add(product);
await _context.SaveChangesAsync();
}
This prevents records from being stored without tenant ownership.
Authentication
Authentication determines who the user is.
Authorization determines what they can access.
Tenant information is commonly stored inside a JWT.
Example claims:
sub = 123
tenant = tenant-a
role = Admin
The middleware or authentication handler can retrieve the tenant claim instead of relying solely on request headers.
Tenant-Aware Caching
Cache keys should include the tenant identifier.
Avoid:
products
Instead use:
tenant-a-products
tenant-b-products
This prevents cached data from one tenant being returned to another.
Join the conversation! Your thoughts help the community grow.