Skip to main content

Command Palette

Search for a command to run...

Multi-Tenancy: Build SaaS for Multiple Clients

Learn: Multi-Tenancy: Build SaaS for Multiple Clients

Updated
7 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Multi-Tenancy: Build SaaS for Multiple Clients - Data Isolation Strategies

Problem

Building a SaaS platform requires serving multiple independent clients (tenants) from a single application instance while ensuring:

  • Data isolation: Tenant A cannot access Tenant B's data
  • Security: Unauthorized access prevention
  • Scalability: Efficient resource utilization
  • Cost efficiency: Shared infrastructure without compromising privacy
  • Compliance: Meeting regulatory requirements (GDPR, HIPAA, etc.)

Without proper multi-tenancy architecture, you risk data breaches, performance issues, and regulatory violations.


Solution

Multi-Tenancy Models

1. Shared Database, Shared Schema (Row-Level Isolation)

  • Single database, single schema
  • Tenant identifier in every table
  • Most cost-efficient, highest security risk
  • Best for: Low-security, high-volume SaaS

2. Shared Database, Separate Schema

  • Single database, separate schemas per tenant
  • Better isolation than row-level
  • Moderate cost and complexity
  • Best for: Medium-security requirements

3. Separate Database per Tenant

  • Complete database isolation
  • Highest security and compliance
  • Higher operational overhead
  • Best for: Enterprise, high-security requirements

4. Hybrid Approach

  • Combination of above strategies
  • Shared infrastructure for non-sensitive data
  • Isolated databases for sensitive data

Code Implementation

1. Row-Level Isolation (Shared Database, Shared Schema)

# Django ORM Example with Row-Level Isolation

from django.db import models
from django.contrib.auth.models import User
from django.http import HttpRequest
from functools import wraps

# Tenant Model
class Tenant(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

# Base model with tenant isolation
class TenantAwareModel(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        abstract = True
        indexes = [
            models.Index(fields=['tenant', 'id']),
        ]

# Example: Customer model
class Customer(TenantAwareModel):
    name = models.CharField(max_length=255)
    email = models.EmailField()
    phone = models.CharField(max_length=20)

    class Meta:
        unique_together = ('tenant', 'email')

    def __str__(self):
        return f"{self.name} ({self.tenant.slug})"

# Example: Order model
class Order(TenantAwareModel):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    order_number = models.CharField(max_length=50)
    total_amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(
        max_length=20,
        choices=[('pending', 'Pending'), ('completed', 'Completed')]
    )

    class Meta:
        unique_together = ('tenant', 'order_number')

# Middleware to extract tenant from request
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Extract tenant from subdomain or URL
        tenant_slug = self.extract_tenant_slug(request)

        try:
            request.tenant = Tenant.objects.get(slug=tenant_slug)
        except Tenant.DoesNotExist:
            request.tenant = None

        response = self.get_response(request)
        return response

    def extract_tenant_slug(self, request):
        # From subdomain: tenant.example.com
        host = request.get_host().split(':')[0]
        parts = host.split('.')

        if len(parts) > 2:
            return parts[0]

        # Fallback: from URL path
        return request.path.split('/')[1]

# Custom QuerySet for automatic tenant filtering
class TenantAwareQuerySet(models.QuerySet):
    def for_tenant(self, tenant):
        return self.filter(tenant=tenant)

class TenantAwareManager(models.Manager):
    def get_queryset(self):
        return TenantAwareQuerySet(self.model, using=self._db)

    def for_tenant(self, tenant):
        return self.get_queryset().for_tenant(tenant)

# Update models to use custom manager
class Customer(TenantAwareModel):
    name = models.CharField(max_length=255)
    email = models.EmailField()

    objects = TenantAwareManager()

    class Meta:
        unique_together = ('tenant', 'email')

# View with automatic tenant isolation
from django.shortcuts import render
from django.http import HttpResponseForbidden

def customer_list(request):
    if not request.tenant:
        return HttpResponseForbidden("Tenant not found")

    # Automatically filtered by tenant
    customers = Customer.objects.for_tenant(request.tenant)

    return render(request, 'customers/list.html', {
        'customers': customers,
        'tenant': request.tenant
    })

def create_customer(request):
    if request.method == 'POST':
        name = request.POST.get('name')
        email = request.POST.get('email')

        # Automatically associate with current tenant
        customer = Customer.objects.create(
            tenant=request.tenant,
            name=name,
            email=email
        )

        return redirect('customer_detail', pk=customer.id)

    return render(request, 'customers/form.html')

# Decorator for tenant verification
def tenant_required(view_func):
    @wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if not request.tenant:
            return HttpResponseForbidden("Tenant not found")
        return view_func(request, *args, **kwargs)
    return wrapper

@tenant_required
def customer_detail(request, pk):
    try:
        customer = Customer.objects.get(
            id=pk,
            tenant=request.tenant
        )
    except Customer.DoesNotExist:
        return HttpResponseForbidden("Access denied")

    return render(request, 'customers/detail.html', {
        'customer': customer
    })

2. Schema-Level Isolation (Separate Schema per Tenant)

# PostgreSQL with separate schemas

from django.db import connection
from django.core.management.base import BaseCommand

class TenantSchemaManager:
    @staticmethod
    def create_schema(tenant_slug):
        """Create a new schema for tenant"""
        with connection.cursor() as cursor:
            cursor.execute(f"CREATE SCHEMA IF NOT EXISTS {tenant_slug}")
            connection.commit()

    @staticmethod
    def drop_schema(tenant_slug):
        """Drop tenant schema"""
        with connection.cursor() as cursor:
            cursor.execute(f"DROP SCHEMA IF EXISTS {tenant_slug} CASCADE")
            connection.commit()

    @staticmethod
    def migrate_schema(tenant_slug):
        """Run migrations for specific schema"""
        from django.core.management import call_command

        with connection.cursor() as cursor:
            cursor.execute(f"SET search_path TO {tenant_slug}")

        call_command('migrate', verbosity=0)

# Middleware for schema routing
class SchemaMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant_slug = self.extract_tenant_slug(request)

        try:
            tenant = Tenant.objects.get(slug=tenant_slug)
            request.tenant = tenant

            # Set PostgreSQL search_path to tenant schema
            with connection.cursor() as cursor:
                cursor.execute(f"SET search_path TO {tenant_slug}")

        except Tenant.DoesNotExist:
            request.tenant = None

        response = self.get_response(request)
        return response

    def extract_tenant_slug(self, request):
        host = request.get_host().split(':')[0]
        parts = host.split('.')
        return parts[0] if len(parts) > 2 else request.path.split('/')[1]

# Management command to create tenant
class Command(BaseCommand):
    help = 'Create a new tenant'

    def add_arguments(self, parser):
        parser.add_argument('name', type=str)
        parser.add_argument('slug', type=str)

    def handle(self, *args, **options):
        name = options['name']
        slug = options['slug']

        # Create tenant record
        tenant = Tenant.objects.create(name=name, slug=slug)

        # Create schema
        TenantSchemaManager.create_schema(slug)

        # Run migrations
        TenantSchemaManager.migrate_schema(slug)

        self.stdout.write(
            self.style.SUCCESS(f'Tenant "{name}" created successfully')
        )

3. Database-Level Isolation (Separate Database per Tenant)

# Multi-database routing

from django.conf import settings

class TenantRouter:
    """Route database operations to tenant-specific database"""

    def db_for_read(self, model, **hints):
        if 'tenant' in hints:
            return f"tenant_{hints['tenant'].slug}"
        return 'default'

    def db_for_write(self, model, **hints):
        if 'tenant' in hints:
            return f"tenant_{hints['tenant'].slug}"
        return 'default'

    def allow_relation(self, obj1, obj2, **hints):
        return True

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        return True

# Settings configuration
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'saas_main',
        'USER': 'postgres',
        'PASSWORD': 'password',
        'HOST': 'localhost',
    },
    'tenant_acme': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'tenant_acme_db',
        'USER': 'postgres',
        'PASSWORD': 'password',
        'HOST': 'localhost',
    },
    'tenant_globex': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'tenant_globex_db',
        'USER': 'postgres',
        'PASSWORD': 'password',
        'HOST': 'localhost',
    }
}

DATABASE_ROUTERS = ['myapp.routers.TenantRouter']

# View with database routing
def customer_list(request):
    customers = Customer.objects.using(
        f"tenant_{request.tenant.slug}"
    ).all()

    return render(request, 'customers/list.html', {
        'customers': customers
    })

# Tenant provisioning
class TenantProvisioner:
    @staticmethod
    def provision_tenant(tenant_name, tenant_slug):
        """Create new tenant with dedicated database"""
        from django.core.management import call_command

        # Create database
        db_name = f"tenant_{tenant_slug}_db"
        TenantProvisioner.create_database(db_name)

        # Add to DATABASES
        settings.DATABASES[f"tenant_{tenant_slug}"] = {
            'ENGINE': 'django.db.backends.postgresql',
            'NAME': db_name,
            'USER': 'postgres',
            'PASSWORD': 'password',
            'HOST': 'localhost',
        }

        # Run migrations
        call_command('migrate', database=f"tenant_{tenant_slug}")

        # Create tenant record
        Tenant.objects.create(name=tenant_name, slug=tenant_slug)

    @staticmethod
    def create_database(db_name):
        """Create PostgreSQL database"""
        from django.db import connection

        with connection.cursor() as cursor:
            cursor.execute(f"CREATE DATABASE {db_name}")

4. API Authentication with Tenant Isolation

# REST API with tenant-aware authentication

from rest_framework import serializers, viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated

class TenantUser(models.Model):
    """User associated with specific tenant"""
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    role = models.CharField(
        max_length=20,
        choices=[('admin', 'Admin'), ('user', 'User')]
    )

class TenantAuthentication(TokenAuthentication):
    """Custom authentication that validates tenant"""

    def authenticate(self, request):
        auth = super().authenticate(request)

        if auth is None:
            return None

        user, token = auth

        try:
            tenant_user = TenantUser.objects.get(user=user)
            request.tenant = tenant_user.tenant
            request.tenant_user = tenant_user
        except TenantUser.DoesNotExist:
            return None

        return (user, token)

class IsTenantUser(IsAuthenticated):
    """Permission to verify tenant access"""

    def has_permission(self, request, view):
        if not super().has_permission(request, view):
            return False

        return hasattr(request, 'tenant') and request.tenant is not None

class CustomerSerializer(serializers.ModelSerializer):
    class Meta:
        model = Customer
        fields = ['id', 'name', 'email', 'phone']

class CustomerViewSet(viewsets.ModelViewSet):
    serializer_class = CustomerSerializer
    authentication_classes = [TenantAuthentication]
    permission_classes = [IsTenantUser]

    def get_queryset(self):
        """Filter by current tenant"""
        return Customer.objects.filter(tenant=self.request.tenant)

    def perform_create(self, serializer):
        """Automatically set tenant"""
        serializer.save(tenant=self.request.tenant)

    @action(detail=False, methods=['get'])
    def by_email(self, request):
        """Get customer by email"""
        email = request.query_params.get('email')

        try:
            customer = Customer.objects.get(
                tenant=request.tenant,
                email=email
            )
            serializer = self.get_serializer(customer)
            return Response(serializer.data)
        except Customer.DoesNotExist:
            return Response(
                {'error': 'Customer not found'},
                status=status.HTTP_404_NOT_FOUND
            )

5. Data Encryption for Additional Security

# Field-level encryption

from cryptography.fernet import Fernet
from django.conf import settings
import base64

class EncryptedField(models.CharField):
    """Encrypted database field"""

    def __init__(self, *args, **kwargs):
        self.cipher_suite = Fernet(settings.ENCRYPTION_KEY)
        super().__init__(*args, **kwargs)

    def get_prep_value(self, value):
        if value is None:
            return value

        encrypted = self.cipher_suite.encrypt(value.encode())
        return base64.b64encode(encrypted).decode()

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value

        decrypted = self.cipher_suite.decrypt(
            base64.b64decode(value.encode())
        )
        return decrypted.decode()

# Usage
class Customer(TenantAwareModel):
    name = models.CharField(max_length=255)
    email = EncryptedField(max_length=255)  # Encrypted
    phone = EncryptedField(max_length=20)   # Encrypted

    objects = TenantAwareManager()

# Settings
import os
from cryptography.fernet import Fernet

ENCRYPTION_KEY = Fernet.generate_key()  # Store securely in environment

Tips & Best Practices

1. Choose the Right Model

Row-Level:      Cost ↑↑↑  Security ↓    Complexity ↓
Schema-Level:   Cost ↑↑   Security ↑↑   Complexity ↑
Database-Level: Cost ↑    Security ↑↑↑  Complexity ↑↑↑

2. Always Filter by Tenant

  • Never query without tenant filter
  • Use custom managers/querysets
  • Implement at ORM level, not application level

3. Audit Logging

class AuditLog(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    action = models.CharField(max_length=50)
    model_name = models.CharField(max_length=100)
    object_id = models.IntegerField()
    changes = models.JSONField()
    timestamp = models.DateTimeField(auto_now_add=True)

4. Rate Limiting per Tenant

from django_ratelimit.decorators import ratelimit

@ratelimit(key='request.tenant.slug', rate='100/h')
def api_endpoint(request):
    pass

5. Backup Strategy

  • Row-level: Single backup, restore specific tenant data
  • Schema-level: Per-schema backups
  • Database-level: Dedicated backups per tenant

6. Testing

class TenantTestCase(TestCase):
    def setUp(self):
        self.tenant1 = Tenant.objects.create(name='Tenant 1', slug='t1')
        self.tenant2 = Tenant.objects.create(name='Tenant 2', slug='t2')

    def test_data_isolation(self):
        Customer.objects.create(
            tenant=self.tenant1,
            name='Customer A',
            email='a@example.com'
        )

        # Tenant 2 should not see Tenant 1's data
        self.assertEqual(
            Customer.objects.filter(tenant=self.tenant2).count(),
            0
        )

7. Performance Optimization

  • Add indexes on tenant + other fields
  • Use database-level partitioning
  • Cache tenant configuration
  • Monitor query performance per tenant

8. Compliance & Security

  • Encrypt sensitive fields
  • Implement audit logging
  • Regular security audits
  • GDPR: Easy data export/deletion per tenant
  • SOC 2: Document isolation mechanisms

Conclusion

Multi-tenancy requires careful planning:

  • Start simple (row-level) and scale up
  • Automate tenant provisioning
  • Test isolation thoroughly
  • Monitor for data leaks
  • Document your architecture

Choose the model matching your security, cost, and scalability requirements.