When building an enterprise SaaS, one of the most important decisions is how to isolate each customer's data. In this article I share the pattern I used in ERP Market to handle multiple companies with complete data isolation.
The Problem
Imagine an ERP where multiple minimarkets use the same system. Each one has its own products, sales, invoices, and users. They should never see each other's data.
Multi-Tenancy Strategies
There are three main approaches:
| Strategy | Isolation | Complexity | Cost |
|---|---|---|---|
| Database per tenant | High | High | High |
| Schema per tenant | Medium | Medium | Medium |
| Shared rows | Low | Low | Low |
For ERP Market I chose shared rows with automatic filtering for its balance between simplicity and cost.
Implementation
1. Base Model with Company
from django.db import models
class Company(models.Model):
name = models.CharField(max_length=200)
rut = models.CharField(max_length=12, unique=True)
is_active = models.BooleanField(default=True)
class CompanyBoundModel(models.Model):
"""Base model that belongs to a company."""
company = models.ForeignKey(
Company,
on_delete=models.CASCADE,
related_name='%(class)s_set'
)
class Meta:
abstract = True
2. Manager with Automatic Filtering
class CompanyManager(models.Manager):
def for_company(self, company):
return self.filter(company=company)
def get_queryset(self):
# Actual filtering is done in views
return super().get_queryset()
3. Context Middleware
from threading import local
_thread_locals = local()
def get_current_company():
return getattr(_thread_locals, 'company', None)
class CompanyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
_thread_locals.company = request.user.profile.company
response = self.get_response(request)
return response
4. View Mixin
class CompanyFilterMixin:
def get_queryset(self):
qs = super().get_queryset()
company = get_current_company()
if company and hasattr(qs.model, 'company'):
return qs.filter(company=company)
return qs
Critical Validations
Never rely solely on filtering. Add explicit validations:
def create_sale(request, product_id):
product = get_object_or_404(
Product,
id=product_id,
company=request.user.profile.company # Explicit validation
)
# ...
Results
With this pattern, ERP Market handles multiple companies with:
- 0 data leaks between tenants
- Simple queries without complex JOINs
- Single migrations for all tenants
- Centralized backup of the entire platform
Conclusion
Row-based multi-tenancy is ideal for SaaS where infrastructure cost matters. The key is being paranoid with validations and never assuming that automatic filtering is enough.