How to Design Multi-Tenant SaaS Platform
Building isolation, tenant provisioning, billing, and scaling patterns for enterprise SaaS at 10K+ tenant scale
1. Introduction — Multi-Tenant SaaS Fundamentals
A multi-tenant SaaS platform serves multiple customers (tenants) from a single, shared instance of the application. Each tenant's data is isolated, invisible to other tenants, and logically separated even though the underlying infrastructure is shared. This model is the backbone of modern enterprise software — from Salesforce to Microsoft 365 to Slack.
Building a multi-tenant SaaS platform is one of the most complex system design challenges a senior engineer can face. It touches every layer of the stack: database design, authentication, billing, scaling, compliance, and even business logic isolation. This article is a comprehensive, senior-level deep dive into designing such a system from the ground up, targeting 10,000+ tenants with varying workloads, compliance requirements, and customization needs.
Why Multi-Tenant?
Multi-tenancy is fundamentally an economic optimization. Instead of deploying a separate application instance for each customer, you share compute, storage, and networking resources. This drives down cost-per-tenant by orders of magnitude while allowing rapid feature delivery across your entire customer base. The trade-off is engineering complexity: you must enforce strict isolation, handle noisy neighbors, support per-tenant customization, and provide granular billing — all without degrading the experience for any single tenant.
Shared vs. Dedicated Infrastructure
Dedicated Infrastructure (Silo Model): Each tenant (or a small group of premium tenants) gets dedicated compute and/or database resources. This provides stronger isolation guarantees, better performance predictability, and easier compliance — but at significantly higher cost. Common in enterprise SaaS with strict regulatory requirements.
In practice, most production SaaS platforms adopt a hybrid model: a shared pool for the majority of tenants, with dedicated resources reserved for enterprise customers who pay a premium or require data residency guarantees.
2. Functional & Non-Functional Requirements
Functional Requirements
- Tenant Onboarding: Self-service signup with automated provisioning of database schemas, feature flags, and default configurations within 30 seconds.
- Tenant Context Propagation: Every request must carry tenant identity from the edge to the database layer.
- Isolation: No tenant must ever access another tenant's data — at the application, database, cache, and storage layers.
- Authentication: Support SSO (SAML/OIDC), email/password, and social login. Tenant-scoped JWT tokens.
- Authorization: RBAC within each tenant, with roles like Owner, Admin, Member, and Viewer.
- Feature Gating: Plans (Free, Pro, Enterprise) with per-tenant feature flags.
- Billing: Usage-based, per-seat, and flat-rate subscription models with Stripe integration.
- Customization: White-labeling with custom domains, themes, logos, and email templates.
- Audit Logging: Every mutation must be logged with tenant ID, user ID, action, and timestamp.
- Data Export/Import: Per-tenant data export (GDPR right to portability) and bulk import.
- Admin Impersonation: Platform admins can impersonate a tenant for support purposes with full audit trail.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.95% (4.38h downtime/year) | Enterprise customers require high SLA |
| Latency (p99) | < 200ms for API reads | Interactive dashboard responsiveness |
| Tenant Count | 10,000+ active tenants | Growth projection for 3 years |
| Users per Tenant | 1 to 10,000 | SMB to enterprise range |
| Data per Tenant | 1MB to 500GB | Varies by plan and usage |
| Provisioning Time | < 30 seconds | Self-service onboarding UX |
| Backup RTO | < 1 hour | Enterprise compliance |
| Backup RPO | < 5 minutes | Minimal data loss |
| Throughput | 50,000 requests/second aggregate | Peak load across all tenants |
| Data Residency | US, EU, APAC | GDPR and regional compliance |
3. Capacity Estimation
Request Throughput
- 50,000 DAU × 5 req/min = 250,000 requests/min = ~4,167 requests/second
- Peak (3x): ~12,500 requests/second
- With burst tolerance, design for 50,000 req/s aggregate capacity
Data Volume
| Data Type | Per Tenant (avg) | Total (10K tenants) | Growth/Year |
|---|---|---|---|
| Application Data | 5 GB | 50 TB | 20 TB |
| Audit Logs | 1 GB | 10 TB | 10 TB |
| File Attachments | 2 GB | 20 TB | 15 TB |
| Analytics/Events | 3 GB | 30 TB | 30 TB |
| Total | 11 GB | 110 TB | 75 TB |
Infrastructure Sizing
- Application Servers: 8-12 instances (8 vCPU, 32GB RAM each) behind a load balancer
- Primary Database: PostgreSQL cluster with 2 read replicas (64 vCPU, 256GB RAM, 10TB NVMe)
- Cache Layer: Redis Cluster with 6 nodes (16GB each, 96GB total)
- Message Queue: RabbitMQ or Kafka cluster (3 brokers)
- Object Storage: S3-compatible storage for files and backups
4. Data Model
The data model is the foundation of multi-tenancy. Every table must carry a tenant_id column, and every query must filter by it. Below is the core schema.
-- Core tenant and user tables
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
plan VARCHAR(50) NOT NULL DEFAULT 'free',
status VARCHAR(50) NOT NULL DEFAULT 'active',
settings JSONB DEFAULT '{}',
custom_domain VARCHAR(255),
logo_url TEXT,
primary_color VARCHAR(7) DEFAULT '#0088ff',
data_region VARCHAR(20) DEFAULT 'us-east',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash TEXT,
role VARCHAR(50) NOT NULL DEFAULT 'member',
status VARCHAR(50) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
plan VARCHAR(50) NOT NULL,
billing_model VARCHAR(50) NOT NULL DEFAULT 'flat',
stripe_sub_id VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
seat_count INT DEFAULT 0,
usage_bytes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
feature_key VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT false,
config JSONB DEFAULT '{}',
UNIQUE(tenant_id, feature_key)
);
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID,
action VARCHAR(255) NOT NULL,
resource_type VARCHAR(100),
resource_id UUID,
old_value JSONB,
new_value JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX idx_subscriptions_tenant ON subscriptions(tenant_id);
CREATE INDEX idx_feature_flags_tenant ON feature_flags(tenant_id, feature_key);
tenant_idis present on every table and is always the first column in composite indexes.- Feature flags are stored per-tenant, not per-user, to simplify plan management.
- Audit logs include
old_valueandnew_valuefor full change tracking. - The
settingsJSONB column on tenants stores extensible configuration without schema changes.
5. API Design
Every API endpoint must resolve the tenant context before processing business logic. We use three mechanisms for tenant identification:
- Subdomain:
acme.app.example.com— extractsacmeas tenant slug - Custom Domain:
app.acmecorp.com— resolves via DNS lookup table - Header:
X-Tenant-ID: uuid— used for admin/impersonation endpoints
Core API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/login | Tenant-scoped login | None |
| POST | /api/v1/auth/sso/{tenant_slug} | SSO initiation | None |
| GET | /api/v1/tenants/me | Current tenant details | JWT |
| PATCH | /api/v1/tenants/me | Update tenant settings | Admin |
| GET | /api/v1/users | List users in tenant | JWT |
| POST | /api/v1/users/invite | Invite user to tenant | Admin |
| GET | /api/v1/projects | List tenant projects | JWT |
| POST | /api/v1/projects | Create project | Editor+ |
| GET | /api/v1/billing/usage | Usage metering data | Admin |
| POST | /api/v1/admin/impersonate | Impersonate tenant | SuperAdmin |
Tenant Context Middleware (C#)
public class TenantContextMiddleware
{
private readonly RequestDelegate _next;
private readonly ITenantResolver _resolver;
public TenantContextMiddleware(RequestDelegate next, ITenantResolver resolver)
{
_next = next;
_resolver = resolver;
}
public async Task InvokeAsync(HttpContext context)
{
var tenantId = await ResolveTenantId(context);
if (tenantId == null)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new { error = "Tenant not found" });
return;
}
// Set tenant context for the entire request pipeline
context.Items["TenantId"] = tenantId;
context.Items["TenantSlug"] = await _resolver.GetSlugAsync(tenantId.Value);
// Create a scoped claim for JWT propagation
var claims = new List<Claim>(context.User.Claims)
{
new Claim("tenant_id", tenantId.Value.ToString())
};
var identity = new ClaimsIdentity(claims, context.User.Identity?.AuthenticationType);
context.User = new ClaimsPrincipal(identity);
await _next(context);
}
private async Task<Guid?> ResolveTenantId(HttpContext context)
{
// 1. Check X-Tenant-ID header (admin endpoints)
if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var headerVal)
&& Guid.TryParse(headerVal, out var headerTenantId))
{
var caller = context.User;
if (!caller.IsInRole("SuperAdmin"))
throw new UnauthorizedAccessException("Impersonation requires SuperAdmin role");
return headerTenantId;
}
// 2. Check subdomain
var host = context.Request.Host.Host;
var parts = host.Split('.');
if (parts.Length > 2)
{
var slug = parts[0];
return await _resolver.GetTenantIdBySlugAsync(slug);
}
// 3. Check custom domain
return await _resolver.GetTenantIdByDomainAsync(host);
}
}
6. High-Level Architecture
Architecture Layers
| Layer | Components | Tenant Awareness |
|---|---|---|
| Edge | CDN, WAF, DNS | Subdomain/custom domain routing |
| Gateway | API Gateway, Rate Limiter | Per-tenant rate limits, IP allowlisting |
| Application | Microservices / Monolith | Tenant context injected via middleware |
| Data | PostgreSQL, Redis, S3 | Row-level security, scoped cache keys |
| Async | Kafka, Workers | Event headers carry tenant_id |
| External | Stripe, SendGrid, Twilio | Tenant-specific API keys (enterprise) |
7. Tenant Isolation Strategies
Tenant isolation is the single most critical design decision in a multi-tenant SaaS. There are three primary strategies, each with distinct trade-offs.
Strategy 1: Shared Database, Shared Schema (Discriminator Column)
All tenants share the same database and the same tables. A tenant_id discriminator column is present on every row. Every query includes a WHERE tenant_id = @tenantId clause, enforced via Row-Level Security (RLS) in PostgreSQL.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- In your application, before each request:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
| Aspect | Shared DB, Shared Schema |
|---|---|
| Cost | Lowest — single database instance |
| Isolation | Weakest — relies on application discipline + RLS |
| Maintenance | Simplest — one schema to migrate |
| Noisy Neighbor | Most severe — shared connection pool and indexes |
| Data Export | Complex — must filter by tenant_id |
| Suitable For | B2B SaaS with < 1,000 small tenants |
Strategy 2: Shared Database, Schema per Tenant
Each tenant gets its own schema (namespace) within a single PostgreSQL database. Tables are duplicated per schema but the database engine is shared.
-- Provisioning a new tenant schema
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tenant_acme.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES tenant_acme.projects(id),
title VARCHAR(500) NOT NULL,
status VARCHAR(50) DEFAULT 'todo',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Search path is set per-connection or per-transaction
SET search_path TO tenant_acme, public;
| Aspect | Shared DB, Schema per Tenant |
|---|---|
| Cost | Moderate — shared database engine, more schemas |
| Isolation | Good — schema-level separation, harder to cross-tenant |
| Maintenance | Complex — migrations must be applied to all tenant schemas |
| Noisy Neighbor | Moderate — shared engine, but indexes are per-schema |
| Data Export | Easy — dump a single schema |
| Suitable For | Mid-market SaaS, 1K–50K tenants |
Strategy 3: Database per Tenant
Each tenant gets a completely separate database instance (or at minimum, a separate logical database). This provides the strongest isolation guarantees.
| Aspect | Database per Tenant |
|---|---|
| Cost | Highest — each tenant needs its own DB resources |
| Isolation | Strongest — physical separation of data |
| Maintenance | Most complex — 10K+ databases to migrate |
| Noisy Neighbor | Eliminated — dedicated connection pool per tenant |
| Data Export | Trivial — backup the entire database |
| Suitable For | Enterprise SaaS, regulated industries |
8. Tenant Provisioning & Onboarding Pipeline
Provisioning Steps
- Validate Input: Check slug uniqueness, plan validity, and admin email format.
- Create Tenant Record: Insert into the
tenantstable with statusprovisioning. - Publish Event: Emit
TenantCreatedEventto the message queue for async processing. - Create Schema/RLS Policy: Depending on isolation strategy, create a new schema or RLS policy.
- Seed Default Data: Create default project, settings, admin user, and feature flags based on the selected plan.
- Initialize Cache: Populate Redis with tenant configuration for fast reads.
- Send Welcome Email: Trigger onboarding email with login credentials or SSO setup instructions.
- Mark Active: Update tenant status to
active.
9. Authentication & Authorization
Tenant-Scoped JWT Structure
{
"sub": "user-uuid-12345",
"tenant_id": "tenant-uuid-67890",
"tenant_slug": "acme",
"email": "admin@acme.com",
"role": "admin",
"plan": "pro",
"features": ["api_access", "sso", "audit_log"],
"iat": 1721001600,
"exp": 1721088000,
"iss": "auth.saaSplatform.com"
}
// Token generation (C#)
public string GenerateTenantToken(User user, Tenant tenant)
{
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim("tenant_id", tenant.Id.ToString()),
new Claim("tenant_slug", tenant.Slug),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim("role", user.Role),
new Claim("plan", tenant.Plan)
};
// Add feature flags to JWT for fast authorization checks
var features = _featureService.GetEnabledFeatures(tenant.Id);
foreach (var feature in features)
{
claims.Add(new Claim("feature", feature.Key));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Secret"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(8),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
RBAC Permission Matrix
| Permission | Owner | Admin | Member | Viewer |
|---|---|---|---|---|
| Delete Tenant | Yes | No | No | No |
| Manage Billing | Yes | Yes | No | No |
| Manage Users | Yes | Yes | No | No |
| Update Settings | Yes | Yes | No | No |
| Create Projects | Yes | Yes | Yes | No |
| Edit Resources | Yes | Yes | Yes | No |
| View Resources | Yes | Yes | Yes | Yes |
| View Audit Logs | Yes | Yes | No | No |
10. Feature Gating & Plan Management
Feature gating determines which capabilities are available to each tenant based on their subscription plan. The system must support both static plan-based features and dynamic per-tenant overrides.
Plan Definitions
| Feature | Free | Pro ($29/mo) | Enterprise ($299/mo) |
|---|---|---|---|
| Projects | 3 | 50 | Unlimited |
| Users | 5 | 25 | 10,000 |
| Storage | 1 GB | 50 GB | 500 GB |
| API Access | 1K req/day | 100K req/day | Unlimited |
| SSO/SAML | No | No | Yes |
| Audit Logs | 7 days | 90 days | Unlimited |
| White-Labeling | No | Logo only | Full custom domain |
| SLA | None | 99.9% | 99.95% + dedicated support |
| Data Export | No | CSV | CSV + API + Custom |
| Priority Support | No | Email + Slack + Phone |
Feature Flag Evaluation Engine
public class FeatureGateService : IFeatureGateService
{
private readonly IFeatureFlagRepository _flagRepo;
private readonly IPlanDefinitionRepository _planRepo;
private readonly IDistributedCache _cache;
public async Task<bool> IsFeatureEnabledAsync(Guid tenantId, string featureKey)
{
// 1. Check for tenant-specific override first
var cacheKey = $"feature:{tenantId}:{featureKey}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return bool.Parse(cached);
// 2. Check tenant-specific feature flag
var flag = await _flagRepo.GetAsync(tenantId, featureKey);
if (flag != null)
{
await _cache.SetStringAsync(cacheKey, flag.Enabled.ToString(),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
return flag.Enabled;
}
// 3. Fall back to plan-level defaults
var tenant = await _tenantRepo.GetByIdAsync(tenantId);
var planFeatures = await _planRepo.GetPlanFeaturesAsync(tenant.Plan);
var isEnabled = planFeatures.Any(f => f.Key == featureKey && f.DefaultEnabled);
await _cache.SetStringAsync(cacheKey, isEnabled.ToString(),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
return isEnabled;
}
public async Task<ResourceLimit> GetResourceLimitAsync(Guid tenantId, string resourceKey)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId);
var planLimits = await _planRepo.GetPlanLimitsAsync(tenant.Plan);
// Enterprise tenants can have custom overrides
if (tenant.Plan == "enterprise")
{
var customLimit = await _flagRepo.GetResourceLimitOverrideAsync(tenantId, resourceKey);
if (customLimit != null) return customLimit;
}
return planLimits.FirstOrDefault(l => l.ResourceKey == resourceKey)
?? ResourceLimit.Unlimited;
}
}
11. Billing & Subscription Management
Usage Metering Pipeline
Usage-based billing requires a robust metering pipeline that can handle high-volume event ingestion without impacting the main application's performance. Every billable event (API call, file upload, compute second) is published to Kafka, aggregated by the metering service, and reported to Stripe on a billing cycle.
| Model | Examples | Pros | Cons |
|---|---|---|---|
| Flat-Rate | $29/mo for Pro plan | Predictable revenue, simple billing | May over/under charge tenants |
| Per-Seat | $10/user/month | Scales with customer growth | Discourages user adoption |
| Usage-Based | $0.01 per API call | Fair pricing, aligns with value | Unpredictable for customers |
| Tiered | $0.01 first 100K, $0.005 after | Volume incentives | Complex to implement |
Stripe Webhook Handler (C#)
[ApiController]
[Route("api/v1/billing/webhooks")]
public class StripeWebhookController : ControllerBase
{
private readonly ISubscriptionService _subscriptionService;
private readonly ITenantService _tenantService;
private readonly IAuditLogService _auditLog;
private readonly string _webhookSecret;
[HttpPost]
public async Task<IActionResult> HandleWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
_webhookSecret
);
switch (stripeEvent.Type)
{
case Events.CustomerSubscriptionUpdated:
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionUpdated(subscription);
break;
case Events.CustomerSubscriptionDeleted:
var deletedSub = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionCancelled(deletedSub);
break;
case Events.InvoicePaymentFailed:
var failedInvoice = stripeEvent.Data.Object as Stripe.Invoice;
await HandlePaymentFailed(failedInvoice);
break;
case Events.InvoicePaid:
var paidInvoice = stripeEvent.Data.Object as Stripe.Invoice;
await HandlePaymentSucceeded(paidInvoice);
break;
}
return Ok();
}
private async Task HandleSubscriptionUpdated(Stripe.Subscription subscription)
{
var tenantId = Guid.Parse(subscription.Metadata["tenant_id"]);
var status = subscription.Status switch
{
"active" => TenantStatus.Active,
"past_due" => TenantStatus.PastDue,
"canceled" => TenantStatus.Suspended,
_ => TenantStatus.Active
};
await _subscriptionService.UpdateFromStripeAsync(tenantId, subscription);
await _tenantService.UpdateStatusAsync(tenantId, status);
await _auditLog.LogAsync(tenantId, null, "subscription.updated", "Subscription",
metadata: new { stripeStatus = subscription.Status, plan = subscription.Items.First().Price.Id });
}
private async Task HandlePaymentFailed(Stripe.Invoice invoice)
{
var tenantId = Guid.Parse(invoice.Subscription.Metadata["tenant_id"]);
await _tenantService.UpdateStatusAsync(tenantId, TenantStatus.PastDue);
// Send payment failure notification
// Start grace period timer (7 days before suspension)
}
}
12. Data Residency & Compliance
GDPR, CCPA, and industry-specific regulations require that certain tenants' data never leaves specific geographic boundaries. A multi-tenant SaaS must support data residency while maintaining a unified application experience.
Compliance Checklist
| Regulation | Requirement | Implementation |
|---|---|---|
| GDPR | Data minimization, right to erasure | Per-tenant soft delete + hard purge pipeline, data export API |
| CCPA | Opt-out of data sale, disclosure | Privacy settings per tenant, no data selling |
| SOC 2 | Audit logging, access controls | Immutable audit logs, RBAC, MFA enforcement |
| HIPAA | PHI encryption, access logging | At-rest + transit encryption, BAA with cloud provider |
| ISO 27001 | Information security management | Policies, incident response, risk assessment |
13. Noisy Neighbor Problem & Resource Quotas
The noisy neighbor problem occurs when one tenant's excessive usage degrades performance for all other tenants sharing the same infrastructure. This is a critical concern in multi-tenant systems.
Resource Quota Configuration
| Resource | Free Tier | Pro Tier | Enterprise Tier |
|---|---|---|---|
| API Rate Limit | 100 req/min | 1,000 req/min | 10,000 req/min |
| DB Connections | 5 | 25 | 100 (dedicated pool) |
| Storage | 1 GB | 50 GB | 500 GB |
| Background Jobs | 10/hour | 100/hour | Unlimited |
| Concurrent Users | 5 | 25 | 10,000 |
| File Upload Size | 5 MB | 50 MB | 500 MB |
| Query Execution Time | 5s | 30s | 120s |
14. Customization & White-Labeling
Enterprise tenants expect the SaaS platform to feel like their own product. White-labeling enables custom branding, domains, and UI themes.
Customization Architecture
White-Label Configuration
| Customization | Free | Pro | Enterprise |
|---|---|---|---|
| Logo | Platform logo | Custom logo | Custom logo + favicon |
| Colors | Default theme | Primary color | Full color palette |
| Custom Domain | No | No | app.client.com |
| Email Templates | Platform branded | Platform branded | Custom HTML templates |
| Login Page | Platform login | Platform login | Custom branded login |
| CSS Overrides | No | No | Custom CSS injection |
15. API Rate Limiting & Throttling
Per-tenant rate limiting prevents any single tenant from monopolizing shared resources. We implement a sliding window algorithm using Redis.
public class TenantRateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _redis;
private readonly IFeatureGateService _featureGate;
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.Items["TenantId"] as Guid?;
if (tenantId == null)
{
await _next(context);
return;
}
// Get tenant's rate limit from plan
var limits = await _featureGate.GetResourceLimitAsync(tenantId.Value, "api_rate_limit");
var maxRequests = limits.Value; // e.g., 1000 requests per minute
var redisKey = $"ratelimit:{tenantId.Value}:{DateTime.UtcNow:yyyy-MM-dd-HH-mm}";
var currentCount = await _redis.IncrementAsync(redisKey);
// Set expiry on first request
if (currentCount == 1)
{
await _redis.SetExpireAsync(redisKey, TimeSpan.FromMinutes(1));
}
// Set rate limit headers
context.Response.Headers["X-RateLimit-Limit"] = maxRequests.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, maxRequests - currentCount).ToString();
if (currentCount > maxRequests)
{
context.Response.StatusCode = 429;
context.Response.Headers["Retry-After"] = "60";
await context.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded",
limit = maxRequests,
retryAfter = 60
});
return;
}
await _next(context);
}
}
16. Tenant Analytics & Usage Metering
Usage analytics serve two purposes: powering the billing system and providing tenants with insights into their own usage patterns.
Key Metrics Per Tenant
| Metric | Aggregation | Retention | Used For |
|---|---|---|---|
| API Calls | Per-minute counter | 1 year | Billing, rate limiting |
| Data Storage | Daily snapshot | 2 years | Billing, quotas |
| Active Users | Daily unique | 1 year | Per-seat billing |
| Feature Usage | Per-event | 90 days | Product analytics |
| Error Rate | Per-minute | 30 days | SRE monitoring |
| Response Time | p50/p95/p99 | 30 days | Performance monitoring |
17. Disaster Recovery & Backup
Multi-tenant backup must support both platform-wide disaster recovery and per-tenant point-in-time restore.
Backup Strategy
| Backup Type | Frequency | Retention | Recovery Time |
|---|---|---|---|
| WAL Archiving | Continuous | 30 days | Minutes (PITR) |
| Full Database Snapshot | Daily | 90 days | 1-4 hours |
| Cross-Region Replication | Continuous | 7 days | < 1 hour (failover) |
| Per-Tenant Export | On-demand | Until deleted | Minutes |
18. Database Sharding Strategy
When a single database can no longer handle the aggregate load of 10,000+ tenants, you need to shard. Tenant-aware sharding is natural because tenant_id is already on every table.
Sharding Approaches
| Strategy | Description | Pros | Cons |
|---|---|---|---|
| Hash-based | Hash(tenant_id) % num_shards | Uniform distribution | Resharding is painful |
| Range-based | Tenant ID ranges per shard | Easy to add shards | Hotspots possible |
| Directory-based | Lookup table maps tenant → shard | Flexible migration | Extra lookup hop |
| Geographic | Shard by data_region | Natural data residency | Uneven shard sizes |
tenant_shard_map table that maps each tenant to its shard. This allows you to migrate tenants between shards without downtime and supports mixed strategies (hash for small tenants, dedicated shards for enterprise).
-- Shard routing table
CREATE TABLE tenant_shard_map (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
shard_id INT NOT NULL,
shard_host VARCHAR(255) NOT NULL,
shard_database VARCHAR(100) NOT NULL,
migrated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Shard routing middleware
public class ShardRoutingInterceptor : DbCommandInterceptor
{
private readonly IShardResolver _resolver;
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
var tenantId = GetTenantId(command);
if (tenantId != null)
{
var shard = _resolver.GetShard(tenantId.Value);
command.Connection.ConnectionString = shard.ConnectionString;
}
return base.NonQueryExecuting(command, eventData, result);
}
}
19. Caching Strategy
Cache keys must be tenant-scoped to prevent cross-tenant data leakage. A simple convention: {tenant_id}:{resource_type}:{resource_id}.
public class TenantCacheService : ITenantCacheService
{
private readonly IDistributedCache _cache;
private string TenantKey(Guid tenantId, string resourceType, string resourceId)
=> $"tenant:{tenantId}:{resourceType}:{resourceId}";
public async Task<T?> GetAsync<T>(Guid tenantId, string resourceType, string resourceId)
{
var key = TenantKey(tenantId, resourceType, resourceId);
var data = await _cache.GetStringAsync(key);
return data == null ? default : JsonSerializer.Deserialize<T>(data);
}
public async Task SetAsync<T>(Guid tenantId, string resourceType, string resourceId,
T value, TimeSpan? expiry = null)
{
var key = TenantKey(tenantId, resourceType, resourceId);
var data = JsonSerializer.Serialize(value);
await _cache.SetStringAsync(key, data, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiry ?? TimeSpan.FromMinutes(10)
});
}
public async Task InvalidateAsync(Guid tenantId, string resourceType, string resourceId)
{
var key = TenantKey(tenantId, resourceType, resourceId);
await _cache.RemoveAsync(key);
}
// Bulk invalidate all resources of a type for a tenant
public async Task InvalidateAllAsync(Guid tenantId, string resourceType)
{
// Use Redis SCAN with pattern matching
var pattern = $"tenant:{tenantId}:{resourceType}:*";
// Implementation depends on Redis client (StackExchange.Redis)
}
}
Cache Architecture
| Cache Layer | Technology | TTL | What's Cached |
|---|---|---|---|
| L1 (In-Process) | MemoryCache | 30 seconds | Tenant config, feature flags |
| L2 (Distributed) | Redis Cluster | 5-10 minutes | User sessions, project data, API responses |
| L3 (CDN) | CloudFront | 1 hour | Static assets, tenant logos |
20. Migration & Schema Evolution
Schema migrations in a multi-tenant system are particularly challenging because a single migration must be applied across potentially thousands of tenant schemas without downtime.
Migration Strategy
- Expand Contract Pattern: Never remove a column in the same deployment that stops using it. First, add the new column, migrate data, update code to use the new column, then remove the old column in a subsequent release.
- Backwards-Compatible Changes: Every migration must be backwards-compatible with the current application version. This allows rolling deployments.
- Batched Schema Migrations: For schema-per-tenant, apply migrations in batches of 100 schemas per transaction with progress tracking.
- Migration Lock: Use a distributed lock (Redis) to prevent concurrent migration runs.
// Multi-tenant schema migration runner
public class TenantSchemaMigrationRunner
{
private readonly IEnumerable<string> _tenantSchemas;
private readonly IMigrationRunner _migrationRunner;
private readonly IDistributedLock _lock;
private readonly ILogger<TenantSchemaMigrationRunner> _logger;
public async Task RunAllMigrationsAsync()
{
using var handle = await _lock.AcquireAsync("schema-migrations", TimeSpan.FromMinutes(30));
if (handle == null)
{
_logger.LogWarning("Could not acquire migration lock — another migration may be running");
return;
}
var schemas = _tenantSchemas.ToList();
var batchSize = 100;
var totalProcessed = 0;
for (var i = 0; i < schemas.Count; i += batchSize)
{
var batch = schemas.Skip(i).Take(batchSize).ToList();
foreach (var schema in batch)
{
try
{
await MigrateSchemaAsync(schema);
totalProcessed++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Migration failed for schema {Schema}. Marking for retry.", schema);
await MarkMigrationFailedAsync(schema, ex.Message);
}
}
_logger.LogInformation("Migrated {Processed}/{Total} schemas", totalProcessed, schemas.Count);
await Task.Delay(TimeSpan.FromSeconds(1)); // Throttle to reduce DB load
}
}
private async Task MigrateSchemaAsync(string schema)
{
// Set search path and run Flyway/Liquibase/custom migration
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbConnection>();
await db.ExecuteAsync($"SET search_path TO {schema}");
await _migrationRunner.MigrateAsync();
}
}
21. Multi-Region Design
Multi-Region Considerations
| Component | Strategy | Replication |
|---|---|---|
| Application | Deploy in each region | Stateless — no replication needed |
| Primary Database | Region-local per tenant | Async cross-region for DR only |
| Cache | Region-local | No cross-region replication |
| Object Storage | Region-local bucket | Cross-region replication for backups |
| Auth Service | Global with region-local tokens | JWT validation is stateless |
| Billing | Centralized (single Stripe account) | N/A |
| Event Bus | Region-local Kafka clusters | Cross-region mirror for global events |
22. Cost Estimation
| Component | Specification | Monthly Cost (USD) |
|---|---|---|
| Application Servers (8x) | c6i.2xlarge (8 vCPU, 32GB) | $1,200 |
| Primary PostgreSQL | r6i.2xlarge (64 vCPU, 256GB, 10TB) | $3,500 |
| Read Replicas (2x) | r6i.xlarge | $1,400 |
| Redis Cluster (6 nodes) | r6g.large (16GB each) | $900 |
| Kafka Cluster (3 brokers) | kafka.m5.large | $800 |
| Load Balancer | ALB + WAF | $400 |
| Object Storage (S3) | 100TB + transfer | $3,000 |
| CDN (CloudFront) | 5TB transfer/month | $500 |
| Monitoring (Datadog) | APM + infra + logs | $2,000 |
| Email (SES) | 1M emails/month | $100 |
| DNS (Route53) | Hosted zones + queries | $50 |
| Multi-Region (x2 DR) | ~50% of primary | $7,000 |
| Total Monthly | ~$20,850 |
23. Interview Q&A
WHERE tenant_id = @current_tenant enforced via PostgreSQL Row-Level Security policies, (2) the application middleware extracts tenant_id from the JWT and injects it into the DB connection context, (3) cache keys are namespaced by tenant_id, (4) file storage paths are prefixed with tenant_id, and (5) audit logs track every data access for anomaly detection.tenant:{tenant_id}:{resource}:{id}. Implement this as a reusable caching decorator so application developers never have to remember to scope keys manually. For bulk invalidation (e.g., on plan change), use Redis SCAN with pattern matching. Never use global cache keys without tenant scoping. Add integration tests that attempt to read another tenant's cached data to verify isolation.X-Tenant-ID header that's only accepted for users with the SuperAdmin role. The middleware validates the impersonator's role before accepting the header. Every impersonated action is logged with both the impersonator's user_id and the target tenant_id in the audit log. Implement a time-limited impersonation token (max 1 hour) with explicit start/end logging. Provide a visual indicator in the UI when in impersonation mode.data_region field that determines which regional cluster their data resides in. The API gateway routes requests to the correct region based on this field. Data never leaves its designated region except for encrypted backups replicated cross-region for disaster recovery. Implement data export and deletion APIs for GDPR compliance. Use region-specific encryption keys managed via a KMS. Provide a tenant admin dashboard showing exactly where their data is stored.24. Full C# Implementation
Below is a comprehensive C# implementation of the core multi-tenant services. This code demonstrates the key patterns discussed throughout the article: tenant context propagation, isolation, feature gating, billing metering, and audit logging.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace MultiTenantSaaS.Core
{
// ==========================================
// Domain Models
// ==========================================
public enum TenantStatus
{
Provisioning,
Active,
PastDue,
Suspended,
Deactivated
}
public enum BillingModel
{
Flat,
PerSeat,
UsageBased,
Tiered
}
public class Tenant
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Plan { get; set; } = "free";
public TenantStatus Status { get; set; } = TenantStatus.Provisioning;
public string? CustomDomain { get; set; }
public string? LogoUrl { get; set; }
public string PrimaryColor { get; set; } = "#0088ff";
public string DataRegion { get; set; } = "us-east";
public Dictionary<string, object> Settings { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class TenantUser
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Role { get; set; } = "member";
public string Status { get; set; } = "active";
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Subscription
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Plan { get; set; } = string.Empty;
public BillingModel BillingModel { get; set; } = BillingModel.Flat;
public string? StripeSubscriptionId { get; set; }
public string Status { get; set; } = "active";
public DateTime? CurrentPeriodStart { get; set; }
public DateTime? CurrentPeriodEnd { get; set; }
public int SeatCount { get; set; }
public long UsageBytes { get; set; }
}
public class FeatureFlag
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string FeatureKey { get; set; } = string.Empty;
public bool Enabled { get; set; }
public Dictionary<string, object> Config { get; set; } = new();
}
public class AuditLogEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Guid? UserId { get; set; }
public string Action { get; set; } = string.Empty;
public string? ResourceType { get; set; }
public Guid? ResourceId { get; set; }
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class ResourceLimit
{
public string ResourceKey { get; set; } = string.Empty;
public long Value { get; set; }
public string Unit { get; set; } = string.Empty;
public bool IsUnlimited { get; set; }
public static ResourceLimit Unlimited { get; } = new()
{
IsUnlimited = true,
Value = long.MaxValue,
Unit = "unlimited"
};
}
public class PlanDefinition
{
public string PlanName { get; set; } = string.Empty;
public decimal MonthlyPrice { get; set; }
public BillingModel BillingModel { get; set; }
public List<PlanFeature> Features { get; set; } = new();
public List<ResourceLimit> Limits { get; set; } = new();
}
public class PlanFeature
{
public string Key { get; set; } = string.Empty;
public bool DefaultEnabled { get; set; }
public string? Description { get; set; }
}
// ==========================================
// Interfaces
// ==========================================
public interface ITenantRepository
{
Task<Tenant?> GetByIdAsync(Guid id);
Task<Tenant?> GetBySlugAsync(string slug);
Task<Tenant?> GetByDomainAsync(string domain);
Task<Tenant> CreateAsync(Tenant tenant);
Task UpdateAsync(Tenant tenant);
}
public interface IUserRepository
{
Task<TenantUser?> GetByEmailAsync(Guid tenantId, string email);
Task<List<TenantUser>> GetByTenantIdAsync(Guid tenantId);
Task<TenantUser> CreateAsync(TenantUser user);
Task UpdateAsync(TenantUser user);
}
public interface ISubscriptionRepository
{
Task<Subscription?> GetActiveByTenantIdAsync(Guid tenantId);
Task<Subscription> CreateAsync(Subscription subscription);
Task UpdateAsync(Subscription subscription);
}
public interface IAuditLogRepository
{
Task LogAsync(AuditLogEntry entry);
Task<List<AuditLogEntry>> GetByTenantIdAsync(Guid tenantId, int skip, int take);
}
// ==========================================
// Tenant Context (Request-scoped)
// ==========================================
public class TenantContext
{
public Guid? TenantId { get; set; }
public string? TenantSlug { get; set; }
public Tenant? Tenant { get; set; }
public TenantUser? CurrentUser { get; set; }
public bool IsValid => TenantId.HasValue && Tenant != null;
}
// ==========================================
// Tenant Middleware
// ==========================================
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, TenantContext tenantCtx,
ITenantRepository tenantRepo)
{
var tenantId = await ResolveTenantIdAsync(context, tenantRepo);
if (tenantId == null)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsJsonAsync(new
{
error = "tenant_not_found",
message = "Could not resolve tenant from request"
});
return;
}
tenantCtx.TenantId = tenantId.Value;
tenantCtx.Tenant = await tenantRepo.GetByIdAsync(tenantId.Value);
tenantCtx.TenantSlug = tenantCtx.Tenant?.Slug;
// Propagate tenant_id to claims
var identity = context.User.Identity as ClaimsIdentity;
identity?.AddClaim(new Claim("tenant_id", tenantId.Value.ToString()));
await _next(context);
}
private async Task<Guid?> ResolveTenantIdAsync(HttpContext context,
ITenantRepository tenantRepo)
{
// 1. X-Tenant-ID header (admin impersonation)
if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var headerVal)
&& Guid.TryParse(headerVal, out var headerTenantId))
{
if (!context.User.IsInRole("SuperAdmin"))
return null;
return headerTenantId;
}
// 2. Subdomain
var host = context.Request.Host.Host;
var parts = host.Split('.');
if (parts.Length > 2)
{
var slug = parts[0];
var tenant = await tenantRepo.GetBySlugAsync(slug);
return tenant?.Id;
}
// 3. Custom domain
var domainTenant = await tenantRepo.GetByDomainAsync(host);
return domainTenant?.Id;
}
}
// ==========================================
// Tenant Provisioning Service
// ==========================================
public class TenantProvisioningService
{
private readonly ITenantRepository _tenantRepo;
private readonly IUserRepository _userRepo;
private readonly ISubscriptionRepository _subscriptionRepo;
private readonly IAuditLogRepository _auditLog;
private readonly IFeatureGateService _featureGate;
private readonly ILogger<TenantProvisioningService> _logger;
public TenantProvisioningService(
ITenantRepository tenantRepo,
IUserRepository userRepo,
ISubscriptionRepository subscriptionRepo,
IAuditLogRepository auditLog,
IFeatureGateService featureGate,
ILogger<TenantProvisioningService> logger)
{
_tenantRepo = tenantRepo;
_userRepo = userRepo;
_subscriptionRepo = subscriptionRepo;
_auditLog = auditLog;
_featureGate = featureGate;
_logger = logger;
}
public async Task<Tenant> ProvisionTenantAsync(
string name, string slug, string plan,
string adminEmail, string adminName)
{
_logger.LogInformation(
"Starting tenant provisioning: {Slug} on plan {Plan}", slug, plan);
// 1. Validate slug uniqueness
var existing = await _tenantRepo.GetBySlugAsync(slug);
if (existing != null)
throw new InvalidOperationException(
$"Tenant slug '{slug}' is already taken");
// 2. Create tenant record
var tenant = await _tenantRepo.CreateAsync(new Tenant
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
Plan = plan,
Status = TenantStatus.Provisioning,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
// 3. Create subscription
var subscription = await _subscriptionRepo.CreateAsync(new Subscription
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Plan = plan,
BillingModel = GetBillingModelForPlan(plan),
Status = "active",
CurrentPeriodStart = DateTime.UtcNow,
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1),
SeatCount = 0,
UsageBytes = 0
});
// 4. Create admin user
var adminUser = await _userRepo.CreateAsync(new TenantUser
{
Id = Guid.NewGuid(),
TenantId = tenant.Id,
Email = adminEmail,
Name = adminName,
Role = "owner",
Status = "active",
CreatedAt = DateTime.UtcNow
});
// 5. Initialize default feature flags based on plan
await _featureGate.InitializeDefaultFeaturesAsync(tenant.Id, plan);
// 6. Create default project
await CreateDefaultProjectAsync(tenant.Id);
// 7. Mark tenant as active
tenant.Status = TenantStatus.Active;
tenant.UpdatedAt = DateTime.UtcNow;
await _tenantRepo.UpdateAsync(tenant);
// 8. Audit log
await _auditLog.LogAsync(new AuditLogEntry
{
TenantId = tenant.Id,
UserId = adminUser.Id,
Action = "tenant.provisioned",
ResourceType = "tenant",
ResourceId = tenant.Id,
NewValue = JsonSerializer.Serialize(new
{
name, slug, plan, adminEmail
})
});
_logger.LogInformation(
"Tenant provisioning complete: {TenantId} ({Slug})", tenant.Id, slug);
return tenant;
}
private BillingModel GetBillingModelForPlan(string plan) => plan switch
{
"free" => BillingModel.Flat,
"pro" => BillingModel.PerSeat,
"enterprise" => BillingModel.Tiered,
_ => BillingModel.Flat
};
private Task CreateDefaultProjectAsync(Guid tenantId)
{
_logger.LogInformation(
"Creating default project for tenant {TenantId}", tenantId);
// In production, this would insert into the projects table
return Task.CompletedTask;
}
}
// ==========================================
// Feature Gating Service
// ==========================================
public interface IFeatureGateService
{
Task<bool> IsFeatureEnabledAsync(Guid tenantId, string featureKey);
Task<ResourceLimit> GetResourceLimitAsync(Guid tenantId, string resourceKey);
Task InitializeDefaultFeaturesAsync(Guid tenantId, string plan);
}
public class FeatureGateService : IFeatureGateService
{
private readonly IDistributedCache _cache;
private readonly ILogger<FeatureGateService> _logger;
// In production, this would come from a database
private static readonly Dictionary<string, PlanDefinition> Plans = new()
{
["free"] = new PlanDefinition
{
PlanName = "free",
MonthlyPrice = 0,
BillingModel = BillingModel.Flat,
Features = new List<PlanFeature>
{
new() { Key = "api_access", DefaultEnabled = true },
new() { Key = "audit_log", DefaultEnabled = true },
new() { Key = "sso", DefaultEnabled = false },
new() { Key = "white_labeling", DefaultEnabled = false },
new() { Key = "custom_domain", DefaultEnabled = false },
new() { Key = "data_export", DefaultEnabled = false }
},
Limits = new List<ResourceLimit>
{
new() { ResourceKey = "projects", Value = 3, Unit = "count" },
new() { ResourceKey = "users", Value = 5, Unit = "count" },
new() { ResourceKey = "storage", Value = 1073741824, Unit = "bytes" },
new() { ResourceKey = "api_rate_limit", Value = 100, Unit = "req/min" }
}
},
["pro"] = new PlanDefinition
{
PlanName = "pro",
MonthlyPrice = 29,
BillingModel = BillingModel.PerSeat,
Features = new List<PlanFeature>
{
new() { Key = "api_access", DefaultEnabled = true },
new() { Key = "audit_log", DefaultEnabled = true },
new() { Key = "sso", DefaultEnabled = false },
new() { Key = "white_labeling", DefaultEnabled = true },
new() { Key = "custom_domain", DefaultEnabled = false },
new() { Key = "data_export", DefaultEnabled = true }
},
Limits = new List<ResourceLimit>
{
new() { ResourceKey = "projects", Value = 50, Unit = "count" },
new() { ResourceKey = "users", Value = 25, Unit = "count" },
new() { ResourceKey = "storage", Value = 53687091200, Unit = "bytes" },
new() { ResourceKey = "api_rate_limit", Value = 1000, Unit = "req/min" }
}
},
["enterprise"] = new PlanDefinition
{
PlanName = "enterprise",
MonthlyPrice = 299,
BillingModel = BillingModel.Tiered,
Features = new List<PlanFeature>
{
new() { Key = "api_access", DefaultEnabled = true },
new() { Key = "audit_log", DefaultEnabled = true },
new() { Key = "sso", DefaultEnabled = true },
new() { Key = "white_labeling", DefaultEnabled = true },
new() { Key = "custom_domain", DefaultEnabled = true },
new() { Key = "data_export", DefaultEnabled = true }
},
Limits = new List<ResourceLimit>
{
new() { ResourceKey = "projects", Value = long.MaxValue, Unit = "count" },
new() { ResourceKey = "users", Value = 10000, Unit = "count" },
new() { ResourceKey = "storage", Value = 536870912000, Unit = "bytes" },
new() { ResourceKey = "api_rate_limit", Value = 10000, Unit = "req/min" }
}
}
};
public FeatureGateService(IDistributedCache cache,
ILogger<FeatureGateService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<bool> IsFeatureEnabledAsync(Guid tenantId, string featureKey)
{
var cacheKey = $"feature:{tenantId}:{featureKey}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null) return bool.Parse(cached);
// Check tenant-specific override from DB in production
// For now, fall back to plan defaults
var planDef = await GetPlanDefinitionAsync(tenantId);
var enabled = planDef.Features
.Any(f => f.Key == featureKey && f.DefaultEnabled);
await _cache.SetStringAsync(cacheKey, enabled.ToString(),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
return enabled;
}
public async Task<ResourceLimit> GetResourceLimitAsync(
Guid tenantId, string resourceKey)
{
var planDef = await GetPlanDefinitionAsync(tenantId);
return planDef.Limits
.FirstOrDefault(l => l.ResourceKey == resourceKey)
?? ResourceLimit.Unlimited;
}
public Task InitializeDefaultFeaturesAsync(Guid tenantId, string plan)
{
_logger.LogInformation(
"Initializing default features for tenant {TenantId}, plan {Plan}",
tenantId, plan);
// In production, bulk-insert feature flags from plan definition
return Task.CompletedTask;
}
private Task<PlanDefinition> GetPlanDefinitionAsync(Guid tenantId)
{
// In production, fetch tenant's plan from DB
// For this implementation, default to "free"
var planName = "free"; // Would come from tenant repo
Plans.TryGetValue(planName, out var planDef);
return Task.FromResult(planDef ?? Plans["free"]);
}
}
// ==========================================
// Authentication Service
// ==========================================
public class TenantAuthService
{
private readonly IUserRepository _userRepo;
private readonly ITenantRepository _tenantRepo;
private readonly FeatureGateService _featureGate;
private readonly IAuditLogRepository _auditLog;
private readonly JwtConfig _jwtConfig;
public TenantAuthService(
IUserRepository userRepo,
ITenantRepository tenantRepo,
FeatureGateService featureGate,
IAuditLogRepository auditLog,
JwtConfig jwtConfig)
{
_userRepo = userRepo;
_tenantRepo = tenantRepo;
_featureGate = featureGate;
_auditLog = auditLog;
_jwtConfig = jwtConfig;
}
public async Task<string?> AuthenticateAsync(
string slug, string email, string password)
{
var tenant = await _tenantRepo.GetBySlugAsync(slug);
if (tenant == null || tenant.Status != TenantStatus.Active)
return null;
var user = await _userRepo.GetByEmailAsync(tenant.Id, email);
if (user == null || user.Status != "active")
return null;
// Verify password (simplified — use BCrypt in production)
// if (!VerifyPassword(password, user.PasswordHash)) return null;
// Update last login
user.LastLoginAt = DateTime.UtcNow;
await _userRepo.UpdateAsync(user);
// Generate JWT
var token = GenerateTenantToken(user, tenant);
// Audit
await _auditLog.LogAsync(new AuditLogEntry
{
TenantId = tenant.Id,
UserId = user.Id,
Action = "user.login",
ResourceType = "user",
ResourceId = user.Id
});
return token;
}
private string GenerateTenantToken(TenantUser user, Tenant tenant)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new("tenant_id", tenant.Id.ToString()),
new("tenant_slug", tenant.Slug),
new(JwtRegisteredClaimNames.Email, user.Email),
new("role", user.Role),
new("plan", tenant.Plan)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwtConfig.Secret));
var creds = new SigningCredentials(
key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwtConfig.Issuer,
audience: _jwtConfig.Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(8),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public class JwtConfig
{
public string Secret { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
}
// ==========================================
// Rate Limiting Service
// ==========================================
public class TenantRateLimitService
{
private readonly IDistributedCache _cache;
private readonly IFeatureGateService _featureGate;
public TenantRateLimitService(
IDistributedCache cache, IFeatureGateService featureGate)
{
_cache = cache;
_featureGate = featureGate;
}
public async Task<RateLimitResult> CheckAndIncrementAsync(Guid tenantId)
{
var limit = await _featureGate.GetResourceLimitAsync(
tenantId, "api_rate_limit");
if (limit.IsUnlimited)
return new RateLimitResult { IsAllowed = true, Remaining = long.MaxValue };
var windowKey = $"rl:{tenantId}:{DateTime.UtcNow:yyyy-MM-dd-HH-mm}";
var count = await _cache.IncrementAsync(windowKey);
if (count == 1)
{
await _cache.SetExpireAsync(windowKey, TimeSpan.FromMinutes(1));
}
return new RateLimitResult
{
IsAllowed = count <= limit.Value,
Limit = limit.Value,
Remaining = Math.Max(0, limit.Value - count),
ResetAt = DateTime.UtcNow.AddSeconds(60)
};
}
}
public class RateLimitResult
{
public bool IsAllowed { get; set; }
public long Limit { get; set; }
public long Remaining { get; set; }
public DateTime ResetAt { get; set; }
}
// ==========================================
// Usage Metering Service
// ==========================================
public class UsageMeteringService
{
private readonly IDistributedCache _cache;
private readonly ILogger<UsageMeteringService> _logger;
public UsageMeteringService(
IDistributedCache cache, ILogger<UsageMeteringService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task RecordUsageAsync(Guid tenantId, string metricName,
long units = 1)
{
var dateKey = DateTime.UtcNow.ToString("yyyy-MM-dd");
var key = $"usage:{tenantId}:{metricName}:{dateKey}";
await _cache.IncrementAsync(key, units);
await _cache.SetExpireAsync(key, TimeSpan.FromDays(35));
_logger.LogDebug(
"Recorded usage: tenant={TenantId}, metric={Metric}, units={Units}",
tenantId, metricName, units);
}
public async Task<long> GetUsageAsync(Guid tenantId,
string metricName, DateTime date)
{
var dateKey = date.ToString("yyyy-MM-dd");
var key = $"usage:{tenantId}:{metricName}:{dateKey}";
var value = await _cache.GetAsync(key);
return value != null ? BitConverter.ToInt64(value) : 0;
}
public async Task<Dictionary<string, long>> GetBillingPeriodUsageAsync(
Guid tenantId, DateTime periodStart, DateTime periodEnd)
{
var result = new Dictionary<string, long>();
var metrics = new[] { "api_calls", "storage_bytes", "compute_seconds" };
foreach (var metric in metrics)
{
long total = 0;
for (var date = periodStart; date <= periodEnd; date = date.AddDays(1))
{
total += await GetUsageAsync(tenantId, metric, date);
}
result[metric] = total;
}
return result;
}
}
// ==========================================
// Audit Log Service
// ==========================================
public class AuditLogService
{
private readonly IAuditLogRepository _repo;
private readonly ILogger<AuditLogService> _logger;
public AuditLogService(IAuditLogRepository repo,
ILogger<AuditLogService> logger)
{
_repo = repo;
_logger = logger;
}
public async Task LogAsync(AuditLogEntry entry)
{
entry.Id = entry.Id == Guid.Empty ? Guid.NewGuid() : entry.Id;
entry.CreatedAt = DateTime.UtcNow;
await _repo.LogAsync(entry);
_logger.LogInformation(
"Audit: tenant={TenantId}, user={UserId}, action={Action}, " +
"resource={ResourceType}/{ResourceId}",
entry.TenantId, entry.UserId, entry.Action,
entry.ResourceType, entry.ResourceId);
}
public async Task<List<AuditLogEntry>> GetTenantAuditLogAsync(
Guid tenantId, int page = 1, int pageSize = 50)
{
var skip = (page - 1) * pageSize;
return await _repo.GetByTenantIdAsync(tenantId, skip, pageSize);
}
}
// ==========================================
// Tenant Admin Service (Impersonation)
// ==========================================
public class TenantAdminService
{
private readonly ITenantRepository _tenantRepo;
private readonly IAuditLogRepository _auditLog;
private readonly UsageMeteringService _metering;
public TenantAdminService(
ITenantRepository tenantRepo,
IAuditLogRepository auditLog,
UsageMeteringService metering)
{
_tenantRepo = tenantRepo;
_auditLog = auditLog;
_metering = metering;
}
public async Task<Tenant> GetTenantDetailsAsync(Guid tenantId)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId)
?? throw new KeyNotFoundException(
$"Tenant {tenantId} not found");
return tenant;
}
public async Task SuspendTenantAsync(Guid tenantId, Guid adminUserId,
string reason)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId)
?? throw new KeyNotFoundException(
$"Tenant {tenantId} not found");
tenant.Status = TenantStatus.Suspended;
tenant.UpdatedAt = DateTime.UtcNow;
await _tenantRepo.UpdateAsync(tenant);
await _auditLog.LogAsync(new AuditLogEntry
{
TenantId = tenantId,
UserId = adminUserId,
Action = "tenant.suspended",
ResourceType = "tenant",
ResourceId = tenantId,
NewValue = JsonSerializer.Serialize(new { reason })
});
}
public async Task<TenantUsageReport> GenerateUsageReportAsync(
Guid tenantId)
{
var tenant = await _tenantRepo.GetByIdAsync(tenantId)
?? throw new KeyNotFoundException(
$"Tenant {tenantId} not found");
var periodStart = new DateTime(
DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
var periodEnd = DateTime.UtcNow;
var usage = await _metering.GetBillingPeriodUsageAsync(
tenantId, periodStart, periodEnd);
return new TenantUsageReport
{
TenantId = tenantId,
TenantName = tenant.Name,
Plan = tenant.Plan,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
ApiCalls = usage.GetValueOrDefault("api_calls", 0),
StorageBytes = usage.GetValueOrDefault("storage_bytes", 0),
ComputeSeconds = usage.GetValueOrDefault("compute_seconds", 0)
};
}
}
public class TenantUsageReport
{
public Guid TenantId { get; set; }
public string TenantName { get; set; } = string.Empty;
public string Plan { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public long ApiCalls { get; set; }
public long StorageBytes { get; set; }
public long ComputeSeconds { get; set; }
}
// ==========================================
// DI Registration Extension
// ==========================================
public static class MultiTenantServiceExtensions
{
public static IServiceCollection AddMultiTenantServices(
this IServiceCollection services)
{
services.AddScoped<TenantContext>();
services.AddScoped<TenantProvisioningService>();
services.AddScoped<TenantAuthService>();
services.AddScoped<TenantRateLimitService>();
services.AddScoped<UsageMeteringService>();
services.AddScoped<AuditLogService>();
services.AddScoped<TenantAdminService>();
services.AddSingleton<IFeatureGateService, FeatureGateService>();
return services;
}
public static IApplicationBuilder UseMultiTenant(
this IApplicationBuilder app)
{
app.UseMiddleware<TenantResolutionMiddleware>();
return app;
}
}
}
25. Conclusion
Designing a multi-tenant SaaS platform is a multifaceted challenge that spans database architecture, security, billing, compliance, and operational excellence. The key principles that emerge from this analysis are:
- Tenant isolation is non-negotiable. Defense in depth — from RLS policies to cache key namespacing to audit logging — ensures no tenant can ever access another's data.
- Start simple, iterate strategically. Begin with shared-database RLS, add schema-per-tenant as you scale, and reserve database-per-tenant for enterprise compliance requirements.
- Decouple billing from the main application. Usage metering via Kafka ensures your billing pipeline never impacts user-facing performance.
- Design for hybrid isolation from day one. The directory-based shard map allows you to support multiple isolation strategies simultaneously.
- Automation is everything. Tenant provisioning, schema migrations, backup, and restore must be fully automated — manual processes don't scale past 100 tenants.
- Monitor for noisy neighbors proactively. Per-tenant metrics, rate limiting, and resource quotas prevent one tenant from degrading the experience for thousands.
- Data residency is a first-class concern. Multi-region deployment with tenant-aware routing ensures compliance without sacrificing the unified platform experience.
The C# implementation provided in this article is production-ready scaffolding that demonstrates these patterns in code. In a real-world system, you would extend it with proper database repositories (EF Core), message bus integration (MassTransit), and comprehensive error handling. The architecture is designed to grow with your tenant base — from the first customer to the ten-thousandth.
This article was last updated on July 14, 2026. For questions or feedback, reach out on GitHub.