Every multi-tenant application has the same latent failure: one query somewhere that forgets to scope by tenant, and one customer sees another's data. The architecture you pick changes how likely that is. Nothing changes it as much as whether isolation is the default or something a developer has to remember.
Row-level. One database, one schema, a
tenant_id on every table. Simplest to operate, cheapest
to run, easiest to report across. Isolation is entirely a property of
your queries, which is the whole risk.
Schema-per-tenant. One database, a schema per tenant, connection switched per request. Isolation is enforced by the database rather than by your code. Migrations now run per schema, which is fine at ten tenants and a genuine operational problem at five thousand.
Database-per-tenant. Strongest isolation, easiest story for a customer who asks where their data lives, easiest per-tenant restore. Most expensive, most operationally involved, and cross-tenant reporting becomes its own project.
For most products row-level is right, and the rest of this is about making it safe. Move up only when a customer contract or a regulator requires it - and when they do, that requirement usually arrives with budget attached.
The failure mode is never the query someone thought about. It is the admin report written in a hurry, the background job that runs outside a request, the console session, the CSV export added on a Friday.
So the rule is: unscoped access should require deliberate effort and be visible in review. A default scope applied at the model layer, driven by a request-scoped current-tenant value, gets you most of the way. Any code that needs to cross tenants has to say so explicitly, and that explicit call is greppable - which means it is auditable.
Then add a test that fails when a model without tenant scoping is introduced. It is a handful of lines, and it catches the class of mistake that code review reliably misses.
Requests carry tenant context naturally. Jobs do not. A job enqueued during a request and executed twenty minutes later on another machine has no idea who it belongs to unless you told it.
Pass the tenant explicitly in the job arguments and re-establish
context at the start of perform. Never rely on ambient
state surviving the queue. And be careful with jobs that retry - the
retry must reconstruct the same context, not whatever happens to be
current.
The related trap is the job that legitimately spans tenants, like a nightly billing run. Those should iterate tenants explicitly and set context per iteration, rather than running unscoped and filtering afterwards. If it runs unscoped, one bad condition leaks everything.
Data isolation is the part people think about. These are the parts that produce the incident:
Tenant isolation is not a database property. It is a property of every system that holds tenant data, and most applications hold it in six or seven places.
The test that matters is not "tenant A sees their own record". It is "tenant A cannot see tenant B's record", written for every access path - controller, API, export, search, job, admin.
Seed two tenants in fixtures as a matter of course. Then any test that accidentally relies on there being only one tenant fails immediately, which is exactly the signal you want. The cost is trivial and it makes an entire category of bug hard to write.
We have built multi-tenant platforms across warehousing, B2B commerce, loyalty and rewards, including white-label deployments with per-tenant configuration. If you are choosing an approach or auditing an existing one, we can help.