Tenants live in the schema.
Not in your code.

Multi-tenant SaaS is the default mode for almost every B2B product, and it's the place teams introduce their most expensive bugs: the leaked row, the cross-tenant write, the subscription that pushed customer A's data to customer B's browser. Voltro makes tenancy a schema-level invariant — one mixin, runtime-enforced everywhere reads and writes go.

projects.entity.ts
TypeScript
// database/projects.entity.ts
import { id, table, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'

export const projects = table('projects', {
  id:   id(),
  name: text(),
})
  .with(tenant())   // ← that's it
  .reactive()

// Now EVERY read against this table is auto-filtered by
// ctx.request.subject.tenantId. EVERY insert auto-stamps it.
// EVERY subscription is auto-scoped. Cross-tenant writes throw
// TenantScopeViolation before they hit the DB.

Tenancy enforcement, six places at once.

Adding .with(tenant()) to a table descriptor turns on all of these in lock-step. There's no path where one fires and the other doesn't — they're the same runtime invariant.

tenant() schema mixin

One line on the table descriptor. Adds tenantId column → tenants reference, runtime-scopes reads, auto-stamps writes, blocks cross-tenant access at the SQL layer.

Tenant-scoped subscriptions

The reactive matcher AND-merges the tenant predicate into every subscription. Writes from tenant A literally cannot wake up subscribers in tenant B — not because of polling, because of the matching algorithm.

assertOwnTenant() write guard

For mutations that take a tenantId in the input, a one-line guard pattern-matches it against the resolved Subject. Cross-tenant impersonation surfaces as a typed TenantMismatch error.

Per-tenant rate limits

rateLimitPlugin scopes buckets by subject OR by tenant OR by API key. A single noisy customer can't starve everyone else; a malicious tenant can't exhaust your global rate limit.

Per-tenant billing meters

The @voltro/plugin-billing entitlement system meters quota per tenant, not per subject. One Stripe account, one quota plan, many users — all enforced atomically at handler entry.

Per-tenant observability

OTel spans tag every operation with tenant.id. Traces are per-tenant. Logs are per-tenant. The dashboard surfaces per-tenant breakdowns of latency, throughput, and cost.

The runtime does the AND-merge for you.

projects.list.query.server.ts
TypeScript
// queries/projects.list.query.server.ts
const route = (_, ctx) =>
  database.projects.orderBy('createdAt', 'desc')
//                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// The runtime AND-merges eq('tenantId', subject.tenantId)
// into this query at execution time. You wrote 0 lines of
// tenant code. The wire payload to the client is filtered
// before it leaves the server.

Where the tenant boundary lives.

A filter every query has to remember
Per query
// Every read. Every single one.
const orders = await db.orders.where({ tenantId: ctx.tenantId })
const items  = await db.items.where({ tenantId: ctx.tenantId })

// The one that ships the incident:
const report = await db.orders.where({ status: 'open' })
//                                     ^ no tenant. Compiles. Passes review.
//                                       Returns every customer's rows.
A mixin the runtime enforces
By construction
// database/schema.ts — declared once
export const orders = table('orders', {
  status: text(),
}).with(tenant())

// Any read, anywhere:
const report = await database.orders.where({ status: 'open' })
// The tenant predicate is merged in by the runtime. There is no
// spelling of this query that returns another tenant's rows.

The left column is correct until someone writes the query that forgets — and the forgotten one is never the query anyone reviews. The right column moves the decision to a place a query cannot skip: the predicate is merged into every read, and relaxing it at all is an explicit, named operation rather than an omission.

Multi-tenancy, in depth.

Where is the tenant boundary actually enforced?

In the runtime, beneath your handlers and beneath any plugin. Adding the tenancy mixin to a table means reads are scoped by a predicate the runtime merges into the query, and writes are filled from the caller's subject — not by a `where` clause you remember to add.

That placement is the whole design. A convention you follow in every query is one forgotten line away from a cross-tenant read, and the forgotten line looks exactly like the others in review. A predicate merged by the runtime cannot be forgotten, because there is no per-query place to forget it.

It also holds for plugins. Interceptors run after the predicate merge, so a plugin — first-party or yours — cannot read another tenant's rows by rewriting the subject. The boundary is below the extension point rather than beside it.

What happens when a request has no tenant?

It fails closed. A tenant-scoped read with no resolved tenant returns nothing rather than everything, and a write refuses rather than landing unattributed. The dangerous default is the one where absence means "unscoped", and that is deliberately not the behaviour.

Where a genuinely system-wide operation is needed — a maintenance job, an admin export — it runs under an explicit system subject rather than by omitting the tenant. The difference between "no tenant" and "all tenants" is a decision somebody made, visible in the code.

That distinction has bitten this framework directly. A scheduled job's subject used to be invented separately by the development and production boot paths; one supplied a tenant-flavoured fallback and the other a tenant-less subject, so the same cron read one tenant locally and every tenant in production, silently. The engine hands the subject to the job now.

Does a write from one tenant leak to another through real-time?

No — fan-out is tenant-scoped. A subscriber only wakes for changes inside its own tenant, so a busy tenant does not push work onto every other tenant's sockets, and the existence of another tenant's rows cannot be inferred from update timing.

Row visibility is resolved before a delta is produced rather than filtered afterwards. Filtering after the fact would mean the server had already decided to send something it should not have, and that decision is exactly where mistakes hide.

Caching follows the same scoping. A cached query result is invalidated by the change stream that carries the tenant, so one tenant's write cannot serve another tenant a stale — or worse, a foreign — cached list. The same applies to rate limits, audit records and background work: each is keyed by the tenant it belongs to, because a shared counter across tenants answers a question no multi-tenant product actually has.

What if tenants must be physically separated?

Data residency is declarable. Configure the regions you serve and the framework opens one store per region, routing each request to its tenant's home region — or refusing it. A legal requirement becomes a boot-time configuration rather than a review checklist somebody has to remember.

Between the extremes of one shared table and one database per customer, the mixin approach keeps the common case cheap: shared tables with an enforced predicate, which is what almost every product needs, without giving up the option to isolate the customers who contractually require it.

Memberships and tenant switching are part of the auth suite rather than something you model yourself. A user can belong to several tenants, and switching produces a new subject scoped to the chosen one — the same subject shape every other part of the framework already reads, so nothing downstream needs to know a switch happened. Invitations are addressed, single-use and carry the role the inviter chose, which keeps the join path from becoming a second, weaker way to grant access.

Where the boundary is enforced.

Tenancy concerns and where each is handled
ConcernHow it is handled
Read scopingA predicate the runtime merges into every query on a tenant-scoped table — not a where clause you add.
Write attributionFilled from the caller's subject; a mismatch is a typed error rather than a silently reassigned row.
Absent tenantFails closed — nothing is returned and writes refuse, rather than defaulting to unscoped.
Real-timeFan-out scoped per tenant; row visibility resolved before a delta is produced.
PluginsInterceptors run after the predicate merge, so no plugin can read across the boundary.
ResidencyDeclared regions open one store each; a request is routed to its tenant's home region or refused.

Frequently asked questions

Do I need a database per tenant?

No, and for most products you should not want one. Shared tables with a runtime-enforced predicate is the cheap common case; physical separation stays available through declared residency for the customers whose contracts require it.

What stops me forgetting the tenant filter?

There is nowhere to forget it. The predicate is merged by the runtime into queries on tenant-scoped tables, beneath your handler and beneath any plugin interceptor — so it is not a line of code that can be omitted in one query out of two hundred.

Can a user belong to several organisations?

Yes. Memberships and a switch-tenant flow are part of the auth suite, and switching produces a new subject scoped to the chosen tenant — the same shape everything else already reads, so nothing downstream needs to know a switch happened.

How are background jobs scoped?

Explicitly. A job has no caller, so its subject is handed to it by the engine rather than invented by whichever code path started it — precisely because two paths inventing one is how the same cron once read a single tenant in development and every tenant in production.

Can I store tenant data in different regions?

Yes, by declaring residency. The framework opens a store per servable region and routes each request to its tenant's home region, refusing the ones it cannot serve there — so the guarantee is enforced at boot rather than promised in a document.

Cross-tenant bugs are uniquely terrible.

A bug in the cart total is embarrassing. A bug that shows tenant A's data to tenant B is a compliance incident, a board email, and a customer in front of your VP of customer success. Voltro takes the "hand-rolled per project" pattern off the table — there's nothing to forget.

Two failure modes Voltro removes:

  • Missing WHERE clause. A custom query in some forgotten handler doesn't filter by tenant. Voltro: the runtime AND-merges the predicate at execution time. No way to forget.
  • Trusted input. A handler passes the client-supplied tenantId straight to the DB. Voltro: assertOwnTenant rejects mismatches with a typed error before the write lands.

Tenancy weaves through every other primitive.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.