I recently had an exciting experience building a SAAS kind application for our client and want to share it with you. In this article, we'll look into what is #SAAS and why we need it. Also, we'll build a sample project together to understand the challenges.
image source: https://unsplash.com/es/@jasonthedesigner
What is Saas?
SaaS, or Software-as-a-Service, is a cloud-based software delivery model where the software is hosted and maintained by a third-party provider and accessed over the internet. In SaaS, customers typically pay for a subscription to use the software on a pay-as-you-go basis, instead of purchasing and installing the software on their own servers.
SaaS offers several benefits over traditional software delivery models. First and foremost, SaaS is more cost-effective as it eliminates the need for upfront capital expenditure on hardware and software licenses, as well as ongoing maintenance costs. Additionally, because the software is hosted and maintained by the provider, customers can avoid the burden of managing complex IT infrastructure and can focus on their core business operations.
SaaS also offers greater scalability and flexibility compared to traditional software delivery models. Customers can easily add or remove users, features, and services based on their changing needs without the need for additional hardware or software upgrades. This makes SaaS particularly well-suited for businesses that experience fluctuating demand or need to quickly adapt to changing market conditions.
Another advantage of SaaS is that it offers greater accessibility and collaboration. Because the software is hosted in the cloud, users can access it from anywhere with an internet connection and collaborate with others in real-time. This makes SaaS particularly beneficial for remote teams, mobile workers, and businesses with geographically dispersed operations.
Overall, SaaS offers a more cost-effective, scalable, flexible, and accessible way for businesses to use software. As the trend towards cloud computing continues to grow, SaaS is expected to become an increasingly popular delivery model for software.
Benefits of SaaS Multi-Tenant Architecture
SaaS (Software-as-a-Service) providers often use a multi-tenant architecture, which means that multiple customers or tenants share a single instance of the software application. This approach offers several benefits over a traditional single-tenant architecture where each customer has their own dedicated instance of the software.
Cost-Effective: One of the key benefits of a multi-tenant architecture is that it can be much more cost-effective than a single-tenant architecture. Because resources are shared across multiple tenants, the provider can achieve economies of scale and reduce the cost of hardware, maintenance, and support.
Scalability: A multi-tenant architecture also offers greater scalability than a single-tenant architecture. With a single-tenant architecture, each new customer requires a new instance of the software, which can be costly and time-consuming to set up. In contrast, with a multi-tenant architecture, new customers can be added easily and quickly without needing new hardware or software.
Flexibility: A multi-tenant architecture also offers greater flexibility than a single-tenant architecture. Because resources are shared across multiple tenants, the provider can allocate resources dynamically based on demand. This means that tenants can easily scale up or down as their needs change, without having to worry about hardware or software limitations.
Continuous Improvements: SaaS providers that use a multi-tenant architecture can also benefit from continuous improvements to the software. Because all tenants are using the same instance of the software, any improvements or upgrades made by the provider can be rolled out to all customers at the same time, without the need for individual upgrades or customization.
Collaboration: A multi-tenant architecture also offers greater collaboration opportunities between tenants. Because they are all using the same instance of the software, it is easier for them to share data and collaborate on projects, which can lead to greater productivity and innovation.
Overall, a multi-tenant architecture offers several benefits for SaaS providers and their customers, including cost savings, scalability, flexibility, continuous improvements, and collaboration. As a result, it has become an increasingly popular approach for delivering SaaS applications.
image source: https://unsplash.com/@tvick
There are three multi-tenancy models:
Database Multi-tenancy Model: In this model, each tenant has their own database, which is completely isolated from other tenants. This approach offers the highest level of isolation and security, but it can also be the most resource-intensive and complex to manage. In this article, we will talk more about this model.
Schema Multi-tenancy Model: In this model, each tenant has their own schema within a shared database. This means that all tenants are using the same database, but their data is logically separated by schema. This approach is less resource-intensive than the database model and can be easier to manage, but it still requires some level of isolation and security. i have found this and this project but in my tests, it could have been more satisfying.
Table Multi-tenancy Model: In this model, all tenants are using the same database, schema and table, but their data is logically separated by tenant_id colon. This approach is the least resource-intensive and easiest to manage, but it also offers the lowest level of isolation and security. For this kind of apps i highly recommend using Citus extension for #PostgreSQL especially when you are working with a heavy workload query set
Each multi-tenancy model has its own advantages and disadvantages, and the choice of model will depend on the specific requirements of the SaaS application and the needs of the tenants. For example, the database model may be more suitable for applications that require a high level of data isolation and security, while the table model may be more suitable for applications that need to support a large number of tenants with minimal resource requirements. Regardless of the model chosen, multi-tenancy is an important approach for delivering SaaS applications, as it allows providers to efficiently serve multiple customers with a single instance of the software while still providing adequate security, isolation, and flexibility.
Multi Database tenancy approach in #Django
Now let's talk about my soulmate #Django. To handle multi-tenancy in Django we need a model to store tenant info, a middleware to check tenant and set tenant db, and a database router to separate traffic per tenant
advantages
no extra dependency
you can use both sub-domain (org1.site.com) and full-domain pattern (client-site.com)
disadvantages
you cant change db list on the fly. Each time, you have to reload the app
if you need to share a model with all tenants, you have to modify db router
first, we need a model to store 1. db alias and 2. domain
model:
class TenantModel(models.Model):
"""
Tenant model
"""
alias = models.SlugField(
max_length=50,
help_text='settings.DATABASES alias',
default='default',
)
domain = models.CharField(
max_length=100,
unique=True,
db_index=True,
validators=[validate_domain_name],
)
objects = TenantManager()
def is_default(self):
return self.id == settings.DEFAULT_TENANT_ID
domain validator:
def validate_domain_name(domain: str):
if domain == 'localhost':
return True
if 'http' in domain:
raise exceptions.ValidationError('domain name cannot contain schema')
if 'www' in domain:
raise exceptions.ValidationError('domain name cannot contain www')
if ' ' in domain:
raise exceptions.ValidationError('domain name cannot contain spaces')
if '/' in domain:
raise exceptions.ValidationError('domain name cannot contain /')
if ':' in domain:
raise exceptions.ValidationError('domain name cannot contain :')
if '.' not in domain:
raise exceptions.ValidationError('domain name must contain .')
return True
manager:
class TenantQuerySet(models.QuerySet):
def get_by_domain(self, domain: str, raise_exception=False):
if self.filter(domain__iexact=domain).exists():
return self.get(domain__iexact=domain)
if raise_exception:
raise self.model.DoesNotExist(f"Tenant with domain {domain} does not exist.")
return None
def get_default(self):
return self.get(pk=settings.DEFAULT_TENANT_ID)
class TenantManager(models.Manager.from_queryset(TenantQuerySet)):
pass
after that, we need middleware to check the request and map it to tenant
from django.http.request import split_domain_port
from django.utils.deprecation import MiddlewareMixin
SITE_CACHE = {}
class MultiTenantMiddleware(MiddlewareMixin):
"""
Middleware that sets tenant attribute to request object.
"""
def process_request(self, request):
tenant = self.get_tenant(request)
request.tenant = tenant
settings.LOCAL.tenant = tenant
def get_tenant(self, request) -> TenantModel:
"""
try to find tenant by domain, if not found, return default tenant
"""
domain = request.get_host()
if ':' in domain:
domain, port = split_domain_port(domain)
if domain not in SITE_CACHE:
tenant = TenantModel.objects.get_by_domain(domain)
if tenant:
SITE_CACHE[domain] = tenant
else:
return TenantModel.objects.get_default()
return SITE_CACHE[domain]
as you see we have set both settings.LOCAL.tenant and request.tenant. but what is the LOCAL object and why do we need it ? Unfortunately, we can't access request objects everywhere and in db router we don't know how to map tenant db to request. In that case, we can use python threading.local() object . if you set a property to this object, you can read it again in all thread space again. Django has a replacement for Local object that works with async and coroutine
settings.py
LOCAL = asgiref.local.Local()
now we need to create db router
router.py:
from django.conf import settings
class MultiTenantRouter:
"""
A router to separate tenant databases.
"""
def db_for_read(self, model, **hints):
d = getattr(settings.LOCAL, 'tenant', None)
if d:
return d.alias
return 'default'
def db_for_write(self, model, **hints):
d = getattr(settings.LOCAL, 'tenant', None)
if d:
return d.alias
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
return True
don't forget to add the router to Django settings
DATABASE_ROUTERS = ['core.routers.MultiTenantRouter']
was it hard ? now we are ready to test project
create two new database and update settings.DATABASES
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'app',
'USER': 'ahmad',
'PASSWORD': 'app',
'HOST': '127.0.0.1',
'PORT': '5432',
},
't1_app_io;': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 't1_app_io;',
'USER': 'ahmad',
'PASSWORD': 'app',
'HOST': '127.0.0.1',
'PORT': '5432',
},
't2_app_io;': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 't2_app_io;',
'USER': 'ahmad',
'PASSWORD': 'app',
'HOST': '127.0.0.1',
'PORT': '5432',
},
}
2. update /etc/hosts file to forward all requests to running server
127.0.0.1 app.io
127.0.0.1 t1.app.io
127.0.0.1 t2.app.io
3. create 3 instances for TenantModel
TenantModel.objects.create(domain='app.io',alias='default')
TenantModel.objects.create(domain='t1.app.io',alias='t1_app_io')
TenantModel.objects.create(domain='t2.app.io',alias='t2_app_io')
now open browser and test all tenants one by one
Extra Tip
Some cases you want to use tenant as url parameter, not subdomain. in this situation we need to have a dynamic url pattern to read tenant name from domain but don't touch url list. Unfortunately i can't find any generic way to handle this part. In the django code i found LocalePrefixPattern to work with language prefix as domain path like:
site.com/en/news/
site.com/de/news/
so, i created a copy of LocalePrefixPattern:
from django.urls import LocalePrefixPattern, URLResolver
class OrganizationPrefixPattern(LocalePrefixPattern):
"""
LocalePrefixPattern alternative to allow handle dynamic organization in url
"""
@property
def language_prefix(self):
d = getattr(settings.LOCAL, 'organization', None)
if not d:
return 'organization_uuid/'
return f'{d.uuid.__str__()}/'
def organization_patterns(*urls):
return [
URLResolver(
OrganizationPrefixPattern(), list(urls)
)
]
update your urls.py like this:
# organization urls
urlpatterns.extend(organization_patterns(
path('api/v1/', include([
path('product/', include('product.urls'), name='product'),
])),
))
your urls must be like this:
site.com/tenant1/api/v1/products/
site.com/tenant2/api/v1/products/
you have to update the middleware again to check the path instead of domain:
def get_tenant(self, request) -> TenantModel:
org: str = request.path.split('/')[1]
if domain not in SITE_CACHE:
tenant = TenantModel.objects.get_by_domain(org)
if tenant:
SITE_CACHE[domain] = tenant
else:
return TenantModel.objects.get_default()
return SITE_CACHE[domain]
if you have any question feel free to ask me in the comments.