Understanding Multi-Tenancy: Core Logic and High-Level Code with Django

Multi-tenant applications are crucial for efficiently serving multiple clients from a single shared instance, offering cost savings, scalability, and simplified maintenance. Such applications can allow hospitals and clinics to manage patient records securely, can enable financial institutions to provide personalized banking services, and can help streamline inventory management and customer relationship management across multiple stores. The primary functionality of multi-tenant applications resides in their capacity to serve numerous clients through a single installation of the application. In this architecture, each client, referred to as a tenant, maintains complete data isolation, ensuring data privacy and security.

There are multiple third-party libraries available to implement multi-tenancy in Django. However, custom implementations of multi-tenancy can allow developers to innovate and tailor solutions according to unique business needs and use cases. Therefore, in this blog we will show the core logic with a high-level code of how multi-tenancy is implemented using Django.

Approaches to Multi-Tenancy

Multi-tenancy offers to serve diverse requirements that may vary in their constraints. Tenants may have similar data structures and security requirements or might be looking for some flexibility that allows each tenant to have their own schema. Therefore, there are many approaches to achieving multi-tenancy

  1. Shared database with shared schema
  2. Shared database with isolated schema
  3. Isolated database with a shared app server.

1. Shared database with shared schema:

This is the simplest method. It has a shared database and schema. All tenant’s data will be stored in the same DB and schema.

Create a tenant app and create two models: Tenant and Tenant Aware Model to store Tenant base information.

class Tenant(models.Model):

name = models.CharField(max_length=200)

subdomain_prefix = models.CharField(max_length=200, unique=True)

class TenantAwareModel(models.Model):

tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)

class Meta:

    abstract = True

Inherit TenantAwareModel into your all app models:

class User(TenantAwareModel):
...

class Order(TenantAwareModel):
...

Identifying Tenants:

One method to identify tenants is to use a subdomain. Let’s say your main domain is www.example.com, and customer subdomains are:

  1. cust1.example.com
  2. cust2.example.com


Write a method to extract tenant from request in utils.py file:

from .models import Tenant

def hostname_from_request(request):

    # split at `:` to remove port

    return request.get_host().split(':')[0].lower()

def tenant_from_request(request):

    hostname = hostname_from_request(request)

    subdomain_prefix = hostname.split('.')[0]

    return Tenant.objects.filter(subdomain_prefix=subdomain_prefix).first()


Use tenant_from_request method in views:

from tenants.utils import tenant_from_request

class OrderViewSet(viewsets.ModelViewSet):

    queryset = Order.objects.all()

    serializer_class = OrderSerializer

    def get_queryset(self):

        tenant = tenant_from_request(self.request)

        return super().get_queryset().filter(tenant=tenant)

Also update ALLOWED_HOSTS your settings.py. Mine looks like this:

ALLOWED_HOSTS = ['example.com', '.example.com'].

2. Shared database with isolated schema:

In the first option, we used a ForeignKey to separate the tenants. It is simple but there is no way to limit access to a single tenant’s data at the DB level. Also getting the tenant from the request and filtering on it is all over your codebase, rather than a central location.

One solution to the above problem is to create a separate schema within a shared database to isolate tenant wise data. Let’s say you have two schemas cust1 and cust2.

Add this code to utils.py file:

def get_tenants_map():

    # cust1 and cust2 are your database schema names.

    return {

        "cust1.example.com": "cust1",

        "cust2.example.com": "cust2",

    }

def hostname_from_request(request):

    return request.get_host().split(':')[0].lower()

def tenant_schema_from_request(request):

    hostname = hostname_from_request(request)

    tenants_map = get_tenants_map()

    return tenants_map.get(hostname)

def set_tenant_schema_for_request(request):

    schema = tenant_schema_from_request(request)

    with connection.cursor() as cursor:

        cursor.execute(f"SET search_path to {schema}")

We will set the schema in the middleware before any view code comes into picture, so any ORM code will pull and write the data from the tenant’s schema.

Create a new middleware like this:

from tenants.utils import set_tenant_schema_for_request

class TenantMiddleware:

    def __init__(self, get_response):

        self.get_response = get_response

    def __call__(self, request):

        set_tenant_schema_for_request(request)

        response = self.get_response(request)

        return response

And add it to your settings.MIDDLEWARES

MIDDLEWARE = [

    # ...

    'tenants.middlewares.TenantMiddleware',

]

3. Isolated database with a shared app server:

In this third option, We will use a separate Database for all tenants. We will use a thread local feature to store db value during the life-cycle of thread.

Add multiple databases in setting file:

DATABASES = {

    "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "default.db"},

    "cust1": {"ENGINE": "django.db.backends.sqlite3", "NAME": "cust1.db"},

    "cust2": {"ENGINE": "django.db.backends.sqlite3", "NAME": "cust2.db"},

}

Add this code to utils.py file:

def tenant_db_from_request(request):

    return request.get_host().split(':')[0].lower()

Create a TenantMiddleware like this:

import threading

from django.db import connections

from .utils import tenant_db_from_request

import threading

from django.db import connections

from .utils import tenant_db_from_request

THREAD_LOCAL = threading.local()

class TenantMiddleware:

    def __init__(self, get_response):

        self.get_response = get_response

    def __call__(self, request):

        db = tenant_db_from_request(request)

        setattr(THREAD_LOCAL, "DB", db)

        response = self.get_response(request)

        return response

def get_current_db_name():

    return getattr(THREAD_LOCAL, "DB", None)

def set_db_for_router(db):

    setattr(THREAD_LOCAL, "DB", db)

Now write a TenantRouter class to get a database name. This TenantRouter will be assigned to DATABASE_ROUTERS in settings.py file.

from tenants.middleware import get_current_db_name

class
CustomDBRouter:

    def db_for_read(self, model, **hints):

        return get_current_db_name()

    def db_for_write(self, model, **hints):

        return get_current_db_name()

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

        return get_current_db_name()

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

        return get_current_db_name()

Add TenantMiddleware and CustomDBRouter to settings.py file:

MIDDLEWARE = [

    # ...

    "tenants.middlewares.TenantMiddleware",

]

DATABASE_ROUTERS = ["tenants.router.TenantRouter"]

Conclusion

You can choose a way to implement multi-tenancy per requirement and complexity level. However, an isolated DB and schema is the best way to keep data isolated when you do not want to mix all tenants data in a single database due to security concerns.

Let's Talk
Lets Talk

Our Latest Blogs

With Zymr you can