Multi-Tenancy with Organizations
This guide explains how to implement organization-based multi-tenancy in your modular monolith, enabling a single codebase to serve multiple isolated tenants while supporting both shared SaaS and dedicated deployment modes.
Overview
In B2B SaaS applications, multi-tenancy allows a single deployment to serve multiple organizations while keeping their data strictly isolated. Each organization sees only their data, and users of one organization cannot access another’s resources.
This guide uses the shared database with org_id approach: all organizations’ data lives in the same database tables, distinguished by foreign keys. This provides:
Simpler operations: Single database to backup, monitor, and maintain
Natural Django integration: Works with the ORM and migrations
Cross-tenant analytics: Easy to run reports across all tenants when needed
Cost efficiency: No per-tenant database overhead
The trade-off is that every query must be filtered by organization. This guide provides patterns to make this safe and ergonomic.
Deployment modes:
Shared SaaS: Multiple organizations in a single deployment. Users can belong to multiple organizations and switch between them.
Dedicated: Single organization per deployment. Typically for enterprise customers or self-hosted installations.
The Organizations App
Following the modular monolith pattern, organizations are a separate Django app within your {project_slug}/ directory.
Creating the App
# From the project root directory
python manage.py startapp organizations {project_slug}/organizations
Your directory structure should look like:
{project_slug}/
├── users/
├── organizations/ # New app
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── managers.py # Custom managers
│ ├── middleware.py # Organization context
│ ├── migrations/
│ ├── models.py
│ ├── services.py # Business logic
│ ├── api/
│ │ ├── __init__.py
│ │ ├── serializers.py
│ │ └── views.py
│ └── tests/
│ ├── __init__.py
│ ├── factories.py
│ └── test_models.py
├── core/
└── domain_events/
Registering the App
Add to config/settings/base.py:
LOCAL_APPS = [
"{project_slug}.users",
"{project_slug}.organizations", # Add this
"{project_slug}.core",
"{project_slug}.domain_events",
]
Models
The Organization Model
The Organization model represents a tenant in your system:
# {project_slug}/organizations/models.py
from django.db import models
from django.utils.text import slugify
from {project_slug}.core.models import BaseModel
class Organization(BaseModel):
"""A tenant organization in the system."""
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True, db_index=True)
# Organization-level settings stored as JSON
settings = models.JSONField(default=dict, blank=True)
# Status
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
Key fields:
slug: URL-friendly identifier, indexed for fast lookupssettings: JSON field for per-org configuration (feature flags, limits, preferences)is_active: Soft-disable organizations without deleting data
The OrganizationMember Model
Users can belong to multiple organizations with different roles:
# {project_slug}/organizations/models.py (continued)
from django.conf import settings as django_settings
class OrganizationRole(models.TextChoices):
"""Roles within an organization."""
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
class OrganizationMember(BaseModel):
"""Membership linking a user to an organization with a role."""
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="memberships",
)
user = models.ForeignKey(
django_settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="organization_memberships",
)
role = models.CharField(
max_length=20,
choices=OrganizationRole.choices,
default=OrganizationRole.MEMBER,
)
# Invitation tracking
invited_by = models.ForeignKey(
django_settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="invitations_sent",
)
invited_at = models.DateTimeField(null=True, blank=True)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [("organization", "user")]
ordering = ["-joined_at"]
def __str__(self) -> str:
return f"{self.user} - {self.organization} ({self.role})"
@property
def is_admin(self) -> bool:
"""Check if member has admin-level permissions."""
return self.role in (OrganizationRole.OWNER, OrganizationRole.ADMIN)
Role Permissions:
Role |
Invite Members |
Manage Members |
View Billing |
Delete Org |
|---|---|---|---|---|
Owner |
Yes |
Yes |
Yes |
Yes |
Admin |
Yes |
Yes |
Yes |
No |
Member |
No |
No |
No |
No |
Viewer |
No |
No |
No |
No |
Tenant-Scoped Base Model
For models that belong to an organization, inherit from TenantModel:
# {project_slug}/organizations/models.py (continued)
class TenantModel(BaseModel):
"""Abstract base class for all organization-scoped models."""
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="%(class)s_set",
db_index=True,
)
class Meta:
abstract = True
Usage example:
# {project_slug}/projects/models.py
from django.db import models
from {project_slug}.organizations.models import TenantModel
class Project(TenantModel):
"""A project belonging to an organization."""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
class Meta:
indexes = [
models.Index(fields=["organization", "name"]),
]
The %(class)s_set pattern creates automatic related names (e.g., organization.project_set). Always add compound indexes for efficient tenant-scoped queries.
Tenant Context
Every request needs to know which organization it’s operating on behalf of. There are several approaches:
Approach |
Example |
Pros |
Cons |
|---|---|---|---|
URL path |
|
Explicit, bookmarkable |
Longer URLs |
Subdomain |
|
Clean URLs |
DNS/SSL complexity |
Session |
Session-stored |
Simple URLs |
Requires org switcher |
HTTP Header |
|
Good for APIs |
Not for browser UI |
This guide uses session-based context with an org switch endpoint, combined with header-based context for API calls. This balances simplicity with flexibility.
Organization Middleware
Create middleware that attaches the current organization to each request:
# {project_slug}/organizations/middleware.py
from django.conf import settings
from django.http import HttpRequest
from {project_slug}.organizations.models import Organization, OrganizationMember
class OrganizationMiddleware:
"""
Middleware that attaches the current organization to the request.
Resolution order:
1. X-Organization-Slug header (for API clients)
2. Session-stored organization
3. User's default organization (first membership)
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
request.organization = None
if not request.user.is_authenticated:
return self.get_response(request)
# Dedicated mode: use configured organization
if getattr(settings, "DEPLOYMENT_MODE", "saas") == "dedicated":
request.organization = self._get_dedicated_organization()
else:
# SaaS mode: resolve from header/session
org_slug = self._get_org_slug(request)
if org_slug:
request.organization = self._resolve_organization(
request.user, org_slug
)
if not request.organization:
request.organization = self._get_default_organization(request.user)
return self.get_response(request)
def _get_org_slug(self, request: HttpRequest) -> str | None:
# API header takes precedence
if header_slug := request.headers.get("X-Organization-Slug"):
return header_slug
# Fall back to session
return request.session.get("current_organization_slug")
def _resolve_organization(self, user, slug: str) -> Organization | None:
"""Resolve org slug to Organization, verifying user has access."""
try:
return Organization.objects.filter(
memberships__user=user,
slug=slug,
is_active=True,
).get()
except Organization.DoesNotExist:
return None
def _get_default_organization(self, user) -> Organization | None:
"""Get user's first organization as default."""
membership = (
OrganizationMember.objects.filter(
user=user,
organization__is_active=True,
)
.select_related("organization")
.first()
)
return membership.organization if membership else None
def _get_dedicated_organization(self) -> Organization | None:
"""Get the single organization for dedicated deployments."""
slug = getattr(settings, "DEDICATED_ORG_SLUG", None)
if not slug:
return None
return Organization.objects.filter(slug=slug, is_active=True).first()
Register the middleware in config/settings/base.py:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# ... other middleware ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"{project_slug}.organizations.middleware.OrganizationMiddleware", # After auth
# ...
]
Type Hints
For better type safety, extend the request type:
# {project_slug}/organizations/types.py
from django.http import HttpRequest
from {project_slug}.organizations.models import Organization
class OrganizationRequest(HttpRequest):
"""HttpRequest with organization context."""
organization: Organization | None
Query Filtering
The most critical aspect of multi-tenancy is ensuring queries are always filtered by organization. A single unfiltered query can leak data across tenants.
Organization-Aware Manager
Create a custom manager that filters by the current organization:
# {project_slug}/organizations/managers.py
from django.db import models
class TenantManager(models.Manager):
"""Manager that filters querysets by organization."""
def for_organization(self, organization):
"""Filter queryset to a specific organization."""
return self.get_queryset().filter(organization=organization)
def for_request(self, request):
"""Filter queryset based on request's organization context."""
if not hasattr(request, "organization") or not request.organization:
return self.none()
return self.for_organization(request.organization)
Using the Manager
Apply the manager to tenant-scoped models:
# {project_slug}/projects/models.py
from django.db import models
from {project_slug}.organizations.managers import TenantManager
from {project_slug}.organizations.models import TenantModel
class Project(TenantModel):
name = models.CharField(max_length=255)
# Use tenant-aware manager
objects = TenantManager()
class Meta:
indexes = [
models.Index(fields=["organization", "name"]),
]
View Examples
In views, always use the tenant-filtered queryset:
# {project_slug}/projects/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
from {project_slug}.projects.models import Project
class ProjectListView(LoginRequiredMixin, ListView):
model = Project
def get_queryset(self):
return Project.objects.for_request(self.request)
For DRF ViewSets:
# {project_slug}/projects/api/views.py
from rest_framework import viewsets
from {project_slug}.projects.api.serializers import ProjectSerializer
from {project_slug}.projects.models import Project
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
def get_queryset(self):
return Project.objects.for_request(self.request)
def perform_create(self, serializer):
# Automatically set organization on create
serializer.save(organization=self.request.organization)
Warning
Never use the default queryset without filtering.
# DANGEROUS - leaks data across tenants
projects = Project.objects.all()
# SAFE - always filter by organization
projects = Project.objects.for_request(request)
Consider writing a custom Django check or linter rule that flags unfiltered .all() calls on tenant models during development.
Deployment Modes
The same codebase can run in two modes controlled by environment variables:
Settings Configuration
# config/settings/base.py
# Deployment mode: "saas" or "dedicated"
DEPLOYMENT_MODE = env("DEPLOYMENT_MODE", default="saas")
# For dedicated mode, the single organization slug
DEDICATED_ORG_SLUG = env("DEDICATED_ORG_SLUG", default=None)
# Feature flags based on mode
MULTI_ORG_ENABLED = DEPLOYMENT_MODE == "saas"
Mode Behavior Differences:
Feature |
Shared SaaS |
Dedicated |
|---|---|---|
Organization picker |
Shown in UI |
Hidden |
Create organization |
Users can create orgs |
Admin-only via CLI |
Join organization |
Via invite link |
Automatic on signup |
Org in URL/header |
Required for API calls |
Optional (auto-detected) |
Cross-org reports |
Available to super-admin |
N/A |
Environment Variables
For shared SaaS deployment:
DEPLOYMENT_MODE=saas
For dedicated deployment:
DEPLOYMENT_MODE=dedicated
DEDICATED_ORG_SLUG=acme-corp
Integration with Users
The organizations module integrates with the existing users app through the membership model, not by modifying the User model itself.
User to Organizations Relationship
# Access user's organizations
user.organization_memberships.all()
# Get organizations where user is admin
user.organization_memberships.filter(role__in=["owner", "admin"])
# Check membership
def user_belongs_to_org(user, organization) -> bool:
return OrganizationMember.objects.filter(
user=user,
organization=organization,
).exists()
Adding Helper Methods to User
Optionally add convenience methods to the User model:
# {project_slug}/users/models.py
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
# ... existing fields ...
def get_organizations(self):
"""Get all organizations this user belongs to."""
from {project_slug}.organizations.models import Organization
return Organization.objects.filter(memberships__user=self, is_active=True)
def get_membership(self, organization):
"""Get membership for a specific organization."""
return self.organization_memberships.filter(organization=organization).first()
def has_org_role(self, organization, roles: list[str]) -> bool:
"""Check if user has one of the specified roles in an organization."""
return self.organization_memberships.filter(
organization=organization,
role__in=roles,
).exists()
Domain Events Integration
Use domain events to decouple organization lifecycle from other modules. See Event-Driven Architecture for the full pattern.
Organization Events
# {project_slug}/domain_events/events.py
from {project_slug}.domain_events.base import DomainEvent
class OrganizationCreatedEvent(DomainEvent):
"""Emitted when a new organization is created."""
def __init__(self, organization_id: int, name: str, created_by_user_id: int):
self.organization_id = organization_id
self.name = name
self.created_by_user_id = created_by_user_id
class UserJoinedOrganizationEvent(DomainEvent):
"""Emitted when a user joins an organization."""
def __init__(
self,
organization_id: int,
user_id: int,
role: str,
invited_by_user_id: int | None,
):
self.organization_id = organization_id
self.user_id = user_id
self.role = role
self.invited_by_user_id = invited_by_user_id
class UserLeftOrganizationEvent(DomainEvent):
"""Emitted when a user leaves or is removed from an organization."""
def __init__(self, organization_id: int, user_id: int, reason: str):
self.organization_id = organization_id
self.user_id = user_id
self.reason = reason # "left", "removed", "org_deleted"
Publishing Events
Use a service class to encapsulate business logic and event publishing:
# {project_slug}/organizations/services.py
from django.db import transaction
from {project_slug}.domain_events.bus import event_bus
from {project_slug}.domain_events.events import (
OrganizationCreatedEvent,
UserJoinedOrganizationEvent,
)
from {project_slug}.organizations.models import (
Organization,
OrganizationMember,
OrganizationRole,
)
class OrganizationService:
@classmethod
@transaction.atomic
def create_organization(cls, name: str, created_by) -> Organization:
"""Create a new organization with the creator as owner."""
org = Organization.objects.create(name=name)
# Creator becomes owner
OrganizationMember.objects.create(
organization=org,
user=created_by,
role=OrganizationRole.OWNER,
)
# Publish event after transaction commits
def _publish():
event_bus.publish(
OrganizationCreatedEvent(
organization_id=org.id,
name=org.name,
created_by_user_id=created_by.id,
)
)
transaction.on_commit(_publish)
return org
@classmethod
@transaction.atomic
def add_member(
cls,
organization: Organization,
user,
role: str = OrganizationRole.MEMBER,
invited_by=None,
) -> OrganizationMember:
"""Add a user to an organization."""
from django.utils import timezone
membership = OrganizationMember.objects.create(
organization=organization,
user=user,
role=role,
invited_by=invited_by,
invited_at=timezone.now() if invited_by else None,
)
def _publish():
event_bus.publish(
UserJoinedOrganizationEvent(
organization_id=organization.id,
user_id=user.id,
role=role,
invited_by_user_id=invited_by.id if invited_by else None,
)
)
transaction.on_commit(_publish)
return membership
API Endpoints
Register organization endpoints in the API router.
Serializers
# {project_slug}/organizations/api/serializers.py
from rest_framework import serializers
from {project_slug}.organizations.models import Organization, OrganizationMember
class OrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = ["id", "name", "slug", "created_at"]
read_only_fields = ["slug", "created_at"]
class OrganizationMemberSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source="user.email", read_only=True)
user_name = serializers.CharField(source="user.name", read_only=True)
class Meta:
model = OrganizationMember
fields = ["id", "user", "user_email", "user_name", "role", "joined_at"]
read_only_fields = ["joined_at"]
ViewSets
# {project_slug}/organizations/api/views.py
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from {project_slug}.organizations.api.serializers import (
OrganizationMemberSerializer,
OrganizationSerializer,
)
from {project_slug}.organizations.models import Organization, OrganizationMember
class OrganizationViewSet(viewsets.ModelViewSet):
serializer_class = OrganizationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Users only see organizations they belong to
return Organization.objects.filter(
memberships__user=self.request.user,
is_active=True,
)
@action(detail=True, methods=["post"])
def switch(self, request, pk=None):
"""Switch to this organization (stores in session)."""
org = self.get_object()
request.session["current_organization_slug"] = org.slug
return Response({"status": "switched", "organization": org.slug})
@action(detail=False)
def current(self, request):
"""Get the current organization context."""
if not request.organization:
return Response(
{"error": "No organization context"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = self.get_serializer(request.organization)
return Response(serializer.data)
@action(detail=True)
def members(self, request, pk=None):
"""List members of an organization."""
org = self.get_object()
members = OrganizationMember.objects.filter(organization=org)
serializer = OrganizationMemberSerializer(members, many=True)
return Response(serializer.data)
Router Registration
# config/api_router.py
from {project_slug}.organizations.api.views import OrganizationViewSet
router.register("organizations", OrganizationViewSet, basename="organization")
Testing Patterns
Testing multi-tenant code requires verifying data isolation between organizations.
Test Factories
# {project_slug}/organizations/tests/factories.py
import factory
from django.utils.text import slugify
from factory.django import DjangoModelFactory
from {project_slug}.organizations.models import (
Organization,
OrganizationMember,
OrganizationRole,
)
from {project_slug}.users.tests.factories import UserFactory
class OrganizationFactory(DjangoModelFactory):
name = factory.Faker("company")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
class Meta:
model = Organization
django_get_or_create = ["slug"]
class OrganizationMemberFactory(DjangoModelFactory):
organization = factory.SubFactory(OrganizationFactory)
user = factory.SubFactory(UserFactory)
role = OrganizationRole.MEMBER
class Meta:
model = OrganizationMember
Conftest Fixtures
# {project_slug}/conftest.py (add to existing)
import pytest
from {project_slug}.organizations.tests.factories import (
OrganizationFactory,
OrganizationMemberFactory,
)
@pytest.fixture
def organization(db):
return OrganizationFactory()
@pytest.fixture
def user_with_org(db, user):
"""User with organization membership."""
org = OrganizationFactory()
OrganizationMemberFactory(user=user, organization=org, role="owner")
return user, org
@pytest.fixture
def request_with_org(rf, user_with_org):
"""Request factory with organization context."""
user, org = user_with_org
request = rf.get("/")
request.user = user
request.organization = org
return request
Testing Data Isolation
# {project_slug}/projects/tests/test_isolation.py
import pytest
from {project_slug}.organizations.tests.factories import OrganizationFactory
from {project_slug}.projects.models import Project
@pytest.mark.django_db
class TestTenantIsolation:
def test_projects_isolated_by_organization(self):
"""Projects from one org should not appear in another's queries."""
org_a = OrganizationFactory(name="Org A")
org_b = OrganizationFactory(name="Org B")
# Create projects in each org
project_a = Project.objects.create(organization=org_a, name="Project A")
project_b = Project.objects.create(organization=org_b, name="Project B")
# Query for org_a should only return its projects
org_a_projects = Project.objects.for_organization(org_a)
assert list(org_a_projects) == [project_a]
# Query for org_b should only return its projects
org_b_projects = Project.objects.for_organization(org_b)
assert list(org_b_projects) == [project_b]
def test_for_request_returns_empty_without_org(self, rf, user):
"""for_request returns empty queryset when no organization context."""
OrganizationFactory()
Project.objects.create(
organization=OrganizationFactory(), name="Some Project"
)
request = rf.get("/")
request.user = user
request.organization = None
# Should return empty queryset, not raise an error
projects = Project.objects.for_request(request)
assert projects.count() == 0
def test_cannot_access_other_org_project_by_pk(self, client, user_with_org):
"""User cannot access resources from organizations they don't belong to."""
user, org_a = user_with_org
org_b = OrganizationFactory(name="Other Org")
secret_project = Project.objects.create(
organization=org_b, name="Secret Project"
)
client.force_login(user)
response = client.get(f"/api/projects/{secret_project.id}/")
assert response.status_code == 404
Common Pitfalls and Security
Unfiltered Queries
The most common and dangerous mistake is forgetting to filter queries:
# WRONG: Returns all projects across all organizations
def get_all_projects():
return Project.objects.all()
# RIGHT: Always filter by organization
def get_projects(request):
return Project.objects.for_request(request)
Mitigation strategies:
Use a linter or custom Django check to flag
.all()on tenant modelsOverride the default manager to require explicit filtering
Add logging for unfiltered queries in development
Object-Level Permission Checks
Don’t rely solely on queryset filtering. Add explicit checks:
from rest_framework.exceptions import PermissionDenied
class ProjectViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Project.objects.for_request(self.request)
def get_object(self):
obj = super().get_object()
# Double-check organization access
if obj.organization != self.request.organization:
raise PermissionDenied("Access denied")
return obj
Bulk Operations
Be careful with bulk operations:
# DANGEROUS: Could update projects across organizations
Project.objects.filter(status="draft").update(status="active")
# SAFE: Always scope to organization
Project.objects.for_organization(org).filter(status="draft").update(status="active")
Django Admin
The Django admin bypasses your custom managers. Add organization filtering:
# {project_slug}/organizations/admin.py
from django.contrib import admin
from {project_slug}.organizations.models import Organization, OrganizationMember
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "is_active", "created_at"]
search_fields = ["name", "slug"]
list_filter = ["is_active"]
@admin.register(OrganizationMember)
class OrganizationMemberAdmin(admin.ModelAdmin):
list_display = ["user", "organization", "role", "joined_at"]
list_filter = ["role", "organization"]
class TenantModelAdmin(admin.ModelAdmin):
"""Base admin for tenant-scoped models."""
def get_queryset(self, request):
qs = super().get_queryset(request)
# Superusers see all; others see only their orgs
if request.user.is_superuser:
return qs
user_orgs = request.user.organization_memberships.values_list(
"organization_id", flat=True
)
return qs.filter(organization_id__in=user_orgs)
Summary
Organizations as Tenants: The
Organizationmodel represents tenants;OrganizationMemberlinks users with roles via a many-to-many relationship.TenantModel Base Class: Inherit from
TenantModelfor all organization-scoped models to ensure consistent foreign key patterns and indexing.Middleware for Context:
OrganizationMiddlewareattachesrequest.organizationbased on HTTP headers, session, or default membership.Always Filter Queries: Use
TenantManager.for_request()orfor_organization()to ensure queries are scoped. Never use.all()on tenant models.Deployment Modes: Environment variable
DEPLOYMENT_MODEswitches between shared SaaS (multiple orgs) and dedicated (single org) deployments.Domain Events: Publish
OrganizationCreatedEvent,UserJoinedOrganizationEventto decouple organization lifecycle from other modules.Test Isolation: Write explicit tests verifying that data from one organization is never visible to another.
Defense in Depth: Combine queryset filtering with object-level permission checks, especially for update and delete operations.