Back to Blog
Available in:
Jun 3, 20264 min read

Multi-Tenancy in B2B SaaS: Trade-offs and the Model I Chose

Multi-tenancy is the most irreversible architectural decision in a B2B SaaS. Most teams make it implicitly, mid-sprint, the moment they realize they need to isolate one customer's data from another's.

Get the model wrong early and you will not refactor. You will migrate production data for every tenant, under pressure, with leakage risk — or live with an architecture that never quite closes.

Why it's more than a security question

In a compliance product, each tenant holds sensitive supplier data, risk alert history, and AI classification results. Leaking that across tenants isn't a UX bug; it's a security incident with direct regulatory and contractual consequences.

But the operational side is what surprises people. With N tenants, every architectural decision multiplies by N. One database per tenant means N backups, N migration runs, N instances to monitor. Operational cost scales linearly with customer count — the exact opposite of what you want in SaaS.

And the decision has to be made before the first line of data-model code. Adding tenant isolation to a schema already in production is a rewrite with downtime and data-migration risk, not a refactor.

The four models

Database-per-tenant. Maximum isolation, very high operational cost. It makes sense for enterprise customers with requirements demanding physical separation — data residency, separate database audits. Unworkable for an SMB product with hundreds of tenants.

Schema-per-tenant. Good isolation, moderate cost — migrations must run per schema. This is what the apartment gem does in Rails. The catch: dumping N schemas is operationally awkward, and PostgreSQL's practical schema limit starts to bite somewhere above a thousand.

Shared database with application-level filtering. Just where(tenant_id: ...) on every query. Simplest to implement, and isolation depends entirely on never forgetting the filter — anywhere, ever. One application bug equals cross-tenant data leakage. Unacceptable for compliance.

Shared database with Row-Level Security. Isolation enforced by the database rather than the application. Single migrations, minimal infrastructure cost, low implementation complexity in Rails. This is what I chose.

The implementation

Enable RLS on the table and define the isolation policy:

ALTER TABLE suppliers ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON suppliers
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

PostgreSQL now applies the tenant_id filter automatically to every query against suppliers. The application cannot forget it, because the application is no longer the thing enforcing it.

The dangerous default

Here is the part worth internalizing: if app.current_tenant is not set, the query returns empty rather than raising.

That silent behavior is a genuine trap. A background job that runs without tenant context doesn't crash — it quietly finds nothing, and you get a bug report about missing data weeks later instead of a stack trace in the moment.

The mitigation is to make context-setting structural rather than remembered. On each authenticated request, resolve the tenant from the token and set the session variable before any query runs. Then propagate that same context into background jobs through middleware, because jobs execute outside the request cycle where you set it.

Any code path that reaches the database without passing through one of those two places is a path where isolation silently does nothing. Enumerate them deliberately.

What this buys and what it costs

You get isolation that survives application bugs, one migration for all tenants, and infrastructure costs that don't scale with customer count.

You accept that all tenants share physical storage — so a customer who contractually requires physical separation cannot be served by this model without an escape hatch. You also accept that RLS policies are one more thing to test, and that the empty-result default will bite at least once before you build the habit.

For an MVP with compliance requirements and one developer, that trade was clearly correct. For an enterprise deal demanding data residency, it would not be — and the honest version of this decision is knowing which of those futures you're in before you commit.

ArchitecturePostgreSQLMulti-tenancySecurity