Engineering
Multi-Tenant SaaS Architecture: Where to Put the Tenant Boundary
By Clasify Team · · 6 min read
Every multi-tenant SaaS starts with the same well-meaning plan: put a companyId column on the tables that need it, and remember to filter by it. That plan works right up until the day somebody forgets — and the failure mode is not a crash. It is one customer quietly reading another customer's data, in a query that returns 200 and looks entirely normal in the logs.
We run Clasify, a white-label platform where every academy is a separate tenant with its own branding and its own data. This is how we drew the tenant boundary, the thing we got wrong, and the test that now stops us getting it wrong again.
"Remember the WHERE clause" is not an architecture
Filtering by hand fails open. A missing companyId condition raises no error; it just widens the result set. Code review does not reliably catch it either, because the wrong query and the right query differ by one clause that is very easy to read past.
So the useful question is not "how do we remember?" It is "where do we put the boundary so that forgetting is impossible?"
There are three usual answers: a database per tenant, a schema per tenant, or one shared schema with a discriminator column. We chose the third — and then moved enforcement out of the application code entirely.
The boundary lives in the data layer, not the routes
Two pieces do the work.
The first is a request-scoped context built on Node's AsyncLocalStorage. A middleware opens a store for the lifetime of each request, and the tenant and auth middleware fill in the resolved company id as they work out who is calling. Because the store is async-local, any function in that request — however deep in the call stack — can read the current tenant without it being threaded through as an argument.
The second is a Prisma client extension that wraps every query. Before a read reaches the database, it checks the model against a list of tenant-owned models and, when it matches, injects the current tenant's id into the where clause.
The result is that a route which forgets to filter still issues a filtered query. The data layer stops trusting its callers.
Fail closed, in every environment
The interesting decision is not the injection. It is what happens when there is no tenant to inject.
A scoped query running inside a request with no resolved tenant and no explicit override throws. Not a warning, and not a soft fallback in development: it throws in development exactly as it does in production.
That last part matters more than it sounds. A guard that warns locally and throws in production is a guard you meet for the first time in production. Making both environments behave identically means the wall gets discovered on a laptop, by the person who just wrote the query.
Three states are legal, and they are deliberately distinct:
- No store at all — a background job, a script, startup code. Not a client request, so nothing is auto-scoped and the caller must pass the company id explicitly.
- A store with a tenant — the ordinary request path. Scoped automatically.
- An explicit bypass — trusted cross-tenant access for super-admin and system work. Opt-in, by name, at the call site.
A guard that warns in development and throws in production is a guard you meet for the first time in production.
The allowlist is the part that rots
Every mechanism like this has a soft spot, and ours is the list itself. Auto-scoping only protects the models on it. A new table carrying a company id that nobody adds to the list is not protected — and it fails open, silently, in exactly the way the hand-written filter did.
That is not hypothetical. A tenant-isolation review of our own code found a ratings table that carried a company id and appeared on neither list. It had been written, it had been reviewed, and it was quietly unscoped.
The fix was not to be more careful. It was a test that reads the Prisma schema and the source of both lists, finds every model with a company id column, and asserts that each one is either auto-scoped or explicitly excluded with a documented reason. A new tenant-owned table that touches neither list now fails at commit time.
It parses the source rather than importing it, so it needs no database and no generated client to run. A guard that is slow or awkward to run is a guard people start skipping.
Today that is 48 models, 37 of which carry a company id: 24 auto-scoped, 13 explicitly excluded, and none unaccounted for. The number that matters is the last one.
The exclusions are where the real design lives
An exclusion list sounds like a list of holes. In practice it is where you find out what your tenancy model actually is, because every entry is a case where "scope it to the current tenant" is the wrong answer.
Some are obvious once written down. Users are looked up by email at login, before any tenant is known — scoping that lookup would make signing in impossible. The custom-domain table is what resolves the tenant in the first place, so it cannot itself require one.
Others were genuinely surprising. Registered mobile push devices carry a company id, so the reflex is to scope them. But the match key when registering a device is the push token, and that is deliberately global: the same physical phone can belong to a teacher at one academy, then be logged out and logged back in as somebody at a different academy on the same app. Auto-scoping that lookup would inject the caller's tenant into it, miss the existing row, and collide with the token's unique constraint. So registration stays global on purpose, and every other method on that repository scopes explicitly instead.
That is the kind of thing you only learn by running the system. It is also why each exclusion carries a written reason rather than just a name — the reason is what stops someone "fixing" it six months later.
When you actually need this
Not every product does. If tenants are large, few, and want their data physically separated — often for regulatory reasons — a database per tenant is easier to explain, and easier to back up and restore one customer at a time. The cost is migrations and connection management multiplied by your customer count.
One shared schema with an enforced boundary makes the opposite trade: a single migration, a single connection pool, a single deployment, and the entire safety of the model resting on code you have to get right once and then defend with tests.
We built ours this way because our tenants are numerous and small, where per-tenant infrastructure would dominate the cost of running the product.
If you are making that choice now, the useful question is not which pattern is best in the abstract. It is: when somebody forgets, what happens? Design it so the answer is "an exception" and not "a wider result set".
This is the kind of work we do for other teams as SaaS development; how we built Clasify covers the rest of the architecture.
All articles