Comparison Table
| Factor |
Shared DB + Shared Schema |
Shared DB + Separate Schema |
Separate Database |
| Cost |
Lowest |
Medium |
Highest |
| Complexity |
Low |
Medium |
High |
| Data Isolation |
Logical only |
Schema-level |
Full physical |
| Scalability |
Highest |
High |
Medium |
| Migration Effort |
Single migration |
Per-schema migration |
Per-database migration |
| Risk of Data Leakage |
High (no filter = leak) |
Low |
Minimal |
| Best For |
Most SaaS products |
Regulated + shared infra |
Enterprise / regulated |

My take: Start with Shared Database + Shared Schema. It handles ninety percent of SaaS use cases without the operational burden of the other two. Add per-schema or per-database isolation only when a specific business, contractual, or compliance requirement forces the issue.
Tenant Resolution Strategies
Before your application can do anything tenant-aware, it needs to figure out which tenant is making the request. There are four main strategies, each with different trade-offs.

Subdomain-based Resolution
https://acme.yoursaas.com/api/orders
https://techstartup.yoursaas.com/api/orders
How it works: Extract the subdomain from HttpContext.Request.Host and look up the corresponding tenant.
Pros: Human-readable URLs, excellent UX, natural fit for white-labeling, easy to configure at the DNS and reverse proxy level.
Cons: Requires wildcard SSL certificates (*.yoursaas.com), DNS provisioning per tenant, more infrastructure setup.
Best for: Consumer-facing SaaS products where per-tenant branding matters.
http
GET /api/orders HTTP/1.1
Host: api.yoursaas.com
X-Tenant-ID: acme-corp
Authorization: Bearer <token>
How it works: Client sends a custom header with every request. The middleware reads it and resolves the tenant.
Pros: Simple to implement, zero infrastructure changes, great for API-first products and machine-to-machine communication.
Cons: Relies on the client always sending the correct header. Not natural for browser-based apps where custom request headers are not automatically included.
Best for: Internal APIs, microservice-to-microservice calls.
JWT Claims-based Resolution
json
{
"sub": "user-123",
"email": "alice@acme.com",
"tenant_id": "acme-corp",
"tenant_name": "ACME Corporation",
"role": "admin"
}
How it works: The tenant identifier is embedded in the JWT token at the time of authentication. Middleware extracts it from the user's claims on every request.
Pros: Secure (the identity provider controls the token contents), no extra round-trips, works seamlessly with existing authentication flows, tenant cannot be spoofed by the client.
Cons: Requires a token refresh if tenant affiliation changes. Slightly more complex initial setup if you are rolling your own identity.
Best for: Most production SaaS applications. This is the approach this guide uses.
URL Path-based Resolution
https://yoursaas.com/t/acme-corp/orders
https://yoursaas.com/t/techstartup/orders
How it works: Tenant identifier is part of the route path. Middleware reads the route value.
Pros: No special DNS or SSL setup. Easy to test locally without any infrastructure.
Cons: Clutters routing, unusual for REST APIs, awkward to maintain at scale.
Best for: Quick prototypes, internal tools, admin dashboards.
Step-by-Step Implementation in .NET 10
Let us build the core of a multi-tenant .NET 10 application using the Shared Database + Shared Schema model with JWT claims-based tenant resolution. This is the most common production setup and the right starting point for most SaaS products.
1. TenantContext Model
Start with a simple model representing the resolved tenant for the current request.
csharp
namespace YourApp.MultiTenancy;
public sealed record TenantContext
{
public required string TenantId { get; init; }
public required string TenantName { get; init; }
public bool IsResolved { get; init; } = true;
public static TenantContext Unresolved() =>
new() { TenantId = string.Empty, TenantName = string.Empty, IsResolved = false };
}
Keep this model lean. It is not the place to store feature flags, plan tiers, or billing state. Those live elsewhere and are loaded on demand.
2. ITenantService
A scoped service that holds the resolved tenant for the lifetime of a single HTTP request. Scoped lifetime is non-negotiable here.
csharp
namespace YourApp.MultiTenancy;
public interface ITenantService
{
TenantContext Current { get; }
void SetTenant(TenantContext context);
}
public sealed class TenantService : ITenantService
{
private TenantContext _current = TenantContext.Unresolved();
public TenantContext Current => _current;
public void SetTenant(TenantContext context)
{
ArgumentNullException.ThrowIfNull(context);
_current = context;
}
}
3. Tenant Resolution Middleware
This middleware runs early in the pipeline, resolves the tenant from the authenticated user's JWT claims, and populates the scoped ITenantService.
csharp
namespace YourApp.MultiTenancy;
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
{
var tenantId = context.User.FindFirst("tenant_id")?.Value;
var tenantName = context.User.FindFirst("tenant_name")?.Value;
if (!string.IsNullOrWhiteSpace(tenantId))
{
tenantService.SetTenant(new TenantContext
{
TenantId = tenantId,
TenantName = tenantName ?? tenantId,
IsResolved = true
});
}
await next(context);
}
}
Notice that ITenantService is injected through the InvokeAsync method signature, not the constructor. This is intentional. ITenantService is scoped, and the middleware itself is a singleton in the ASP.NET Core pipeline. Constructor injection would create a captive dependency problem, where the singleton middleware would hold a single scoped instance across all requests. Method injection on InvokeAsync resolves the scoped service correctly from the current request's DI scope.
4. Registering Services in DI
csharp
using YourApp.MultiTenancy;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorization();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<TenantResolutionMiddleware>();
app.MapControllers();
app.Run();

5. Example Entity with TenantId
Every entity that belongs to a tenant carries a TenantId. Enforcing this through a base class ensures consistency and makes the contract explicit.
csharp
namespace YourApp.Domain;
public interface ITenantEntity
{
string TenantId { get; set; }
}
public abstract class TenantEntityBase : ITenantEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public string TenantId { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class Order : TenantEntityBase
{
public string CustomerName { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public List<OrderItem> Items { get; set; } = [];
}
public sealed class OrderItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid OrderId { get; set; }
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }
6. EF Core DbContext with Global Query Filter
The global query filter is the single most important safety mechanism in the shared-schema approach. Every EF Core query against a tenant entity automatically receives a WHERE TenantId = @tenantId clause, regardless of who wrote the query or where it lives in the codebase.
csharp
using Microsoft.EntityFrameworkCore;
using YourApp.Domain;
using YourApp.MultiTenancy;
namespace YourApp.Data;
public sealed class AppDbContext : DbContext
{
private readonly ITenantService _tenantService;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantService tenantService)
: base(options)
{
_tenantService = tenantService;
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenantService.Current.TenantId);
modelBuilder.Entity<Order>()
.Property(o => o.TenantId)
.IsRequired()
.HasMaxLength(100);
modelBuilder.Entity<Order>()
.HasIndex(o => o.TenantId);
base.OnModelCreating(modelBuilder);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.TenantId = _tenantService.Current.TenantId;
}
return base.SaveChangesAsync(cancellationToken);
}
}
The SaveChangesAsync override is just as important as the query filter. New entities get their TenantId stamped automatically at persistence time. No feature code ever touches TenantId directly — it is purely infrastructure-level behavior.
7. Example Controller Using Tenant-Aware Data
csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YourApp.Data;
using YourApp.Domain;
using YourApp.MultiTenancy;
namespace YourApp.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public sealed class OrdersController(AppDbContext db, ITenantService tenantService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetOrders(CancellationToken ct)
{
var orders = await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
return Ok(orders);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var order = await db.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id, ct);
return order is null ? NotFound() : Ok(order);
}
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request, CancellationToken ct)
{
var order = new Order
{
CustomerName = request.CustomerName,
TotalAmount = request.TotalAmount,
Status = OrderStatus.Pending
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
[HttpGet("tenant-info")]
public IActionResult GetTenantInfo()
{
var tenant = tenantService.Current;
return Ok(new
{
tenant.TenantId,
tenant.TenantName,
tenant.IsResolved
});
}
}
public sealed record CreateOrderRequest(string CustomerName, decimal TotalAmount);
Advanced Scenario: Database per Tenant
When a large enterprise customer's legal team demands that their data cannot physically coexist with any other company's data, the shared-schema approach is off the table. You need the database-per-tenant model.
The central challenge is dynamic connection string resolution. Rather than reading a single connection string from appsettings.json, you look up the correct connection string based on the resolved tenant at request time.
Tenant Configuration Store
You need a place to store the mapping from tenant identifier to connection string. In production, this is typically a central configuration database or a secrets vault such as Azure Key Vault. For this example, an in-memory implementation illustrates the contract.
csharp
namespace YourApp.MultiTenancy;
public sealed record TenantDatabaseConfig
{
public required string TenantId { get; init; }
public required string TenantName { get; init; }
public required string ConnectionString { get; init; }
}
public interface ITenantConfigurationProvider
{
Task<TenantDatabaseConfig?> GetAsync(string tenantId, CancellationToken ct = default);
Task<IReadOnlyList<string>> GetAllTenantIdsAsync(CancellationToken ct = default);
}
public sealed class InMemoryTenantConfigurationProvider : ITenantConfigurationProvider
{
private static readonly Dictionary<string, TenantDatabaseConfig> _configs = new()
{
["acme-corp"] = new TenantDatabaseConfig
{
TenantId = "acme-corp",
TenantName = "ACME Corporation",
ConnectionString = "Server=acme.database.windows.net;Database=AcmeDb;Authentication=Active Directory Default;"
},
["techstartup"] = new TenantDatabaseConfig
{
TenantId = "techstartup",
TenantName = "TechStartup Inc",
ConnectionString = "Server=techstartup.database.windows.net;Database=TechDb;Authentication=Active Directory Default;"
}
};
public Task<TenantDatabaseConfig?> GetAsync(string tenantId, CancellationToken ct = default) =>
Task.FromResult(_configs.TryGetValue(tenantId, out var config) ? config : null);
public Task<IReadOnlyList<string>> GetAllTenantIdsAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>([.. _configs.Keys]);
}
Dynamic DbContext Factory
Rather than registering AppDbContext with AddDbContext, you use a factory that builds the context with the correct connection string for the current tenant on demand.
csharp
namespace YourApp.Data;
public sealed class TenantDbContextFactory(
ITenantService tenantService,
ITenantConfigurationProvider configProvider,
ILoggerFactory loggerFactory)
{
public async Task<AppDbContext> CreateAsync(CancellationToken ct = default)
{
var tenantId = tenantService.Current.TenantId;
if (string.IsNullOrWhiteSpace(tenantId))
throw new InvalidOperationException(
"Tenant is not resolved. Cannot create a database context.");
var config = await configProvider.GetAsync(tenantId, ct)
?? throw new InvalidOperationException(
$"No database configuration found for tenant '{tenantId}'.");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(config.ConnectionString)
.UseLoggerFactory(loggerFactory)
.Options;
return new AppDbContext(options, tenantService);
}
}
DI Registration for Database-per-Tenant
csharp
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddSingleton<ITenantConfigurationProvider, InMemoryTenantConfigurationProvider>();
builder.Services.AddScoped<TenantDbContextFactory>();
Using the Factory in a Controller
csharp
[HttpGet]
public async Task<IActionResult> GetOrders(
[FromServices] TenantDbContextFactory dbFactory,
CancellationToken ct)
{
await using var db = await dbFactory.CreateAsync(ct);
var orders = await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
return Ok(orders);
}
When database-per-tenant makes sense:
- Enterprise clients with contractual or regulatory data isolation requirements
- Healthcare or financial services where co-tenancy is legally restricted
- When a single tenant's data volume justifies dedicated infrastructure
- When you need per-tenant backup, restore, and point-in-time recovery
When it does not make sense:
- Standard SaaS with hundreds or thousands of small tenants
- Early-stage products before you have validated product-market fit
- When your operations team does not have the capacity to manage N databases