Multi-tenancy decisions you cannot easily reverse
Three architectural choices in a SaaS build that are cheap on day one and extremely expensive to revisit in year two.
Sarah Whitfield
Principal Engineer · 18 March 2026
Most SaaS architecture is reversible. You can swap a queue, replace a frontend framework, move between clouds — painful, but tractable.
A few decisions are not like that. They get baked into every query, every migration and every integration, and unwinding them means touching the entire codebase. Here are the three we spend the most time on before writing code.
1. Where the tenant boundary lives
The options, roughly in order of isolation:
- Shared tables with a
tenant_idcolumn and row-level security enforcing it - Schema per tenant in a shared database
- Database per tenant
Shared tables with row-level security is right for most products. Operational cost stays flat as you add customers, migrations run once, and Postgres RLS enforces the boundary at the database rather than trusting every developer to remember a WHERE clause.
The failure mode is enterprise sales. Eventually a large customer's security review demands physical isolation, and if your data access layer assumes a shared table you are looking at a rewrite under deadline pressure.
The fix is cheap if you do it early: route every query through a tenant context abstraction rather than scattering tenant_id filters through the code. Then moving one tenant to an isolated database is a deployment concern, not an application rewrite.
2. Whether identifiers are globally unique
Sequential integer primary keys scoped per tenant look tidy until the first time you need to merge two tenants, migrate a customer between environments, or debug a support ticket where someone pasted an ID with no context.
Use globally unique identifiers. UUIDv7 gives you uniqueness and rough time ordering, which keeps index locality reasonable — the historical objection to UUID keys.
The cost is a few bytes per row. The saving is not having a conversation about which tenant's invoice number 4051 someone means.
3. Whether entitlements are code or data
The tempting version:
if (subscription.plan === "pro") {
showAdvancedReporting();
}
This works precisely until sales agrees a custom arrangement, and then the plan names multiply, the conditionals nest, and every commercial experiment needs an engineering release.
Model entitlements as data instead — features a plan grants, plus per-tenant overrides:
if (await entitlements.has(tenant, "advanced_reporting")) {
showAdvancedReporting();
}
Now pricing changes are configuration. Sales can agree a bespoke package on a Friday without an engineer being involved, and you can run pricing experiments without touching the deployment pipeline.
The pattern underneath
All three are the same idea: put an abstraction at the point where a commercial decision meets the code, before you know what that decision will be.
You are not building the flexible thing. You are building the simple thing behind a boundary, so that when the requirement changes — and on a product that succeeds, it always does — the change is contained.
That boundary costs perhaps a day during the first sprint. We have watched teams spend a quarter recovering from not having it.