Skip to main content
โšก Calmops

Multi-Tenant SaaS Architecture: Building Scalable Cloud Applications

Introduction

Software as a Service (SaaS) applications serve multiple customers from a single deployment. This requires careful architectural decisions around resource sharing, data isolation, and cost optimization. Multi-tenant architecture enables organizations to build scalable SaaS products that efficiently serve diverse customer bases while maintaining security and performance.

This article explores multi-tenant architecture patterns, implementation strategies, and best practices for building production-ready SaaS applications.

Understanding Multi-Tenancy

What is Multi-Tenancy?

Multi-tenancy is an architecture where a single instance of software serves multiple customers (tenants). Each tenant’s data is isolated while sharing underlying compute resources.

Key Benefits

  • Cost efficiency: Share infrastructure across tenants
  • Simplified maintenance: Single deployment for all tenants
  • Scalability: Add tenants without provisioning new infrastructure
  • Resource optimization: Dynamic allocation based on demand

Tenancy Models

Model 1: Shared Everything

All tenants share the same database, application instance, and storage.

# Single database with tenant column
Table: Orders
- id: UUID
- tenant_id: UUID  # All queries filter by this
- customer_id: UUID
- total: DECIMAL
- created_at: TIMESTAMP

Pros: Maximum resource sharing, lowest cost Cons: Complex queries, noisy neighbor problems, security concerns Use cases: Low-security workloads, cost-sensitive applications

Model 2: Shared Database, Separate Schemas

Tenants share a database but have isolated schemas.

-- Tenant A's schema
CREATE SCHEMA tenant_a;
CREATE TABLE tenant_a.users (...);

-- Tenant B's schema
CREATE SCHEMA tenant_b;
CREATE TABLE tenant_b.users (...);

Pros: Better isolation than column-based, simpler queries Cons: Schema management complexity, migration challenges Use cases: Moderate isolation requirements

Model 3: Separate Databases

Each tenant gets its own database.

# Connection routing
tenant-a.db.company.com  -> Database: tenant_a
tenant-b.db.company.com  -> Database: tenant_b

Pros: Strong isolation, independent scaling Cons: Higher infrastructure costs, management overhead Use cases: Enterprise customers requiring guarantees

Model 4: Separate Infrastructure

Complete isolation with dedicated resources per tenant.

# Per-tenant Kubernetes namespace
apiVersion: v1
kind: Namespace
metadata:
  name: tenant-a
---
apiVersion: v1
kind: Namespace
metadata:
  name: tenant-b

Pros: Maximum isolation, no noisy neighbors Cons: Highest cost, operational complexity Use cases: Regulated industries, large enterprises

Database Design Patterns

Tenant Context Pattern

Add tenant ID to all data operations:

# Middleware that injects tenant context
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant_id = self.extract_tenant_id(request)
        TenantContext.set_current(tenant_id)
        response = self.get_response(request)
        TenantContext.clear()
        return response

# Repository automatically filters by tenant
class OrderRepository:
    def get_all(self):
        tenant_id = TenantContext.get_current()
        return self.db.query(
            "SELECT * FROM orders WHERE tenant_id = ?",
            tenant_id
        )

Row-Level Security

Database-enforced tenant isolation:

-- PostgreSQL: Create policy
CREATE POLICY tenant_isolation_policy ON orders
    USING (tenant_id = current_setting('app.tenant_id'));

-- Set tenant context
SET app.tenant_id = 'tenant-uuid';
SELECT * FROM orders;  -- Returns only tenant's data

Metadata-Driven Configuration

Flexible tenant-specific settings:

# Tenant configuration
tenants:
  - id: tenant-a
    name: Company A
    plan: enterprise
    features:
      custom_branding: true
      api_access: true
      sso_enabled: true
    limits:
      max_users: 1000
      storage_gb: 100
  - id: tenant-b
    name: Company B
    plan: standard
    features:
      custom_branding: false
      api_access: false
    limits:
      max_users: 100
      storage_gb: 10

Application Architecture

Tenant Routing

Route requests to appropriate tenant context:

// API Gateway tenant resolution
async function resolveTenant(request: Request): Promise<string> {
  // Method 1: Subdomain
  const host = request.headers.get('host');
  const subdomain = host?.split('.')[0];
  if (subdomain && await isValidTenant(subdomain)) {
    return subdomain;
  }

  // Method 2: Header
  const tenantHeader = request.headers.get('X-Tenant-ID');
  if (tenantHeader && await isValidTenant(tenantHeader)) {
    return tenantHeader;
  }

  // Method 3: JWT claim
  const token = await extractToken(request);
  return token.tenant_id;
}

Tenant-Aware Services

class TenantAwareService:
    def __init__(self, repository, cache, tenant_id):
        self.repository = repository
        self.cache = cache
        self.tenant_id = tenant_id

    def get_order(self, order_id):
        cache_key = f"{self.tenant_id}:order:{order_id}"
        
        # Check cache
        cached = self.cache.get(cache_key)
        if cached:
            return cached

        # Fetch from database
        order = self.repository.get(order_id, self.tenant_id)
        
        # Cache with tenant-specific key
        self.cache.set(cache_key, order)
        return order

Feature Flagging per Tenant

Enable features conditionally:

class FeatureService {
  private tenantConfig: Map<string, TenantConfig>;

  isEnabled(tenantId: string, feature: string): boolean {
    const config = this.tenantConfig.get(tenantId);
    return config?.features?.[feature] ?? false;
  }

  // Usage in code
  async handleRequest(req: Request) {
    if (this.isEnabled(req.tenantId, 'beta-features')) {
      return this.handleBetaRequest(req);
    }
    return this.handleStandardRequest(req);
  }
}

Scaling Strategies

Horizontal Scaling with Tenant Affinity

# Kubernetes: Tenant-aware pod placement
apiVersion: v1
kind: Pod
metadata:
  labels:
    app: api-server
    tenant: tenant-a
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              tenant: tenant-a
          topologyKey: kubernetes.io/hostname

Connection Pooling

Manage database connections per tenant:

# Tenant-aware connection pool
class TenantConnectionPool:
    def __init__(self, base_pool):
        self.base_pool = base_pool
        self.tenant_pools = {}

    def get_connection(self, tenant_id):
        if tenant_id not in self.tenant_pools:
            # Create dedicated pool for high-volume tenants
            if self.is_high_volume(tenant_id):
                self.tenant_pools[tenant_id] = self.create_pool(
                    min_size=10, max_size=50
                )
            else:
                # Share base pool for low-volume tenants
                self.tenant_pools[tenant_id] = self.base_pool

        return self.tenant_pools[tenant_id].get_connection()

Resource Quotas

# Kubernetes ResourceQuota per tenant
apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a-quota
  namespace: tenant-a
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.cpu: "40"
    limits.memory: 80Gi
    pods: "50"
    services: "10"

Security Considerations

Data Isolation Verification

# Tenant isolation testing
class TenantIsolationTest:
    async def test_data_leakage(self):
        # Create data for tenant A
        tenant_a_token = self.generate_token(tenant_id='tenant-a')
        await self.create_order(tenant_a_token, {'item': 'secret-a'})

        # Try to access from tenant B
        tenant_b_token = self.generate_token(tenant_id='tenant-b')
        result = await self.get_orders(tenant_b_token)

        # Verify no leakage
        assert 'secret-a' not in result
        assert len(result) == 0  # Tenant B has no orders

Audit Logging

# Comprehensive audit trail
class AuditLogger:
    def log_access(self, tenant_id, user_id, resource, action):
        self.logger.info({
            'timestamp': datetime.utcnow().isoformat(),
            'tenant_id': tenant_id,
            'user_id': user_id,
            'resource': resource,
            'action': action,
            'ip_address': self.get_client_ip(),
            'user_agent': self.get_user_agent()
        })

Migration Strategies

Upgrading Multi-Tenant Systems

  1. Blue-green tenant migration: Migrate tenants in batches
  2. Feature flags for compatibility: Toggle old/new behavior
  3. Backward compatibility window: Support both versions
# Phased migration
async def migrate_tenant(tenant_id, batch_size=100):
    # Phase 1: Replicate data
    await replicate_data(tenant_id)

    # Phase 2: Validate consistency
    await validate_consistency(tenant_id)

    # Phase 3: Switch traffic
    await switch_traffic(tenant_id)

    # Phase 4: Cleanup old data
    await cleanup_old_data(tenant_id)

Best Practices

  1. Design for tenant isolation from day one
  2. Use tenant ID in all database queries
  3. Implement tenant-aware caching
  4. Monitor per-tenant resource usage
  5. Plan for tenant-specific customization
  6. Automate tenant provisioning
  7. Test isolation boundaries regularly

Resources

Conclusion

Multi-tenant architecture is fundamental to building successful SaaS applications. The choice of tenancy model depends on isolation requirements, cost constraints, and operational capabilities. Start with shared-everything and evolve toward stronger isolation as needed. The key is designing for tenant isolation from the beginning while maintaining operational efficiency.

Comments