Understanding Multi-Tenancy: Core Logic and High-Level Code With Django
Custom implementations of multi-tenancy can allow developers to innovate and tailor solutions according to unique business needs and use cases.
Join the DZone community and get the full member experience.
Join For FreeMulti-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, enable financial institutions to provide personalized banking services and 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 of how multi-tenancy is implemented using Django with a high-level code.
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:
- Shared database with shared schema
- Shared database with isolated schema
- 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:
- cust1.example.com
- cust2.example.com
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 the 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 the DB value during the life cycle of the thread.
Add multiple databases in the 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 tenant's data in a single database due to security concerns.
Published at DZone with permission of Jatinkumar Patel. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments