Skip to content
Architecture

Multi-Tenancy Patterns for SaaS That Plans to Survive

Shared schema with global scopes and row-level security, database-per-tenant fleets, why schema-per-tenant is the airplane middle seat, and the whale-tier hybrid everyone converges on.

5 min read Updated Sep 3, 2026
Multi-Tenancy Patterns for SaaS That Plans to Survive

Multi-tenancy is the architectural decision SaaS founders make earliest, understand least, and live with longest. I've built it three ways — shared tables, schema-per-tenant, database-per-tenant — and migrated between them twice, which is exactly two more times than anyone wants to. Here's the map I wish I'd had, with the trade-offs stated the blunt way.

Tenant resolution routing to shared-schema or isolated-database storage, with noisy neighbors looming

First: tenant resolution, the part every pattern shares

Before storage, you need to answer "which tenant is this request?" — once, early, and unforgeably. The usual sources: subdomain (acme.yourapp.com), path prefix, a claim inside the auth token, or a header for API traffic. Two rules regardless of source:

  1. Resolve in middleware, stash in a request-scoped context, and never accept tenant IDs from request bodies. The moment tenant_id arrives as a POST field, you're one missing check away from the worst bug a SaaS can have.
  2. Auth and tenancy must agree. A valid user token used against the wrong subdomain should die in middleware, not rely on every query remembering to check.

Pattern 1: shared database, shared schema (the tenant_id column)

Every table carries tenant_id; every query filters by it. This is where ~90% of SaaS should start, because operationally it's just… a database. One migration run, one backup, one connection pool. Postgres or MySQL, no exotic tooling.

The existential risk is the forgotten WHERE clause, so you make filtering structural, not disciplinary. In Laravel, a global scope applied by a trait:

// BelongsToTenant trait — applied to every tenant-owned model
protected static function bootBelongsToTenant(): void
{
    static::addGlobalScope('tenant', function (Builder $query) {
        $query->where($query->getModel()->getTable().'.tenant_id', TenantContext::id());
    });

    static::creating(fn ($model) => $model->tenant_id ??= TenantContext::id());
}

And in Postgres you can add a second, deeper fence — row-level security — so even a raw query that forgets the scope returns nothing rather than everything:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- set per request/connection: SET app.tenant_id = '...';

Belt and suspenders. Wear both; write the test that proves a tenant-A token can't read a tenant-B row, and run it in CI forever. Remaining honest costs: noisy neighbors (one tenant's report queries slow everyone — mitigate with per-tenant rate limits and query timeouts), indexes that must lead with tenant_id (composite index order matters), and per-tenant backup restore being "restore everything, extract one tenant" — write that extraction script before a customer asks.

Pattern 2: database-per-tenant

Each tenant gets their own database; a small central catalog maps tenant → connection. Isolation stops being a query concern entirely: there is no cross-tenant leak to write, no noisy-neighbor index contention, per-tenant backup/restore/export is native, and "your data lives in your own database (in Frankfurt, if you like)" closes enterprise deals by itself.

The bill arrives in operations, and it compounds per tenant: migrations become a fleet operation (tenant #341's migration fails halfway — now your schema is heterogeneous; you need a migration runner with per-tenant status tracking, not php artisan migrate), connection pools multiply, monitoring must aggregate across N databases, and "quick query across all tenants" becomes a script with a loop. Tooling like stancl/tenancy (Laravel) or Finbuckle (.NET) industrializes a lot of this — but the operational surface is real and permanent.

Choose it when: tenants are few and valuable (tens to hundreds, not tens of thousands), contracts demand isolation or data residency, or per-tenant scale genuinely diverges. Avoid it when: you're B2C-ish with thousands of small tenants — the per-database overhead eats you.

Pattern 3: schema-per-tenant — the compromise that isn't

One database, one schema per tenant (Postgres schemas / MySQL "databases"). On paper it splits the difference. In practice it inherits the fleet-migration problem of database-per-tenant and the shared-blast-radius of shared-database, while adding its own party trick: thousands of schemas × hundreds of tables makes the catalog itself a performance problem, and tools (backups, ORMs, monitoring) get weird around it. There are teams it serves well — moderate tenant counts with hard logical-separation needs and one operational budget — but as a default it's the middle seat on the airplane. I reach for it last.

The hybrid that actually happens

Mature SaaS usually converges on a tiered mix: shared schema for the long tail, database-per-tenant for the whales — enterprise plans that pay for isolation get it. Design for this from day one with two cheap moves: route every query through the tenant context (never hardcode the connection), and keep tenant data strictly tenant-keyed so extraction is mechanical. Then the "move tenant to dedicated DB" feature is a data copy plus a catalog update — a maintenance window, not a rewrite. That optionality, like most good architecture, is the real deliverable.

Choosing a tenancy model — or migrating between two of them with the lights on? I've done both directions; happy to save you a rewrite.

Keep reading

Related articles

Architecture 4 min read

Clean Architecture Meets a Real Deadline

The one load-bearing idea under all the concentric circles, the interface-with-one-implementation tax, and the three-folder dose that keeps domains pure without nine-file one-line changes.