top of page
Search
  • Writer's pictureAhmad Hosseini

SAAS in Django, easy and clean way (2023)

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.


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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.



There are three multi-tenancy models:

  1. 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.

  2. 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.

  3. 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

  1. no extra dependency

  2. you can use both sub-domain (org1.site.com) and full-domain pattern (client-site.com)

disadvantages

  1. you cant change db list on the fly. Each time, you have to reload the app

  2. 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



LOCAL = asgiref.local.Local()

now we need to create db router


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

  1. 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.

322 views

Recent Posts

See All
bottom of page