Skip to main content
โšก Calmops

Building Multi-Tenant SaaS: Architecture, Isolation, and Scaling

Introduction

Multi-tenant SaaS applications serve multiple customers from a single codebase and infrastructure. Building a robust multi-tenant system requires careful consideration of data isolation, performance, scalability, and security. Many SaaS companies start with single-tenant architecture and struggle to migrate to multi-tenant later.

This comprehensive guide covers multi-tenant SaaS architecture, tenant isolation strategies, and scaling patterns used by companies serving thousands of customers.


Core Concepts

Tenant

Individual customer or organization using the SaaS application.

Tenant Isolation

Ensuring data and resources of one tenant don’t affect others.

Row-Level Security (RLS)

Database-level enforcement of tenant data boundaries.

Tenant Context

Information identifying current tenant in request.

Shared Infrastructure

Single database/servers serving multiple tenants.

Dedicated Infrastructure

Separate database/servers per tenant.

Hybrid Model

Mix of shared and dedicated infrastructure.

Tenant Routing

Directing requests to appropriate tenant resources.

Data Residency

Storing tenant data in specific geographic regions.

Compliance Isolation

Ensuring regulatory requirements per tenant.


Multi-Tenant Architecture Patterns

Pattern 1: Shared Database, Shared Schema

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Single Database                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  Tenant A Data  โ”‚  Tenant B Data  โ”‚ Tenant C โ”‚  โ”‚
โ”‚  โ”‚  (tenant_id=1)  โ”‚  (tenant_id=2)  โ”‚ (id=3)   โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                                                      โ”‚
โ”‚  Cost: Lowest                                        โ”‚
โ”‚  Isolation: Lowest                                   โ”‚
โ”‚  Complexity: Lowest                                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
# Shared database with tenant_id column
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    tenant_id = db.Column(db.Integer, nullable=False)
    email = db.Column(db.String(120), unique=False)
    name = db.Column(db.String(120))
    
    __table_args__ = (
        db.Index('idx_tenant_id', 'tenant_id'),
        db.Index('idx_tenant_email', 'tenant_id', 'email'),
    )

# Always filter by tenant_id
def get_user(user_id, tenant_id):
    return User.query.filter_by(
        id=user_id,
        tenant_id=tenant_id
    ).first()

# Middleware to set tenant context
@app.before_request
def set_tenant_context():
    # Extract tenant from subdomain or header
    tenant_id = extract_tenant_id(request)
    g.tenant_id = tenant_id

# Decorator to enforce tenant isolation
def require_tenant_isolation(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        # Verify user belongs to tenant
        user = get_current_user()
        if user.tenant_id != g.tenant_id:
            return jsonify({"error": "Unauthorized"}), 403
        return f(*args, **kwargs)
    return decorated_function

@app.route("/api/users/<user_id>")
@require_tenant_isolation
def get_user_api(user_id):
    user = get_user(user_id, g.tenant_id)
    return jsonify(user.to_dict())

Pattern 2: Shared Database, Separate Schema

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Single Database                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”‚
โ”‚  โ”‚ Schema A โ”‚  โ”‚ Schema B โ”‚  โ”‚ Schema C โ”‚          โ”‚
โ”‚  โ”‚(Tenant A)โ”‚  โ”‚(Tenant B)โ”‚  โ”‚(Tenant C)โ”‚          โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ”‚
โ”‚                                                      โ”‚
โ”‚  Cost: Low                                           โ”‚
โ”‚  Isolation: Medium                                   โ”‚
โ”‚  Complexity: Medium                                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
# Separate schema per tenant
def create_tenant_schema(tenant_id):
    schema_name = f"tenant_{tenant_id}"
    
    # Create schema
    db.session.execute(f"CREATE SCHEMA {schema_name}")
    
    # Create tables in schema
    db.session.execute(f"""
        CREATE TABLE {schema_name}.users (
            id SERIAL PRIMARY KEY,
            email VARCHAR(120),
            name VARCHAR(120)
        )
    """)
    
    db.session.commit()

# Query with schema
def get_user_from_schema(user_id, tenant_id):
    schema_name = f"tenant_{tenant_id}"
    
    result = db.session.execute(f"""
        SELECT * FROM {schema_name}.users WHERE id = {user_id}
    """)
    
    return result.fetchone()

# Middleware to set schema
@app.before_request
def set_schema_context():
    tenant_id = extract_tenant_id(request)
    schema_name = f"tenant_{tenant_id}"
    
    # Set search_path for PostgreSQL
    db.session.execute(f"SET search_path TO {schema_name}, public")

Pattern 3: Dedicated Database per Tenant

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Database A   โ”‚  โ”‚ Database B   โ”‚  โ”‚ Database C   โ”‚
โ”‚ (Tenant A)   โ”‚  โ”‚ (Tenant B)   โ”‚  โ”‚ (Tenant C)   โ”‚
โ”‚              โ”‚  โ”‚              โ”‚  โ”‚              โ”‚
โ”‚ - Users      โ”‚  โ”‚ - Users      โ”‚  โ”‚ - Users      โ”‚
โ”‚ - Orders     โ”‚  โ”‚ - Orders     โ”‚  โ”‚ - Orders     โ”‚
โ”‚ - Products   โ”‚  โ”‚ - Products   โ”‚  โ”‚ - Products   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Cost: Highest
Isolation: Highest
Complexity: Highest
# Tenant database registry
TENANT_DATABASES = {
    1: "postgresql://user:[email protected]/tenant_1",
    2: "postgresql://user:[email protected]/tenant_2",
    3: "postgresql://user:[email protected]/tenant_3"
}

# Get database connection for tenant
def get_tenant_db_connection(tenant_id):
    db_url = TENANT_DATABASES.get(tenant_id)
    if not db_url:
        raise ValueError(f"No database for tenant {tenant_id}")
    
    return create_engine(db_url)

# Query with tenant-specific connection
def get_user_from_tenant_db(user_id, tenant_id):
    engine = get_tenant_db_connection(tenant_id)
    
    with engine.connect() as conn:
        result = conn.execute(
            text("SELECT * FROM users WHERE id = :id"),
            {"id": user_id}
        )
        return result.fetchone()

Row-Level Security (RLS)

PostgreSQL RLS Implementation

def enable_rls_for_tenant(tenant_id):
    """Enable Row-Level Security for tenant"""
    
    # Create policy for tenant
    db.session.execute(f"""
        CREATE POLICY tenant_isolation_policy ON users
        USING (tenant_id = current_setting('app.current_tenant_id')::int)
        WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::int)
    """)
    
    # Enable RLS
    db.session.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
    db.session.commit()

# Middleware to set tenant context
@app.before_request
def set_rls_context():
    tenant_id = extract_tenant_id(request)
    
    # Set tenant context for RLS
    db.session.execute(
        f"SET app.current_tenant_id = {tenant_id}"
    )

# Query automatically filtered by RLS
def get_all_users():
    # RLS automatically filters by tenant_id
    return User.query.all()

Tenant Routing

Subdomain-Based Routing

def extract_tenant_from_subdomain(request):
    """Extract tenant from subdomain"""
    
    # acme.example.com -> tenant_id for acme
    host = request.host
    subdomain = host.split('.')[0]
    
    # Look up tenant by subdomain
    tenant = db.session.execute(
        text("SELECT id FROM tenants WHERE subdomain = :subdomain"),
        {"subdomain": subdomain}
    ).fetchone()
    
    return tenant[0] if tenant else None

# Middleware
@app.before_request
def route_by_subdomain():
    tenant_id = extract_tenant_from_subdomain(request)
    if not tenant_id:
        return jsonify({"error": "Invalid tenant"}), 404
    
    g.tenant_id = tenant_id

Header-Based Routing

def extract_tenant_from_header(request):
    """Extract tenant from header"""
    
    # X-Tenant-ID: 123
    tenant_id = request.headers.get("X-Tenant-ID")
    
    if not tenant_id:
        return None
    
    # Verify tenant exists
    tenant = db.session.execute(
        text("SELECT id FROM tenants WHERE id = :id"),
        {"id": int(tenant_id)}
    ).fetchone()
    
    return tenant[0] if tenant else None

# Middleware
@app.before_request
def route_by_header():
    tenant_id = extract_tenant_from_header(request)
    if not tenant_id:
        return jsonify({"error": "Invalid tenant"}), 400
    
    g.tenant_id = tenant_id

Scaling Multi-Tenant Systems

Horizontal Scaling

# Load balancer configuration
TENANT_SHARDS = {
    "shard_1": ["tenant_1", "tenant_2", "tenant_3"],
    "shard_2": ["tenant_4", "tenant_5", "tenant_6"],
    "shard_3": ["tenant_7", "tenant_8", "tenant_9"]
}

def get_shard_for_tenant(tenant_id):
    """Get shard for tenant"""
    
    for shard, tenants in TENANT_SHARDS.items():
        if tenant_id in tenants:
            return shard
    
    return None

def route_to_shard(tenant_id):
    """Route request to appropriate shard"""
    
    shard = get_shard_for_tenant(tenant_id)
    
    if shard == "shard_1":
        return "app1.example.com"
    elif shard == "shard_2":
        return "app2.example.com"
    elif shard == "shard_3":
        return "app3.example.com"

Caching Strategy

from redis import Redis

redis_client = Redis(host="localhost", port=6379)

def get_user_cached(user_id, tenant_id):
    """Get user with caching"""
    
    cache_key = f"tenant:{tenant_id}:user:{user_id}"
    
    # Check cache
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Query database
    user = get_user(user_id, tenant_id)
    
    # Cache result
    redis_client.setex(
        cache_key,
        3600,  # 1 hour TTL
        json.dumps(user.to_dict())
    )
    
    return user

Monitoring Multi-Tenant Systems

class TenantMetrics:
    def __init__(self):
        self.metrics = {}
    
    def record_request(self, tenant_id, endpoint, duration_ms):
        """Record request metrics"""
        
        if tenant_id not in self.metrics:
            self.metrics[tenant_id] = {
                "requests": 0,
                "total_duration": 0,
                "endpoints": {}
            }
        
        self.metrics[tenant_id]["requests"] += 1
        self.metrics[tenant_id]["total_duration"] += duration_ms
        
        if endpoint not in self.metrics[tenant_id]["endpoints"]:
            self.metrics[tenant_id]["endpoints"][endpoint] = {
                "count": 0,
                "total_duration": 0
            }
        
        self.metrics[tenant_id]["endpoints"][endpoint]["count"] += 1
        self.metrics[tenant_id]["endpoints"][endpoint]["total_duration"] += duration_ms
    
    def get_tenant_stats(self, tenant_id):
        """Get tenant statistics"""
        
        if tenant_id not in self.metrics:
            return None
        
        stats = self.metrics[tenant_id]
        avg_duration = stats["total_duration"] / stats["requests"]
        
        return {
            "tenant_id": tenant_id,
            "total_requests": stats["requests"],
            "avg_duration_ms": avg_duration,
            "endpoints": stats["endpoints"]
        }

# Middleware to track metrics
@app.before_request
def start_timer():
    g.start_time = time.time()

@app.after_request
def record_metrics(response):
    duration_ms = (time.time() - g.start_time) * 1000
    tenant_id = g.get("tenant_id")
    endpoint = request.endpoint
    
    metrics.record_request(tenant_id, endpoint, duration_ms)
    return response

Comparison: Shared vs Dedicated

Aspect Shared Dedicated
Cost Low High
Isolation Low High
Complexity Low High
Scalability Medium High
Data Residency Difficult Easy
Compliance Difficult Easy
Performance Variable Consistent

External Resources

SaaS Architecture

Database Design


Conclusion

Multi-tenant SaaS architecture requires careful planning of data isolation, routing, and scaling. Start with shared database and row-level security for simplicity, then migrate to dedicated infrastructure as you scale.

Choose the right isolation model for your compliance and performance requirements. Monitor tenant metrics continuously and scale proactively.

Comments