All lessons
ArchitectureAdvanced15 min readIn my stack

Multi-tenant SaaS architecture

Tenancy models, airtight isolation, plans and limits, noisy neighbours and offline-capable clients.

  • Tenancy models
  • Isolation
  • Plans & limits
  • Offline sync
Step 1/4
Multi-tenant SaaS: many customers (tenants) live in one platform and one database, each with their own data.

In 30 seconds

A multi-tenant SaaS serves many customer companies (tenants) from one platform. The core decisions are how tenant data is separated (a shared schema with a tenant ID, a schema per tenant, or a database per tenant) and how isolation is enforced so no tenant ever sees another's data. Around that sit plans and limits, tenant-aware caching and queues, data export and deletion, and fairness between tenants.

Why I use it

  • I use it because a data leak between tenants is the one bug a SaaS cannot survive, so isolation must be designed in, not bolted on.
  • The tenancy model decides cost, onboarding speed, migrations and compliance for years; changing it later is extremely expensive.
  • Plans, limits and feature switches turn one codebase into products at different price points without forks.
  • Real businesses lose internet; designing clients that keep working offline and sync safely is what makes a SaaS usable at the counter.

How it works

01Three tenancy models

Picture an office building. A shared schema is an open-plan floor: every tenant's rows sit in the same tables, separated by a tenant_id column. It is the cheapest and simplest to operate, with one migration for everyone and easy cross-tenant analytics, but isolation depends entirely on every query being scoped. Schema-per-tenant is a floor with separate rooms: each tenant gets its own set of tables in one database. Isolation is stronger and per-tenant restore is easier, but migrations must run across hundreds or thousands of schemas and connection pooling gets harder. Database-per-tenant is separate buildings: the strongest isolation, per-tenant performance tuning, location and encryption keys, and easy deletion, at the highest operating cost. Many products mix them: a shared schema for most tenants and dedicated databases for large or regulated customers.

  • Shared schema + tenant_id: cheapest, scales to many small tenants, isolation via code and RLS.
  • Schema per tenant: middle ground; migration fan-out is the main pain.
  • Database per tenant: strongest isolation and data residency; highest cost and ops load.

02Enforcing isolation in layers

Isolation should never rely on developers remembering a WHERE clause. Resolve the tenant once per request from a trusted source (the authenticated token, an API key or the subdomain), never from a body field the client controls. Store it in request context and make the data layer enforce it: a repository base class, an ORM extension or global scope that adds the tenant filter and throws if it is missing. Add a second, independent layer in the database with PostgreSQL row-level security, so even a forgotten filter returns nothing. Make the default response for another tenant's ID a 404, not a 403, so attackers cannot discover which IDs exist. Then prove it with tests: every module gets a 'wrong tenant' test that tries to read and modify another tenant's records and expects failure.

sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
 
-- Per request, inside a transaction, using a non-owner app role:
BEGIN;
SET LOCAL app.tenant_id = '00000000-0000-0000-0000-000000000001';
SELECT id, total FROM orders;  -- only this tenant's rows
COMMIT;

03Plans, limits and feature flags

Tenants pay for different things, so model entitlements explicitly. A plan defines capabilities (modules such as loyalty or kitchen screens) and limits (branches, users, storage, API calls per minute). Check capabilities in one place, a policy or guard, rather than scattering 'if plan === pro' through the code. Enforce hard limits at write time (creating the eleventh branch fails with a clear message) and soft limits with warnings and usage dashboards. Feature flags are a separate tool: they turn functionality on gradually for beta tenants, enable kill switches and allow per-tenant overrides agreed in sales deals. Keep both auditable, because 'why can this tenant do that?' is a support question you will hear. Metering usage accurately also supports billing, so record it as events rather than recomputing it from live tables.

04Tenant-aware caching, queues and realtime

Every shared component is a place where tenants can leak into each other. Cache keys must include the tenant ID (tenant:42:menu:v3), or one café will see another's menu. Background jobs must carry the tenant ID in their payload, and the worker must restore tenant context before touching data, exactly as a request would. Realtime channels need the same care: subscribe clients to rooms per tenant and per branch, and authorise every subscription on the server. Files go under tenant-prefixed paths in object storage and are served through short-lived signed URLs. Logs and traces should include the tenant ID so support can investigate one customer without scanning everyone. Finally, rate limits and queue concurrency should be per tenant, so one tenant's import cannot starve the others.

  • Cache, storage and channel names always include the tenant.
  • Jobs restore tenant context before running.
  • Per-tenant rate limits and fair queueing against noisy neighbours.

05Noisy neighbours, data export and deletion

In a shared system, one tenant running a huge report or import can slow everyone. Protect the others with per-tenant rate limits and quotas, statement timeouts on queries, separate queues or concurrency caps for heavy jobs, and scheduled heavy work outside peak hours. Watch per-tenant resource usage so you can spot the outlier, and move very large tenants to dedicated resources when needed; that is one reason to keep the data layer able to route a tenant to a different database. Tenants also own their data. Offer a complete export in documented formats (CSV, JSON), and implement deletion as a real process: soft-delete for a grace period, then hard-delete across the database, files, caches and search indexes, with backups expiring on a known schedule. Document this for GDPR-style requests.

06Offline-capable clients and sync

Point-of-sale terminals, field apps and kiosks must keep working when the internet drops. The pattern: the client or a local hub keeps a local database (often SQLite) with the data it needs, records every change as a command with a unique ID, and pushes those commands to the server when the connection returns. The server applies each command idempotently, deduplicating by command ID and device, so retries after flaky reconnects are safe. Decide conflict rules per entity: append-only records such as sales rarely conflict; shared records such as stock or prices need a clear owner, server-wins rules or explicit merge. Version the local schema and migrations carefully, because devices update at different times. Give the offline device only the permissions and data its branch needs, and use device-bound tokens that can be revoked.

Use it when

  • One product sold to many companies that must never see each other's data.
  • Customers of very different sizes that need plan-based features and limits.
  • Businesses that keep operating during network outages (retail, hospitality, field work).
  • Regulated customers who may require dedicated databases or data residency.

Skip it when

  • A single-customer internal system; tenancy adds complexity with no benefit.
  • Schema-per-tenant for tens of thousands of small tenants; migrations and pooling will hurt.
  • Offline sync for apps that truly need live data only; it adds a large testing surface.

Alternatives

  • Single-tenant deployments

    One installation per customer: strong isolation, but upgrades and costs multiply.

  • Hybrid tenancy

    Shared schema for most tenants, dedicated databases for enterprise or regulated ones.

  • Platform services

    Managed auth with organisations, and managed databases with branching, reduce custom work.

  • Online-only clients

    Simpler than offline sync when connectivity is reliable and downtime is acceptable.

Best practices

  1. Derive the tenant from authentication, never from user-controlled input.
  2. Enforce isolation twice: in the data layer and in the database with row-level security.
  3. Return 404 for another tenant's resources and write a wrong-tenant test for every module.
  4. Include the tenant ID in every cache key, job payload, file path, channel and log line.
  5. Model plans, limits and feature flags explicitly and check them in one place.
  6. Apply per-tenant rate limits, quotas and query timeouts against noisy neighbours.
  7. Make offline sync commands idempotent with unique IDs per device.

Common mistakes

  • Trusting a tenant ID sent in the request body or query string.
  • Background jobs or admin scripts that bypass the tenant scope.
  • Cache keys without the tenant, leaking data between customers.
  • Running the app as the table owner or a superuser, which bypasses row-level security.
  • Deleting a tenant from the main database but leaving files, search indexes and caches behind.

Deep dive

07Row-level security in production: pooling, performance and roles

SET LOCAL cannot take bind parameters, so many apps build the statement as a string. A safer option is set_config('app.tenant_id', $1, true), where true means 'local to this transaction', which works with parameters and with transaction-mode poolers. Use the missing_ok form of current_setting in policies so an unset value yields NULL, which matches no rows, instead of an error. Performance: policies add a predicate to every query, so put tenant_id first in composite indexes (tenant_id, created_at) and in unique constraints (tenant_id, slug). Functions called inside policies should be simple and STABLE; complex subqueries per row are slow. Use three roles: a migration role that owns the tables, an application role without BYPASSRLS, and a separate, audited maintenance role for genuine cross-tenant jobs.

sql
-- Inside each request transaction (works with bind parameters and poolers)
SELECT set_config('app.tenant_id', $1, true);
 
-- Indexes and constraints lead with the tenant
CREATE INDEX orders_tenant_created_idx ON orders (tenant_id, created_at DESC);
ALTER TABLE products ADD CONSTRAINT products_tenant_sku_key UNIQUE (tenant_id, sku);
 
-- App role cannot bypass policies
CREATE ROLE app_rw LOGIN NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw;

08Routing tenants: tenant directory, cells and placement

Keep a tenant directory, a small, highly available table or service that maps each tenant to where its data lives: shared cluster A, dedicated database B, region EU. Every request resolves the tenant, then its placement, and connects to the right pool. This one indirection makes later moves possible: migrate a large tenant to its own database by copying data, replaying changes, briefly freezing writes and flipping the directory entry. Taken further, a cell-based architecture runs several identical stacks (app, database, cache, queues), each serving a subset of tenants. A bad deploy or overload then affects one cell, not every customer, and you roll out changes cell by cell. The cost is more infrastructure and tooling for cross-cell operations such as global admin search and billing, so most products start with one cell and the directory ready for more.

09Migrations and schema changes across many tenants

In a shared schema, one migration touches every tenant's data at once, so it must be online: add columns as nullable or with constant defaults, create indexes CONCURRENTLY, backfill in small batches, then add constraints as NOT VALID and validate separately. Never lock a busy table for minutes. With schema-per-tenant or database-per-tenant, the problem becomes fan-out: thousands of migrations that can partially fail. Run them through a job runner with per-tenant status, retries and concurrency limits, keep the app compatible with both old and new schema during the rollout (expand, migrate, contract), and make it safe to stop halfway. Track schema version per tenant and refuse to serve a tenant whose version the running code does not support.

  • Expand first, deploy code that handles both, contract later.
  • CREATE INDEX CONCURRENTLY and batched backfills for big tables.
  • Per-tenant migration status for schema- or database-per-tenant setups.

10Offline sync internals: ordering, clocks and conflicts

Device clocks drift, so wall-clock timestamps alone cannot order changes from several devices. Common approaches: let the server assign a monotonically increasing sequence when it accepts a command, and have clients pull 'everything after sequence N'; or use hybrid logical clocks, which combine physical time with a counter to stay close to real time while preserving causality. For conflicts, pick a rule per field: append-only events never conflict; last-writer-wins is simple but silently drops edits; field-level merge keeps non-overlapping changes; CRDTs merge automatically for counters, sets and text at the cost of complexity. Keep commands small and intention-based ('add 2 units to order 15') rather than full-state overwrites, which merge far better. Limit how long a device may stay offline before it must resync fully.

Real-world scenarios

01

A support engineer sees another tenant's data

Problem

A report endpoint built for an internal dashboard returned rows from several companies when called with a normal tenant token. Nobody noticed in review.

How to solve it

Contain first: disable the endpoint, rotate any exported files, and use logs to find who called it and what was returned, because this may be a notifiable incident. Then find why the layers failed: likely a raw query that skipped the scoped repository and a database role with BYPASSRLS. Move the app to a non-owner role without BYPASSRLS, force RLS on every tenant table and add a CI check that lists tables lacking policies. Write a wrong-tenant test for the endpoint, and route internal cross-tenant reporting through a separate, audited service and role.

02

One tenant's import slows everyone

Problem

A new large customer imports two million products. For an hour, API latency triples for all tenants and other tenants' background jobs wait in the queue.

How to solve it

Move bulk imports to a dedicated queue with a per-tenant concurrency cap, and process them in batches with short transactions. Set a statement timeout for the import role and schedule large imports off-peak. Apply per-tenant rate limits to API-driven imports. Add per-tenant metrics for query time and job time so the outlier is visible within minutes. If this tenant's steady-state load stays far above others, use the tenant directory to place them on dedicated database resources and reflect that in their plan.

03

Enterprise customer requires its own database in the EU

Problem

A prospect will sign only if its data lives in a dedicated database in a specific region, while the product runs on one shared cluster.

How to solve it

If the data layer already resolves connections through a tenant directory, this is a provisioning task. Create a dedicated database in the required region with the same schema and migration pipeline, register it in the directory, and onboard the tenant there. Make sure every other store follows the placement: object storage bucket, backups, search index, logs and caches in the region. Add the database to the migration fan-out and monitoring. Price it as a premium plan, because it adds fixed infrastructure and operations cost.

Cheat sheet

Enable and force RLS
ALTER TABLE t ENABLE ROW LEVEL SECURITY; ALTER TABLE t FORCE ROW LEVEL SECURITY;
Set tenant per transaction
SELECT set_config('app.tenant_id', $1, true);
Tables missing RLS
SELECT relname FROM pg_class WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace AND NOT relrowsecurity;
App role without bypass
CREATE ROLE app_rw LOGIN NOBYPASSRLS;
Tenant-first index
CREATE INDEX CONCURRENTLY ON orders (tenant_id, created_at);
Tenant-scoped cache key
tenant:{tenantId}:menu:v{version}
Tenant-prefixed object path
tenants/{tenantId}/media/{uuid}.webp
Statement timeout for heavy roles
ALTER ROLE importer SET statement_timeout = '30s';

In my real projects

  • In the POS SaaS I co-lead, tenancy is Organization, then Company (the tenant), then Branch, with plan-based capabilities and feature switches.
  • Isolation is enforced twice there: an explicit companyId in every query plus a Prisma extension that throws if the scope is missing; exceptions must be allow-listed with a reason, cross-tenant rows return 404, and every module test includes a wrong-tenant case.
  • Its realtime layer uses socket.io rooms per company, branch, terminal and user, and media uploads have storage quotas and signed private links.
  • An offline branch hub with a local SQLite database keeps walk-in sales running without internet; sync commands are deduplicated per terminal and the hub's migrations are versioned and pinned by checksum.

Questions & answers

Q1Which tenancy model would you choose for a new SaaS?

For most new products, a shared schema with a tenant ID on every tenant-owned table. It is the cheapest to run, onboarding a tenant is just inserting a row, and one migration updates everyone. I compensate for the weaker physical isolation with strict enforcement: scoped data access in code, PostgreSQL row-level security as a second layer and wrong-tenant tests. I keep the data layer able to route a tenant to its own database later, because large or regulated customers may require it. Schema-per-tenant is a middle ground I would pick only with a modest number of tenants.

Q2How do you guarantee one tenant never sees another's data?

By layering defences so no single mistake causes a leak. The tenant is resolved once from authentication and stored in request context. The data layer adds the tenant filter automatically and throws if a query has no scope, with any intentional exception allow-listed and justified. The database enforces row-level security as an independent check. Caches, queues, files and realtime channels all carry the tenant ID. Other tenants' records return 404. Finally, tests prove it: every module includes a case that tries to read and modify another tenant's data, plus logging that lets you audit access.

Q3What is a noisy neighbour and how do you handle it?

It is a tenant whose workload degrades service for others on shared infrastructure, for example a large import, an expensive report or a burst of API calls. I handle it at several levels: per-tenant rate limits and quotas at the API, statement timeouts and query limits in the database, separate queues or concurrency caps for heavy jobs, and scheduling bulk work off-peak. Per-tenant metrics show who is consuming what. If one tenant consistently needs far more than others, I move them to dedicated resources, ideally a separate database, and price that accordingly.

Q4How does row-level security work in PostgreSQL, and what are its traps?

You enable RLS on a table and create policies, usually comparing the row's tenant_id to a session setting the app sets per transaction. Every query, including ones developers forget to filter, then only sees allowed rows; WITH CHECK prevents writing rows for another tenant. The traps: superusers and roles with BYPASSRLS ignore it, and table owners do unless you FORCE row-level security, so the app must use a separate non-owner role. With connection pooling, use SET LOCAL inside a transaction so the setting cannot leak to the next request. Also index tenant_id, because policies add filters to every query.

Q5How would you design offline mode for a point-of-sale client?

The terminal or a local hub keeps a local database with the menu, prices and open orders it needs, and works fully against it. Every change is recorded as a command with a unique ID, device ID and timestamp, and queued. When the connection returns, commands are pushed in order and the server applies each one idempotently, so a retry never duplicates a sale. Sales are append-only and rarely conflict; for shared data like prices, the server is the owner and pushes updates down. Fiscal and payment steps need explicit offline variants and later reconciliation, and devices use revocable tokens.

Q6How do you handle a tenant asking to export or delete all their data?

Export should be a supported feature, not an ad hoc script: a background job that collects every tenant-owned entity into documented formats, packages files, and delivers a time-limited signed link. Deletion is a process with stages. First a soft delete with a grace period in case of mistakes, then hard deletion across the database, object storage, search indexes, caches and third-party processors. Backups cannot usually be edited, so I document that they expire on a fixed schedule. Every step is logged so we can prove the request was completed, which matters for privacy regulations.

Senior-level questions

S1How would you migrate a shared-schema SaaS to support dedicated databases for some tenants?

Introduce placement before moving anyone. First, make every data access resolve the tenant's connection through a tenant directory, defaulting to the shared cluster; deploy that with no behaviour change. Ensure no query joins across tenants and that background jobs, reports and admin tools also use the directory. Then build the move: create the target database, copy the tenant's rows by tenant_id, stream subsequent changes through logical replication or a dual-write window, verify counts and checksums, freeze the tenant's writes for seconds, flip the directory entry and unfreeze. Keep the old rows read-only for a rollback period. The trade-off is operational: migrations, monitoring and backups now fan out to several databases, so automate those before offering dedicated placement widely.

S2How do you design per-tenant encryption keys, and when is it worth it?

Use envelope encryption. Each tenant gets a data encryption key, which encrypts sensitive fields or files; that key is itself encrypted by a key-encryption key held in a key management service and never stored in plain form. Decrypted keys are cached briefly in memory. Benefits: crypto-shredding, where destroying a tenant's key makes all its data, including copies in backups, unreadable; customer-managed keys for enterprise contracts; and reduced blast radius if one key leaks. Costs: you cannot index or search encrypted fields easily, every read pays a small decryption cost, key rotation needs re-wrapping, and KMS availability becomes a dependency. It is worth it for regulated data and enterprise deals; for most fields, disk-level encryption plus strict access control is enough.

S3How do you keep plan limits accurate under concurrency?

Counting rows and then inserting is a race: two requests both see nine branches out of ten and both create one. Enforce limits where writes are serialised. Options: a usage counter row per tenant and resource, updated with a conditional statement (increment only if below the limit) in the same transaction as the insert; or a lock on the tenant row while checking and inserting. For high-volume metered limits, such as API calls, exact enforcement is costly, so use a Redis counter with a sliding window and accept small overshoot, then bill from the authoritative usage events. Decide per limit whether it is hard (contractual, enforce strictly) or soft (warn, allow brief overshoot), and show tenants their current usage so limits never come as a surprise.

Glossary

Tenant
A customer organisation whose users and data are isolated from every other customer.
Row-level security
Database policies that filter which rows a session may read or write.
Noisy neighbour
A tenant whose heavy usage degrades performance for others on shared resources.
Tenant directory
A mapping from each tenant to the database, region or cell holding its data.
Cell
An independent copy of the full stack serving a subset of tenants, limiting blast radius.
Entitlement
A capability or limit a tenant's plan grants, checked by the application.
Data residency
A requirement that data is stored and processed in a specific country or region.
Hybrid logical clock
A timestamp combining physical time and a counter to order events across devices.
CRDT
A data structure whose concurrent edits always merge to the same result without coordination.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.