Skip to content

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 filterdb.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

$"tenant:{tenantId}:user:{userId}"

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.
DELETE FROM Users WHERE TenantId = ?;
DELETE FROM Orders WHERE TenantId = ?;
-- ... in TX

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

Tag every Azure resource with TenantId where possible.
Cost reports broken down per tenant.

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.

Cross-references