Multi-tenant SaaS architecture: lessons from four platforms

Multi-tenant SaaS architecture lessons from hospital, vehicle, compliance and retail platforms: tenant isolation, query scoping, access gates and defaults.

By Hamza Ghouri

Multi-tenant SaaS architecture is one of those topics where the first version always looks easy. Add a tenant_id column, filter by it, ship. The hard parts show up later: a query that forgets the filter, a customer who wants to change defaults without affecting anyone else, a tenant whose subscription lapsed but whose users are still logged in.

I've built several multi-tenant platforms. HMIS is a hospital SaaS covering OPD and IPD, labs, e-prescriptions and a live token-queue dashboard for doctors, built with Laravel, MySQL and React Native. Recond is a vehicle-reconditioning platform with a Next.js frontend, a Flutter app, and a backend that has gone through a v1 and a v2. More recently I've worked on a compliance and risk platform and a multi-tenant retail SaaS. This post is what I'd tell anyone starting their own.

Choose the isolation model on purpose

There are three common models:

  • Shared database, shared schema: every table has a tenant_id. Cheapest to run and simplest to migrate. Isolation depends entirely on your code and database policies.
  • Schema per tenant: one database, a schema each. Stronger separation, but every migration runs once per tenant and tooling gets awkward.
  • Database per tenant: the strongest isolation and the easiest per-tenant backup and restore, with the highest operational cost.

My default for a new product is shared schema with a tenant_id on every tenant-owned table, enforced at the database level where possible. It scales to a lot of tenants with one migration path. I'd move a specific large or regulated customer to their own database only when a contract or a real load problem asks for it, and design the code so the tenant's connection can be swapped without touching business logic.

Scope every query in one place

The classic multi-tenant bug is a query that forgets its tenant filter. You cannot prevent it with code review alone, because it only takes one missed WHERE. The fix is to make scoping automatic.

In PostgreSQL, row-level security does it at the database layer:

ALTER TABLE work_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON work_orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

-- at the start of each request's transaction
SELECT set_config('app.tenant_id', $1, true);

The third argument to set_config makes the value local to the transaction, so it cannot leak into the next request on a pooled connection. If the value is missing, the cast fails and the query errors out, which is the failure mode you want. The app should connect as a role that does not own the tables and is not a superuser, since those bypass policies.

On MySQL, which has no row-level security, I'd enforce the same rule at the ORM layer. In Laravel, a global scope on every tenant-owned model applies the filter automatically. Whichever ORM you use, the principle holds: scoping happens in one shared place, never in each query by hand. An ORM that lets you drop down to SQL makes policies like these easier to live with; I wrote about that trade-off in why I chose TypeORM over Prisma.

Put a tenant access gate in front of everything

Authentication answers "who is this user". In a SaaS you also need "is this tenant allowed in right now". The multi-tenant retail SaaS I work on has a tenant access gate for exactly this, alongside its subscription lifecycle and usage metering.

The pattern I'd use:

  • Resolve the tenant early in the request, from the subdomain, a header or the user's membership.
  • Check the tenant's state: active, trialing, past due, suspended.
  • Decide what each state allows. Past due might be read-only. Suspended might allow only billing pages.
  • Record metered usage at the same boundary, so billing and access read from one source of truth.

Putting this in a single middleware means a lapsed subscription takes effect everywhere at once, including API clients and mobile apps that never visit your billing page.

Give each tenant its own copy of defaults

Most SaaS products ship with defaults: templates, categories, workflows, frameworks. In the compliance and risk platform I work on, every organisation gets its own copy of the default compliance frameworks.

I like this design for any product where customers customize defaults. If every tenant points at one shared global row, one customer's edit becomes everyone's problem, and you end up adding override tables and merge logic. Copying on tenant creation is simpler: each organisation owns its data outright, edits stay local, and deleting a tenant deletes everything it owns. The cost is that improving a default later becomes a deliberate migration, where you decide per tenant whether to offer the update. That is usually the behaviour customers expect anyway.

Real-time channels and noisy neighbours

Live features are an easy place to leak data across tenants. HMIS has a live token-queue dashboard for doctors, and any feature like that needs its channels scoped by tenant (and often by department or clinic within it). Subscribe clients to channels named by tenant, authorize the subscription on the server, and never broadcast on a global channel and filter on the client.

In a shared system, one busy tenant can slow everyone down. Rate limit per tenant and per user, and protect the whole API at a level the app can actually sustain. On the compliance platform, Nginx rate-limits the API at that level, which keeps a burst from any client from becoming an outage for all of them. Heavy work (imports, reports, AI jobs) belongs in background queues with per-tenant concurrency so one tenant's batch cannot starve the rest. Finding the real bottleneck matters here as much as anywhere, which I wrote about in scaling a fitness platform to 60K users.

Plan tenant data for the next version

Backends get rebuilt. When a platform moves to a new backend version, as Recond did from v1 to v2, the tenant boundaries in the data are what make migration tractable. Clean tenant_id columns, tenant-owned defaults and no shared mutable rows mean you can migrate tenant by tenant, verify each one, and roll back one customer without touching the others.

Where I'd start

Shared schema, tenant_id everywhere, scoping enforced in one place, and a tenant access gate in middleware. Copy defaults per tenant, scope every real-time channel, and rate limit per tenant from day one. HMIS, Recond and my other platforms are on my projects page.

Want this kind of work on your product?

Get in touch