Case: Multi-Tenant SaaS
Problem
Design a multi-tenant SaaS for B2B teams. Tenants vary 10–10,000 users. Strict data isolation. Free, Standard, Premium tiers with different SLAs and feature sets. Tenant-scoped billing.
Walkthrough
Clarify
- 1000 tenants today; growing 10/week.
- Largest tenant: 10K users, 100 GB data.
- Smallest tenant: 5 users, 1 MB.
- Strict isolation: a tenant must NEVER see another's data.
- Some tenants enterprise: SSO via their IdP (federation).
- Feature flags per tenant.
- Premium: 99.99% SLA, dedicated capacity option.
Tenancy patterns
| Pattern | Notes |
|---|---|
| Database-per-tenant | Strongest isolation; ops cost; not scalable to 10K+ |
| Schema-per-tenant | Postgres; mid isolation; mid ops |
| Row-per-tenant (shared) | Cheapest; scales; care to enforce isolation |
| Hybrid | Free/Standard shared; Premium dedicated |
For 1000+ tenants: shared row-per-tenant + EF query filter + optional dedicated for Premium.
Schema
CREATE TABLE Users (
Id UUID PRIMARY KEY,
TenantId UUID NOT NULL,
Email TEXT,
-- ...
);
CREATE INDEX idx_users_tenant ON Users(TenantId);
CREATE TABLE Orders (
Id UUID PRIMARY KEY,
TenantId UUID NOT NULL,
...
);
CREATE INDEX idx_orders_tenant ON Orders(TenantId);
Every table has TenantId. Every query must filter by it.
EF Core query filter
public class AppDb(DbContextOptions o, ITenantContext tc) : DbContext(o)
{
private readonly Guid _tenantId = tc.TenantId;
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<User>().HasQueryFilter(u => u.TenantId == _tenantId);
mb.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenantId);
}
}
Bypass risk: IgnoreQueryFilters() works — accidental admin code can bypass. Audit usage.
Tenant resolution
public class TenantMiddleware(RequestDelegate next, ITenantStore store)
{
public async Task Invoke(HttpContext ctx)
{
// From host: tenant-a.app.com
var host = ctx.Request.Host.Host;
var sub = host.Split('.').First();
var tenant = await store.GetByCodeAsync(sub);
// Or from JWT claim
// var tenantClaim = ctx.User.FindFirst("tenant_id")?.Value;
ctx.Items["TenantId"] = tenant.Id;
await next(ctx);
}
}
Or via path: /{tenant}/users. Or claim on JWT.
DI scoping
builder.Services.AddScoped<ITenantContext, TenantContext>();
builder.Services.AddDbContext<AppDb>((sp, o) => o.UseNpgsql(...));
Per-request ITenantContext injected into DbContext.
Auth + IdP
- Default: shared IdP (Entra External ID), tenant claim on token.
- Enterprise: federation per-tenant. Tenant has its own AAD; we trust their tokens.
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer(o => o.ConfigurationManager = new MultiTenantConfigManager());
Validates per-tenant authority dynamically.
Storage
Blob Storage:
/tenants/{tenantId}/...
Per-tenant Key Vault for Premium tier (compliance):
KV-tenant-{id}
Tier-based feature flags
public class TierFeatureFilter(ITenantContext tc) : IFeatureFilter
{
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext ctx)
{
var tier = tc.GetTier();
var minTier = ctx.Parameters.GetValue<string>("MinTier");
return Task.FromResult(tierLevel(tier) >= tierLevel(minTier));
}
}
Rate limiting per tenant
o.AddPolicy("api", c => RateLimitPartition.GetTokenBucketLimiter(
c.User.FindFirst("tenant_id")?.Value ?? "anonymous",
_ => new TokenBucketRateLimiterOptions { TokenLimit = TierLimit(c), /* ... */ }));
Premium = dedicated
For Premium tenants, separate stack: - Dedicated DB (single-tenant). - Dedicated cache. - Dedicated app pods.
Or shared stack with priority routing (saturating shared first → premium overflow guarantees).
Backup + restore
- Per-tenant backup operations (export → archive).
- Point-in-time recovery via DB-level backups; restore individual tenant via
WHERE TenantId = ....
Multi-tenant pitfalls
- Cross-tenant query bug: leaks data; lawsuit-tier severity.
- Bulk ops without filter —
db.Users.Update(...)updates all. - Cache leak:
cache:user:{id}without tenant prefix → wrong tenant's data. - Logs: include tenant ID in every log; alert on cross-tenant access.
- Background jobs: must scope to tenant; usually loop tenants.
Cache key prefix
Always include tenant in cache keys.
Tenant lifecycle
- Provision: new tenant signs up → initial schema seed → admin user created.
- Suspend: deactivate; data preserved; no access.
- Delete: GDPR-compliant; all data purged.
Or soft-flag + scheduled purge.
Migrations
EF migrations run once per DB. For schema-per-tenant: migrate each schema. For shared DB: single migration applies to all tenants.
Observability
TenantId tag on every metric, log, span.
Per-tenant dashboards filtered by tag.
Alert on tenant-specific anomalies.
Cost allocation
For shared resources, attribute by usage metrics (e.g., RPS-weighted).
Trade-offs
| Pattern | Pros | Cons |
|---|---|---|
| Row-per-tenant | Cheap; scales | Bug → leak |
| Schema-per-tenant | Better isolation; per-tenant migrations | Postgres |
| DB-per-tenant | Strongest isolation | Doesn't scale ops |
| Hybrid (shared + premium dedicated) | Best of both | More complexity |
What we'd skip
- Microservices for each tenant — dramatically overkill.
- Event sourcing unless audit requires.