Django REST Framework Multi-Tenant: Two Products, One API
A Django REST Framework multi-tenant layout for two products on one backend: an org-scoped base viewset, an entitlement permission class, and SigmaCPM's rules.
A Django REST Framework multi-tenant backend can serve several products if you share the core (users, organizations, seats, entitlements, jobs) and keep each product's domain in its own Django apps. Scope every queryset to the caller's organization in one base viewset, gate each product with an entitlement permission class, and keep dependencies pointing one way.
TL;DR: Put tenancy in a base viewset that filtersget_querysetand sets the organization inperform_create, because DRF's docs say object-level permissions don't run on lists or on create. Add one permission class per product module for entitlements. SigmaCPM shares a backend this way with a sibling product and its Chrome extension.
That's how SigmaCPM is built. It's construction project management for civil contractors, from tender to closeout, and its multi-tenant backend (organizations, seats, per-module entitlements) is shared with a sibling password-sharing product and that product's Chrome extension. This post is about that one decision: how two unrelated products share one DRF backend without stepping on each other.
Why Django for a platform rather than a single app?
Because a platform needs the boring parts on day one: an ORM with migrations, auth, an admin for support staff, and a permission model. Django ships all of that, and DRF adds serializers, viewsets, routers, and pluggable permission classes. For two products with many related tables and many kinds of user, that's a lot of code you don't write.
I've made this call before. At Ontario Power Generation I moved an MS Access application to Django REST Framework with a Next.js front end, for the same reasons that apply here: a relational model with real permissions, an admin people could actually use, and an API other tools could call.
If the product is mostly an AI service or a streaming API, FastAPI may fit better. The Node comparison is on the Django vs Node.js page.
How do you lay out one Django codebase for two products?
In three layers, with imports allowed in one direction only. Django itself describes an application as a package that provides a set of features and may be reused across projects (Django docs). That maps cleanly onto a shared core, shared services, and one group of apps per product.
| Layer | Holds | Used by |
|---|---|---|
| Core | Users, organizations, memberships and seats, plans, entitlements, audit | Every product |
| Shared services | The job queue, file storage, LLM calls with cost recording, OCR | Any product that needs them |
| Product apps | Each product's own models, serializers, and viewsets | One product each |
The rule: product apps may import from the core and shared services, and the core never imports from a product. A new product is a new set of apps, not a fork.
In SigmaCPM's case the product side holds bids, contracts, RFIs, vendor quotes, drawings, and tender extraction. The password-sharing product keeps its own models. Both sit on the same organizations, seats, and entitlements. Why would a password tool and a construction tool share a user table? Because the platform pieces they need (accounts, organizations, seats, billing) are the same, and writing them twice buys nothing.
How do you stop one organization reading another's data in DRF?
Make the organization filter impossible to forget. Put it in a base viewset that every product viewset inherits, filter get_queryset by the caller's organization, and set the organization yourself in perform_create. DRF's docs show this exact move for filtering against the current user (DRF filtering docs).
The DRF detail that makes this necessary: its docs say generic views don't apply object-level permissions to each instance in a list, for performance reasons, and that has_object_permission isn't called on create because get_object() isn't (DRF permissions docs). So the queryset protects lists, and the create hook protects writes. has_object_permission alone protects neither.
Here's the shape of the base class, simplified for this post:
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
class OrgScopedViewSet(viewsets.ModelViewSet):
"""Every product viewset inherits from this. request.org is set by
authentication after confirming the user is a member of that org."""
module = None # each product viewset names its module
permission_classes = [IsAuthenticated, IsOrgMember, HasModule]
def get_queryset(self):
return super().get_queryset().filter(organization=self.request.org)
def perform_create(self, serializer):
# Never trust an organization sent in the request body.
serializer.save(organization=self.request.org)
Two review rules go with it. A product viewset that doesn't inherit from the base is a review failure, full stop. And any serializer that exposes organization as a writable field is a bug, because a client could move a record into another tenant.
Seats and entitlements: two different questions
Seats answer "who can log in". Entitlements answer "what has this organization paid for". On a backend shared by two products, keep them in separate tables and separate checks, or every pricing change turns into a permissions bug.
The model is small:
- A plan grants a set of module entitlements.
- An organization has a plan, plus any add-ons.
- Each product viewset declares the module it belongs to.
- A DRF permission class checks the organization has that module before the view runs.
from rest_framework.permissions import BasePermission
class HasModule(BasePermission):
message = "Your plan does not include this module."
def has_permission(self, request, view):
return request.org.entitlements.filter(
module=view.module, active=True
).exists()
That gives you one place to answer the support question "why can't this customer see tenders?" and one place to change when sales invent a new bundle. It also lets each product be sold alone or with the other, with no code change. The wider question of how seats and plans fit a SaaS business is in the multi-tenant SaaS checklist.
What should the two products share besides users?
Slow work and model calls. Two products means OCR on scanned tenders, LLM extraction, exports, and emails on one side, plus the sibling product's own jobs. SigmaCPM runs all of it on one Postgres-backed job queue with leases, orphan reclaim, and skip-locked selection, so there's no extra broker to run.
Postgres documents SKIP LOCKED as a way to avoid lock contention when several consumers read a queue-like table (PostgreSQL docs). Each product registers handlers for its own job types against the shared queue. The full design, including the reclaim query, is in Postgres as a job queue.
LLM calls go through the shared layer too, and SigmaCPM records tokens and cost on every call. That's what lets you see which product, which organization, and which feature is spending. What to do with those numbers is covered in controlling LLM costs. The extraction rules themselves (quotes checked against the cited page, a second pass on bond terms) live in the product app, and the pipeline is described in reading tenders with AI.
Which API habits keep several clients simple?
Two products usually means several clients: web apps, a Chrome extension, maybe a mobile app later. A handful of DRF habits help all of them, and none of them costs much on day one. They cost a lot to add after a client is already in the wild.
- Version from the start, even if there's only
v1. DRF supports URL path, namespace, header, hostname, and query parameter versioning (DRF versioning docs). A Chrome extension can't be force-updated the way a web app can. - Routers and viewsets for standard resources, explicit actions for workflows like "submit bid".
- Paginate every list endpoint.
- One error shape across both products.
- An OpenAPI schema kept in CI, so a breaking change shows up as a diff.
DRF specifics beyond this post are on the Django REST Framework page.
When a product should leave the shared backend
When its scaling needs, release rhythm, or data rules differ sharply from the rest. A shared backend is the right default for a small team running related products. Until one of those three things diverges, one codebase with clear app boundaries is easier to run, test, and hire for.
The one-way import rule is what keeps that exit cheap. If the core never imports a product, moving a product out means copying its apps and pointing them at an API instead of a model. If the core does import products, you're untangling a knot.
FAQ
Where should the active organization come from in a DRF request?
From something the server verifies, such as a claim in the token or a header checked against the user's memberships during authentication. Set it on the request once, before any view runs. Never read it from the request body, and never let a serializer accept it as a writable field.
Can two unrelated products share one Django user table?
Yes, if the same customers buy both. A shared user and organization model means one login, one seat list, and one bill. Keep each product's data in its own apps and gate access with entitlements, so a customer who only bought one product never sees the other's screens.
Does a base viewset slow down every request?
Not noticeably. The organization filter is one indexed WHERE clause, and it usually makes queries faster because the database reads fewer rows. Index the organization column on every tenant table, and check the query plan for the largest tables once the data grows past a few hundred thousand rows.
How do we add a third product later?
Add a new group of Django apps that import from the core, declare a new module name, and add that module to the plans that include it. If the core never imports from products, nothing in the existing two products changes, and their tests should pass untouched.
Planning a platform on Django, or adding a second product to one? See our Django development work and how we approach SaaS development, then book a 30-minute architecture call.