system-design42 min read

How to Design Multi-Tenant SaaS Platform — A Senior+ Guide | Ayodhyya

How to Design Multi-Tenant SaaS Platform

Building isolation, tenant provisioning, billing, and scaling patterns for enterprise SaaS at 10K+ tenant scale

Published July 14, 2026 · Senior+ System Design Guide · ~45 min read

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

Shared Infrastructure (Pool Model): All tenants share the same application servers, databases, and caches. Isolation is enforced at the application layer via tenant context. This is cost-effective and easy to maintain but harder to guarantee strict isolation. Suitable for B2B SaaS with small-to-medium tenants.

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

RequirementTargetRationale
Availability99.95% (4.38h downtime/year)Enterprise customers require high SLA
Latency (p99)< 200ms for API readsInteractive dashboard responsiveness
Tenant Count10,000+ active tenantsGrowth projection for 3 years
Users per Tenant1 to 10,000SMB to enterprise range
Data per Tenant1MB to 500GBVaries by plan and usage
Provisioning Time< 30 secondsSelf-service onboarding UX
Backup RTO< 1 hourEnterprise compliance
Backup RPO< 5 minutesMinimal data loss
Throughput50,000 requests/second aggregatePeak load across all tenants
Data ResidencyUS, EU, APACGDPR and regional compliance

3. Capacity Estimation

Assumptions: 10,000 tenants, average 50 users/tenant (500K total users), 10% daily active rate (50K DAU), 5 API requests/minute per active user, 2KB average request/response size.

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 TypePer Tenant (avg)Total (10K tenants)Growth/Year
Application Data5 GB50 TB20 TB
Audit Logs1 GB10 TB10 TB
File Attachments2 GB20 TB15 TB
Analytics/Events3 GB30 TB30 TB
Total11 GB110 TB75 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);
Key Design Decisions:
  • tenant_id is 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_value and new_value for full change tracking.
  • The settings JSONB 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:

  1. Subdomain: acme.app.example.com — extracts acme as tenant slug
  2. Custom Domain: app.acmecorp.com — resolves via DNS lookup table
  3. Header: X-Tenant-ID: uuid — used for admin/impersonation endpoints

Core API Endpoints

MethodEndpointDescriptionAuth
POST/api/v1/auth/loginTenant-scoped loginNone
POST/api/v1/auth/sso/{tenant_slug}SSO initiationNone
GET/api/v1/tenants/meCurrent tenant detailsJWT
PATCH/api/v1/tenants/meUpdate tenant settingsAdmin
GET/api/v1/usersList users in tenantJWT
POST/api/v1/users/inviteInvite user to tenantAdmin
GET/api/v1/projectsList tenant projectsJWT
POST/api/v1/projectsCreate projectEditor+
GET/api/v1/billing/usageUsage metering dataAdmin
POST/api/v1/admin/impersonateImpersonate tenantSuperAdmin

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

graph TB Client[Web/Mobile Client] --> CDN[CDN - CloudFront] CDN --> WAF[WAF / DDoS Protection] WAF --> Gateway[API Gateway / Reverse Proxy] Gateway --> AuthSvc[Auth Service] Gateway --> TenantCtx[Tenant Context Resolver] TenantCtx --> LB[Load Balancer] LB --> App1[App Server 1] LB --> App2[App Server 2] LB --> App3[App Server N] App1 --> Redis[Redis Cluster] App1 --> PrimaryDB[(Primary PostgreSQL)] App1 --> Kafka[Kafka / Event Bus] App2 --> Redis App2 --> PrimaryDB App3 --> Redis App3 --> PrimaryDB PrimaryDB --> ReadReplica1[(Read Replica 1)] PrimaryDB --> ReadReplica2[(Read Replica 2)] Kafka --> Worker1[Background Worker 1] Kafka --> Worker2[Background Worker 2] Worker1 --> S3[Object Storage - S3] Worker2 --> BillingSvc[Billing Service] BillingSvc --> Stripe[Stripe API] PrimaryDB --> BackupSvc[Backup Service] BackupSvc --> S3 subgraph "Isolation Boundary" App1 App2 App3 end subgraph "Data Layer" PrimaryDB ReadReplica1 ReadReplica2 Redis end subgraph "Async Processing" Kafka Worker1 Worker2 BillingSvc end

Architecture Layers

LayerComponentsTenant Awareness
EdgeCDN, WAF, DNSSubdomain/custom domain routing
GatewayAPI Gateway, Rate LimiterPer-tenant rate limits, IP allowlisting
ApplicationMicroservices / MonolithTenant context injected via middleware
DataPostgreSQL, Redis, S3Row-level security, scoped cache keys
AsyncKafka, WorkersEvent headers carry tenant_id
ExternalStripe, SendGrid, TwilioTenant-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.

graph LR A[Tenant Isolation Strategies] --> B[Shared DB, Shared Schema] A --> C[Shared DB, Schema per Tenant] A --> D[Database per Tenant] B --> B1[Cheapest] B --> B2[Hardest to Isolate] B --> B3[Single DB to manage] C --> C1[Moderate Cost] C --> C2[Good Isolation] C --> C3[Schema migration complexity] D --> D1[Most Expensive] D --> D2[Strongest Isolation] D --> D3[Easiest Compliance]

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.

PostgreSQL RLS Example:
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';
AspectShared DB, Shared Schema
CostLowest — single database instance
IsolationWeakest — relies on application discipline + RLS
MaintenanceSimplest — one schema to migrate
Noisy NeighborMost severe — shared connection pool and indexes
Data ExportComplex — must filter by tenant_id
Suitable ForB2B 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;
AspectShared DB, Schema per Tenant
CostModerate — shared database engine, more schemas
IsolationGood — schema-level separation, harder to cross-tenant
MaintenanceComplex — migrations must be applied to all tenant schemas
Noisy NeighborModerate — shared engine, but indexes are per-schema
Data ExportEasy — dump a single schema
Suitable ForMid-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.

AspectDatabase per Tenant
CostHighest — each tenant needs its own DB resources
IsolationStrongest — physical separation of data
MaintenanceMost complex — 10K+ databases to migrate
Noisy NeighborEliminated — dedicated connection pool per tenant
Data ExportTrivial — backup the entire database
Suitable ForEnterprise SaaS, regulated industries
Recommendation: Start with Shared DB + RLS for MVP. Add schema-per-tenant as you scale past 500 tenants. Reserve database-per-tenant for enterprise customers on premium plans.

8. Tenant Provisioning & Onboarding Pipeline

sequenceDiagram participant User participant API as API Gateway participant Auth as Auth Service participant Tenant as Tenant Service participant DB as Database participant Queue as Message Queue participant Worker as Provisioning Worker participant Cache as Redis Cache participant Email as Email Service User->>API: POST /api/v1/tenants {name, slug, plan} API->>Auth: Validate JWT / create initial admin Auth->>Tenant: CreateTenantCommand Tenant->>DB: INSERT INTO tenants (id, name, slug, plan) Tenant->>Queue: Publish TenantCreatedEvent Tenant-->>API: 202 Accepted {tenant_id, status: provisioning} API-->>User: 202 Accepted Queue->>Worker: TenantCreatedEvent par Parallel Provisioning Worker->>DB: Create tenant schema / RLS policy Worker->>DB: Create default admin user Worker->>DB: Create default project + settings Worker->>Cache: Initialize tenant config cache Worker->>Queue: Publish TenantProvisionedEvent end Queue->>Email: SendWelcomeEmailCommand Email-->>User: Welcome email with login link

Provisioning Steps

  1. Validate Input: Check slug uniqueness, plan validity, and admin email format.
  2. Create Tenant Record: Insert into the tenants table with status provisioning.
  3. Publish Event: Emit TenantCreatedEvent to the message queue for async processing.
  4. Create Schema/RLS Policy: Depending on isolation strategy, create a new schema or RLS policy.
  5. Seed Default Data: Create default project, settings, admin user, and feature flags based on the selected plan.
  6. Initialize Cache: Populate Redis with tenant configuration for fast reads.
  7. Send Welcome Email: Trigger onboarding email with login credentials or SSO setup instructions.
  8. Mark Active: Update tenant status to active.

9. Authentication & Authorization

graph TD User[User] --> Login{Login Method} Login -->|Email/Password| EmailAuth[Email Auth Service] Login -->|SSO| SAML[SAML/OIDC Provider] Login -->|Social| OAuth[OAuth 2.0] EmailAuth --> TokenSvc[Token Service] SAML --> TokenSvc OAuth --> TokenSvc TokenSvc --> JWT[JWT Token] JWT --> JWTClaims[Claims: tenant_id, user_id, role, exp] JWTClaims --> API[API Endpoint] API --> RBAC{RBAC Check} RBAC -->|Owner| FullAccess[Full Access] RBAC -->|Admin| ManageUsers[Manage Users & Settings] RBAC -->|Member| CRUD[Create, Read, Update] RBAC -->|Viewer| ReadOnly[Read Only]

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

PermissionOwnerAdminMemberViewer
Delete TenantYesNoNoNo
Manage BillingYesYesNoNo
Manage UsersYesYesNoNo
Update SettingsYesYesNoNo
Create ProjectsYesYesYesNo
Edit ResourcesYesYesYesNo
View ResourcesYesYesYesYes
View Audit LogsYesYesNoNo

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

FeatureFreePro ($29/mo)Enterprise ($299/mo)
Projects350Unlimited
Users52510,000
Storage1 GB50 GB500 GB
API Access1K req/day100K req/dayUnlimited
SSO/SAMLNoNoYes
Audit Logs7 days90 daysUnlimited
White-LabelingNoLogo onlyFull custom domain
SLANone99.9%99.95% + dedicated support
Data ExportNoCSVCSV + API + Custom
Priority SupportNoEmailEmail + 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

graph TB subgraph "Billing Models" Flat[Flat-Rate: $29/mo] PerSeat[Per-Seat: $10/user/mo] Usage[Usage-Based: $0.01/API call] end Flat --> StripeSub[Stripe Subscription] PerSeat --> StripeSub Usage --> MeteringSvc[Usage Metering Service] MeteringSvc --> Kafka[Event Stream] Kafka --> Aggregator[Usage Aggregator] Aggregator --> UsageDB[(Usage DB)] UsageDB --> InvoiceGen[Invoice Generator] InvoiceGen --> StripeInvoice[Stripe Invoice API] StripeSub --> WebhookHandler[Webhook Handler] WebhookHandler --> TenantStatus[Tenant Status Manager] TenantStatus --> TenantDB[(Tenant DB)]

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.

ModelExamplesProsCons
Flat-Rate$29/mo for Pro planPredictable revenue, simple billingMay over/under charge tenants
Per-Seat$10/user/monthScales with customer growthDiscourages user adoption
Usage-Based$0.01 per API callFair pricing, aligns with valueUnpredictable for customers
Tiered$0.01 first 100K, $0.005 afterVolume incentivesComplex 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.

graph TB subgraph "US Region" AppUS[App Cluster US] DBUS[(PostgreSQL US)] CacheUS[Redis US] S3US[S3 US] end subgraph "EU Region" AppEU[App Cluster EU] DBEU[(PostgreSQL EU)] CacheEU[Redis EU] S3EU[S3 EU] end subgraph "APAC Region" AppAPAC[App Cluster APAC] DBAPAC[(PostgreSQL APAC)] CacheAPAC[Redis APAC] S3APAC[S3 APAC] end GlobalLB[Global Load Balancer] --> AppUS GlobalLB --> AppEU GlobalLB --> AppAPAC TenantUS[Tenants: US Data] --> DBUS TenantEU[Tenants: EU Data] --> DBEU TenantAPAC[Tenants: APAC Data] --> DBAPAC

Compliance Checklist

RegulationRequirementImplementation
GDPRData minimization, right to erasurePer-tenant soft delete + hard purge pipeline, data export API
CCPAOpt-out of data sale, disclosurePrivacy settings per tenant, no data selling
SOC 2Audit logging, access controlsImmutable audit logs, RBAC, MFA enforcement
HIPAAPHI encryption, access loggingAt-rest + transit encryption, BAA with cloud provider
ISO 27001Information security managementPolicies, 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.

graph TD subgraph "Noisy Neighbor Detection" Metrics[Metrics Collector] --> AlertEngine{Threshold Check} AlertEngine -->|CPU > 80%| Throttle[Rate Limit Throttle] AlertEngine -->|DB Queries > Limit| QuotaEnforce[Quota Enforcement] AlertEngine -->|Memory > 90%| Isolate[Migrate to Dedicated Pool] end subgraph "Mitigation Strategies" RateLimiting[Per-Tenant Rate Limiting] ResourceQuotas[DB Connection Pool Quotas] CpuIsolation[CPU/Memory Limits] DataQuotas[Storage Quotas] PriorityQueues[Priority-Based Queuing] end

Resource Quota Configuration

ResourceFree TierPro TierEnterprise Tier
API Rate Limit100 req/min1,000 req/min10,000 req/min
DB Connections525100 (dedicated pool)
Storage1 GB50 GB500 GB
Background Jobs10/hour100/hourUnlimited
Concurrent Users52510,000
File Upload Size5 MB50 MB500 MB
Query Execution Time5s30s120s

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

graph LR subgraph "Theme Resolution" Request[HTTP Request] --> DomainCheck{Custom Domain?} DomainCheck -->|Yes| DomainLookup[Domain → Tenant Mapping] DomainCheck -->|No| SubdomainCheck[Subdomain → Tenant] DomainLookup --> ThemeEngine[Theme Engine] SubdomainCheck --> ThemeEngine ThemeEngine --> CSSVars[CSS Variables] ThemeEngine --> Logo[Logo Override] ThemeEngine --> Colors[Color Palette] CSSVars --> RenderedUI[Rendered UI] Logo --> RenderedUI Colors --> RenderedUI end

White-Label Configuration

CustomizationFreeProEnterprise
LogoPlatform logoCustom logoCustom logo + favicon
ColorsDefault themePrimary colorFull color palette
Custom DomainNoNoapp.client.com
Email TemplatesPlatform brandedPlatform brandedCustom HTML templates
Login PagePlatform loginPlatform loginCustom branded login
CSS OverridesNoNoCustom 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.

graph TB App[Application] --> Events[Event Publisher] Events --> Kafka[Kafka Topic: tenant-events] Kafka --> RealTime[Real-Time Processor - Flink/Spark] Kafka --> BatchProcessor[Batch Processor - Nightly] RealTime --> LiveDash[Tenant Live Dashboard] BatchProcessor --> UsageDB[(Usage Data Warehouse)] UsageDB --> BillingExport[Billing Export] UsageDB --> TenantReports[Tenant Usage Reports] UsageDB --> PlatformInsights[Platform Insights] BillingExport --> Stripe[Stripe Usage Records] TenantReports --> Dashboard[Tenant Admin Dashboard] PlatformInsights --> AdminPanel[Platform Admin Panel]

Key Metrics Per Tenant

MetricAggregationRetentionUsed For
API CallsPer-minute counter1 yearBilling, rate limiting
Data StorageDaily snapshot2 yearsBilling, quotas
Active UsersDaily unique1 yearPer-seat billing
Feature UsagePer-event90 daysProduct analytics
Error RatePer-minute30 daysSRE monitoring
Response Timep50/p95/p9930 daysPerformance monitoring

17. Disaster Recovery & Backup

Multi-tenant backup must support both platform-wide disaster recovery and per-tenant point-in-time restore.

graph TB Primary[(Primary DB)] --> WAL[WAL Archiving] WAL --> S3Backup[S3 Backup Bucket] S3Backup --> CrossRegion[Cross-Region Replication] CrossRegion --> DRRegion[(DR Region DB)] Primary --> DailySnapshot[Daily Snapshot] DailySnapshot --> S3Backup subgraph "Per-Tenant Restore" RestoreReq[Restore Request] --> TenantFilter[Filter by tenant_id] TenantFilter --> PointInTime[Point-in-Time Recovery] PointInTime --> ExportCSV[Export as CSV/JSON] ExportCSV --> NewTenant[Import to New Tenant] end subgraph "Platform DR" HealthCheck[Health Check] --> Failover[DNS Failover] Failover --> DRRegion DRRegion --> RebuildCache[Cache Rebuild] end

Backup Strategy

Backup TypeFrequencyRetentionRecovery Time
WAL ArchivingContinuous30 daysMinutes (PITR)
Full Database SnapshotDaily90 days1-4 hours
Cross-Region ReplicationContinuous7 days< 1 hour (failover)
Per-Tenant ExportOn-demandUntil deletedMinutes

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

StrategyDescriptionProsCons
Hash-basedHash(tenant_id) % num_shardsUniform distributionResharding is painful
Range-basedTenant ID ranges per shardEasy to add shardsHotspots possible
Directory-basedLookup table maps tenant → shardFlexible migrationExtra lookup hop
GeographicShard by data_regionNatural data residencyUneven shard sizes
Recommended: Start with a directory-based sharding approach. Maintain a 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 LayerTechnologyTTLWhat's Cached
L1 (In-Process)MemoryCache30 secondsTenant config, feature flags
L2 (Distributed)Redis Cluster5-10 minutesUser sessions, project data, API responses
L3 (CDN)CloudFront1 hourStatic 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

  1. 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.
  2. Backwards-Compatible Changes: Every migration must be backwards-compatible with the current application version. This allows rolling deployments.
  3. Batched Schema Migrations: For schema-per-tenant, apply migrations in batches of 100 schemas per transaction with progress tracking.
  4. 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

graph TB DNS[Global DNS - Route53] --> US[US-East Region] DNS --> EU[EU-West Region] DNS --> APAC[APAC Region] US --> USApp[App Cluster] US --> USDB[(Primary DB)] EU --> EUApp[App Cluster] EU --> EUDB[(Primary DB)] APAC --> APACApp[App Cluster] APAC --> APACDB[(Primary DB)] USDB -.->|Async Replication| EUDB EUDB -.->|Async Replication| USDB USDB -.->|Async Replication| APACDB subgraph "Tenant Routing" TenantUS[Tenant data_region=us] --> USApp TenantEU[Tenant data_region=eu] --> EUApp TenantAPAC[Tenant data_region=apac] --> APACApp end subgraph "Global Services" GlobalAuth[Global Auth Service] GlobalBilling[Global Billing Service] GlobalAdmin[Global Admin Panel] end

Multi-Region Considerations

ComponentStrategyReplication
ApplicationDeploy in each regionStateless — no replication needed
Primary DatabaseRegion-local per tenantAsync cross-region for DR only
CacheRegion-localNo cross-region replication
Object StorageRegion-local bucketCross-region replication for backups
Auth ServiceGlobal with region-local tokensJWT validation is stateless
BillingCentralized (single Stripe account)N/A
Event BusRegion-local Kafka clustersCross-region mirror for global events

22. Cost Estimation

ComponentSpecificationMonthly Cost (USD)
Application Servers (8x)c6i.2xlarge (8 vCPU, 32GB)$1,200
Primary PostgreSQLr6i.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 BalancerALB + 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
Revenue Breakdown: At 10,000 tenants with an average MRR of $50/tenant, monthly revenue is $500,000. Infrastructure cost of ~$21K represents 4.2% of revenue, which is healthy for a SaaS business (target: < 20%).

23. Interview Q&A

Q1: How do you prevent Tenant A from accessing Tenant B's data?
Answer: Defense in depth: (1) Every database query includes 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.
Q2: How do you handle schema migrations across 10,000 tenant schemas?
Answer: Use a batched migration approach with progress tracking. Apply migrations in batches of 100 schemas with error handling per schema. Use the expand-contract pattern so migrations are backwards-compatible with the running application version. Implement a migration lock via Redis to prevent concurrent runs. Track failed migrations separately for retry. Target migration completion within a maintenance window using progressive rollout.
Q3: How do you handle the noisy neighbor problem?
Answer: Multi-layered approach: (1) Per-tenant rate limiting at the API gateway using Redis sliding windows, (2) database connection pool quotas per tenant enforced at the connection pooler level (PgBouncer), (3) background job queue prioritization where free-tier tenants get lower priority, (4) storage quotas with automated alerts at 80% and block at 100%, (5) real-time monitoring that detects tenants consuming disproportionate resources and triggers automatic throttling or escalation to a dedicated pool.
Q4: What isolation strategy would you choose and why?
Answer: Start with shared database + RLS for MVP and up to ~500 tenants. It's the simplest to operate and provides good-enough isolation for most B2B SaaS products. Migrate high-value enterprise tenants to schema-per-tenant or database-per-tenant as needed for compliance and performance. Use a directory-based shard map to support hybrid strategies. The key insight is that isolation strategy is not a binary choice — you can support multiple strategies simultaneously.
Q5: How do you implement tenant-scoped caching without cross-tenant leakage?
Answer: Prefix every cache key with the tenant UUID: 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.
Q6: How do you design the billing system for multiple pricing models?
Answer: Abstract billing into three components: (1) a usage metering service that captures billable events via Kafka and aggregates them per tenant per billing cycle, (2) a plan definition service that stores pricing rules (flat-rate, per-seat, usage-based) as configuration, and (3) a Stripe integration service that creates subscriptions, reports usage records, and handles webhooks for payment events. The metering pipeline runs independently from the main application to avoid impacting performance. Reconciliation jobs verify metered usage against Stripe records daily.
Q7: How do you handle tenant impersonation for support purposes?
Answer: Implement impersonation via a special 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.
Q8: How do you handle data residency requirements for GDPR?
Answer: Each tenant has a 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.
Q9: How do you achieve zero-downtime deployments with schema migrations?
Answer: Follow the expand-contract pattern: (1) Add new columns/tables without removing old ones, (2) deploy code that writes to both old and new columns, (3) backfill existing data, (4) deploy code that reads from new columns, (5) drop old columns. Use feature flags to control migration rollout. Run schema migrations as a separate pre-deployment step using a distributed lock. Never couple schema changes with application deployments in the same release.
Q10: How do you design per-tenant analytics without degrading main application performance?
Answer: Use an event sourcing pattern: publish every billable or analytically-relevant event to Kafka from the main application (fire-and-forget, minimal latency impact). A separate analytics consumer reads from Kafka, aggregates metrics, and writes to a time-series database or data warehouse (ClickHouse, BigQuery). The main application never queries the analytics store directly — dashboards read from the pre-aggregated analytics database. This decouples analytics workload from transactional workload completely.
Q11: What happens when a tenant wants to export all their data?
Answer: Trigger an async job that queries all tables filtered by tenant_id, serializes the data as CSV or JSON (preserving relationships), compresses it, uploads to a pre-signed S3 URL, and sends the tenant admin a download link via email. For large tenants (100GB+), stream the export to avoid memory issues. Include a data manifest file describing all exported tables and record counts. Set an expiration (7 days) on the download link for security.
Q12: How do you handle a tenant that exceeds their plan limits?
Answer: Implement a grace-based approach: (1) Alert the tenant at 80% of limit via in-app banner and email, (2) at 100%, apply soft limits (degraded experience — reduced rate limits, read-only mode for some features), (3) after 7 days of sustained overage, require plan upgrade to continue, (4) never immediately delete data or lock out users. Log all limit-exceeded events for the sales team to proactively reach out. The goal is to use limits as a growth lever, not a punishment.

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:

  1. 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.
  2. 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.
  3. Decouple billing from the main application. Usage metering via Kafka ensures your billing pipeline never impacts user-facing performance.
  4. Design for hybrid isolation from day one. The directory-based shard map allows you to support multiple isolation strategies simultaneously.
  5. Automation is everything. Tenant provisioning, schema migrations, backup, and restore must be fully automated — manual processes don't scale past 100 tenants.
  6. Monitor for noisy neighbors proactively. Per-tenant metrics, rate limiting, and resource quotas prevent one tenant from degrading the experience for thousands.
  7. 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.

Key Takeaway: A well-designed multi-tenant SaaS platform is not just about shared infrastructure — it's about creating a system where every tenant feels like they're the only customer, while operating at a fraction of the per-tenant cost. The engineering investment in isolation, automation, and observability pays dividends as you scale.

This article was last updated on July 14, 2026. For questions or feedback, reach out on GitHub.

© 2026 Ayodhyya. All rights reserved.