System Design Interview
Design a Multi-Tenant B2B API in ASP.NET Core
500 SaaS tenants, strict data isolation, shared PostgreSQL — tenant resolution, EF global filters, and when to graduate to schema-per-tenant
The Interview Question
"Design a multi-tenant B2B SaaS API in ASP.NET Core. Hundreds of companies share one deployment. Each tenant's data must be fully isolated. Walk through tenant resolution, database strategy, EF Core configuration, and background jobs."
This is one of the most common senior .NET system design questions — it tests whether you understand shared infrastructure with logical isolation, not just CRUD.
Step 1: Requirements
Functional
- REST API for orders, customers, and reports per tenant
- Tenant admins invite users; users belong to exactly one tenant
- Background jobs: nightly report generation per tenant
Non-functional
- ~500 tenants today, path to 5,000
- Zero cross-tenant data leakage (audit requirement)
- p99 read latency under 100ms for dashboard queries
- Deploy schema changes without per-tenant manual steps
Scale: ~2,000 requests/sec peak, 80% reads.
Step 2: Tenant Resolution
Every request must resolve TenantId before touching data.
Resolution order (first match wins):
1. JWT claim: "tid" (tenant id) — primary for API clients
2. HTTP header: X-Tenant-Id — service-to-service only (validated)
3. Subdomain: acme.api.example.com → tenant slug lookup (cached)public interface ICurrentTenant
{
Guid TenantId { get; }
string TenantSlug { get; }
}
public sealed class CurrentTenant : ICurrentTenant
{
public Guid TenantId { get; init; }
public string TenantSlug { get; init; } = "";
}
// Middleware — runs after authentication
public class TenantResolutionMiddleware
{
public async Task InvokeAsync(HttpContext ctx, TenantDbContext db, ICurrentTenantAccessor accessor)
{
var tid = ctx.User.FindFirstValue("tid");
if (tid is null || !Guid.TryParse(tid, out var tenantId))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
accessor.Current = new CurrentTenant { TenantId = tenantId };
await _next(ctx);
}
}Interview trap: Resolving tenant from an unauthenticated header alone. Strong answer: JWT claim is authoritative; header only for internal services with mTLS.
Step 3: Database Strategy — Decision Matrix
| Strategy | Isolation | Ops cost | EF migrations | Best for | |----------|-----------|----------|---------------|----------| | Shared DB + TenantId column + global filter | Logical | Low | One migration | Most B2B SaaS (500–5K tenants) | | Schema-per-tenant | Strong | High (N schemas) | Run N times or scripted | Regulated industries, enterprise tier | | Database-per-tenant | Strongest | Very high | Per-tenant pipeline | Few large enterprise customers |
Recommendation for this case: Shared PostgreSQL with TenantId on every tenant-owned table + EF global query filter + PostgreSQL RLS as a safety net.
public abstract class TenantEntity
{
public Guid TenantId { get; set; }
}
public class AppDbContext : DbContext
{
private readonly ICurrentTenant _tenant;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenant.TenantId);
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.TenantId, o.CreatedAt });
}
}-- Safety net: even raw SQL mistakes are blocked
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);Set app.tenant_id in a connection interceptor on every open connection.
Step 4: Architecture
┌─────────────────────────────────────┐
Client (JWT) ────▶│ ASP.NET Core API (Kestrel) │
│ Auth → Tenant middleware → API │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ PostgreSQL (shared, RLS enabled) │
│ tenants · users · orders · reports │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Hangfire / BackgroundService │
│ (TenantId in job payload) │
└─────────────────────────────────────┘Caching: Redis keys prefixed tenant:{tenantId}:... — never cache without tenant prefix.
Step 5: Background Jobs Without HttpContext
// Enqueue with explicit tenant
BackgroundJob.Enqueue<ReportGenerator>(x =>
x.GenerateAsync(tenantId, CancellationToken.None));
public class ReportGenerator(ICurrentTenantAccessor accessor, AppDbContext db)
{
public async Task GenerateAsync(Guid tenantId, CancellationToken ct)
{
accessor.Current = new CurrentTenant { TenantId = tenantId };
var orders = await db.Orders.AsNoTracking().ToListAsync(ct);
// global filter applies automatically
}
}Wrong approach: Static AsyncLocal tenant without setting it per job — causes cross-tenant leaks under concurrency.
Step 6: Enterprise Tier — Schema-Per-Tenant
When a customer pays for dedicated isolation:
Tenant registry table:
tenant_id | isolation_mode | connection_string_name
Resolve DbContext factory:
if (tenant.IsolationMode == DedicatedSchema)
return CreateContext($"Host=...;SearchPath=tenant_{tenantId}");
else
return CreateContext(sharedConnection);Run migrations via a hosted service that iterates tenants — never block deploy on 5,000 sequential migrations in one pipeline step.
What Interviewers Are Testing
- Tenant resolution — JWT-first, not header spoofing
- Defense in depth — global filter + RLS + cache key prefixing
- Background job context — explicit TenantId, no ambient statics
- Trade-off articulation — when to graduate from shared DB to schema-per-tenant
- Index design — composite
(TenantId, CreatedAt)on hot queries
Strong closing line: "I'd start with shared database and global filters — it's what most successful B2B SaaS platforms use until compliance or a single tenant's load forces dedicated infrastructure."
Related Case Studies
Go Deeper
Case studies teach the "what". Our courses teach the "how" — the patterns behind these decisions, built up from first principles.
Explore Courses