One system serves many customers (tenants). The key design question is how much they share: everything (pool), nothing (silo), or a mix (bridge).
π Tenant isolation
Data: filters, row-level security, per-tenant encryption keys. Compute: dedicated pools or nodes for big tenants. Watch the noisy neighbour.
π§ Tenant context
Resolve the tenant once at the edge (host name, token claim) and carry it everywhere: DB filter, logs, traces, message headers, cache keys.
π¦ Limits & quotas
Rate limits partitioned by tenant, and quotas per plan (requests, storage, users).
ποΈ Tenant-aware caching
Prefix every cache key with the tenant (t:42:account:7). One missing prefix leaks data across tenants.
π€ Onboarding automation
One pipeline creates the database or schema, config, identity and DNS for a new tenant, and removes them on exit.
π° Cost per tenant
Tag resources and meter usage per tenant, so you know which customers and plans are profitable.
.NET 10 How to implement it
| Need | Library / API | Notes |
| Resolve the tenant | Finbuckle.MultiTenant.AspNetCore (Apache-2.0) | Find the tenant from host, route, header or claim, with per-tenant options and auth. v10 releases line up with .NET 10 |
| Shared tables | built-in EF Core global query filters (HasQueryFilter); EF Core 10 named filters | Adds WHERE TenantId = @t to every query. Named filters let you switch off soft delete without switching off the tenant filter. Finbuckle.MultiTenant.EntityFrameworkCore can set this up for you |
| Database per tenant | built-in AddDbContextFactory / IDbContextFactory<T> | Pick the connection string for the current tenant from a tenant catalog or Key Vault |
| Per-tenant limits | built-in Microsoft.AspNetCore.RateLimiting: PartitionedRateLimiter | One token bucket per tenant. Different plans get different limits |
| Tenant-aware cache | Microsoft.Extensions.Caching.Hybrid (HybridCache) | Prefix keys with the tenant ID and tag entries tenant:42, so RemoveByTagAsync clears one tenant |
// EF Core 10: named filters, so soft delete can be ignored while the tenant filter stays
public sealed class BankDbContext(DbContextOptions<BankDbContext> options, ITenantContext tenant)
: DbContext(options)
{
private string TenantId => tenant.TenantId;
public DbSet<Account> Accounts => Set<Account>();
protected override void OnModelCreating(ModelBuilder b) =>
b.Entity<Account>()
.HasQueryFilter("Tenant", a => a.TenantId == TenantId)
.HasQueryFilter("SoftDelete", a => !a.IsDeleted);
}
// db.Accounts.IgnoreQueryFilters(["SoftDelete"]) still only sees this tenant's rows
// One token bucket per tenant: a noisy tenant can't starve the others
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetTokenBucketLimiter(
ctx.User.FindFirst("tenant_id")?.Value ?? "anonymous",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 200, TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(1)
}));
});
IgnoreQueryFilters() with no names turns off every filter, including the tenant one. Always pass the names you mean.
Multi-tenant SaaS: how much do your customers share? π’
π Silo: a stack per tenant. Safest, and the most expensive.
π¬ Pool: everything shared, with a TenantId on every row. Cheapest, but isolation lives in your code.
π Bridge: shared app, a database per tenant. The common middle ground.
π’ Noisy neighbour? Per-tenant rate limits and quotas.
π The tenant ID comes from the token, never the request body.
Most real platforms end up tiered: a pool for the long tail, silos for the big, regulated customers.
#SaaS #MultiTenancy #SystemDesign #CloudArchitecture