Backend & DjangoApr 20268 min read

Building Multi-Tenant SaaS Architecture from Scratch

Tenancy is the one architectural decision you can't easily refactor later. How I chose shared-schema tenancy for a time-tracking SaaS, enforced isolation in depth, and wired Stripe billing to tenant lifecycle.

DILJOT SINGH · TECHNICAL LEAD

When I designed a time-tracking and productivity SaaS from scratch, the first real decision wasn't the framework or the cloud provider. It was tenancy — how one deployment serves many customers without their data ever touching. Almost everything else in a SaaS can be refactored later. Tenancy is load-bearing from commit one.

The three tenancy models, honestly compared

I chose shared schema. The product's economics (many small teams, self-serve signup) demanded cheap tenant provisioning — creating a tenant had to be an INSERT, not a database migration. That choice makes isolation enforcement the central engineering problem, so we built it in depth rather than trusting convention.

Diagram
Fig 1 — The three tenancy models and their isolation/operations trade-off

Isolation in depth, not by convention

"Everyone remembers to filter by tenant" is not an architecture. We enforced tenancy at three layers. First, middleware resolved the tenant from the subdomain and JWT claims and rejected any mismatch before a view ran. Second, a custom Django model manager applied the tenant filter automatically — the default queryset was tenant-scoped, and the unscoped escape hatch had a loud name that stood out in code review:

class TenantManager(models.Manager):
    def get_queryset(self):
        tenant = current_tenant()  # from request-scoped context
        if tenant is None:
            raise TenantContextMissing()
        return super().get_queryset().filter(tenant_id=tenant.id)

    def dangerously_unscoped(self):
        # grep-able. Requires a code-review justification.
        return super().get_queryset()

Third, a test-suite fixture ran every API endpoint as tenant A while tenant B's data existed, and failed the build if any response leaked a foreign row. That last layer caught the mistakes the first two couldn't — hand-written SQL, aggregation queries, and admin endpoints.

Stripe billing is a lifecycle problem, not an integration

The Stripe integration itself is a few days of work. The real design work is mapping subscription state to product behavior: what exactly happens on payment failure, on downgrade, on cancellation? We modeled tenant state as an explicit machine — trialing, active, past_due, suspended, cancelled — driven only by Stripe webhooks, never by assumptions in request handlers. Two rules kept it sane: webhooks were processed idempotently through a Celery queue (Stripe retries, and duplicate delivery is normal), and enforcement was graceful — past_due warned for a grace window before suspension, and suspension locked writes but never deleted data.

The unglamorous parts that mattered

The summary I'd give another architect: choose the tenancy model your business model forces, then spend your cleverness on making isolation automatic. The systems that leak data aren't the ones that chose the "wrong" model — they're the ones that enforced the right model by convention.