Tenants leben im Schema.
Nicht in deinem Code.

Multi-Tenant-SaaS ist der Standardmodus für fast jedes B2B-Produkt — und genau dort schleichen sich die teuersten Bugs ein: die geleakte Zeile, der Cross-Tenant-Write, die Subscription, die die Daten von Kunde A an den Browser von Kunde B gepusht hat. Voltro macht Tenancy zu einer Invariante auf Schema-Ebene — ein Mixin, überall dort per Runtime erzwungen, wo gelesen und geschrieben wird.

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, an sechs Stellen gleichzeitig.

Ein .with(tenant()) am Tabellen-Deskriptor schaltet all das im Gleichschritt ein. Es gibt keinen Pfad, auf dem das eine feuert und das andere nicht — sie sind dieselbe Runtime-Invariante.

tenant()-Schema-Mixin

Eine Zeile am Tabellen-Deskriptor. Fügt eine tenantId-Spalte → tenants-Referenz hinzu, scoped Reads zur Laufzeit, stempelt Writes automatisch und blockiert Cross-Tenant-Zugriffe auf SQL-Ebene.

Tenant-gescopte Subscriptions

Der Reactive-Matcher merged das Tenant-Prädikat per AND in jede Subscription. Writes von Tenant A können Subscriber in Tenant B buchstäblich nicht aufwecken — nicht wegen Polling, sondern wegen des Matching-Algorithmus.

assertOwnTenant()-Write-Guard

Für Mutations, die eine tenantId im Input entgegennehmen, gleicht ein einzeiliger Guard sie per Pattern-Matching gegen das aufgelöste Subject ab. Cross-Tenant-Impersonation zeigt sich als typisierter TenantMismatch-Fehler.

Rate-Limits pro Tenant

rateLimitPlugin scoped Buckets nach Subject ODER Tenant ODER API-Key. Ein einzelner lauter Kunde kann nicht alle anderen aushungern; ein bösartiger Tenant kann dein globales Rate-Limit nicht erschöpfen.

Billing-Meter pro Tenant

Das Entitlement-System von @voltro/plugin-billing zählt Quota pro Tenant, nicht pro Subject. Ein Stripe-Account, ein Quota-Plan, viele Nutzer — alles atomar beim Handler-Eintritt erzwungen.

Observability pro Tenant

OTel-Spans taggen jede Operation mit tenant.id. Traces sind pro Tenant. Logs sind pro Tenant. Das Dashboard zeigt Aufschlüsselungen von Latenz, Durchsatz und Kosten pro Tenant.

Die Runtime macht den AND-Merge für dich.

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.

Wo die Mandantengrenze lebt.

Ein Filter, an den jede Query denken muss
Pro 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.
Ein Mixin, das die Runtime erzwingt
Konstruktionsbedingt
// 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.

Die linke Spalte ist korrekt, bis jemand die Query schreibt, die es vergisst — und die vergessene ist nie die, die jemand reviewt. Die rechte Spalte verschiebt die Entscheidung dorthin, wo eine Query sie nicht überspringen kann: das Prädikat wird in jeden Read gemerged, und es überhaupt zu lockern ist eine ausdrückliche, benannte Operation statt einer Auslassung.

Multi-Tenancy im Detail.

Wo wird die Mandantengrenze tatsächlich durchgesetzt?

In der Runtime, unterhalb deiner Handler und unterhalb jedes Plugins. Das Tenancy-Mixin an einer Tabelle heißt: Lesevorgänge werden durch ein Prädikat gescoped, das die Runtime ins Query merged, und Schreibvorgänge werden aus dem Subject des Aufrufers befüllt — nicht durch eine `where`-Klausel, an die du denkst.

Diese Platzierung ist das ganze Design. Eine Konvention, der du in jeder Query folgst, ist eine vergessene Zeile von einem mandantenübergreifenden Read entfernt — und die vergessene Zeile sieht im Review exakt aus wie die anderen. Ein von der Runtime gemergtes Prädikat kann nicht vergessen werden, weil es keine Stelle pro Query gibt, an der man es vergessen könnte.

Das gilt auch für Plugins. Interceptors laufen nach dem Prädikat-Merge — ein Plugin, ob First-Party oder deins, kann die Zeilen eines anderen Mandanten also nicht durch Umschreiben des Subjects lesen. Die Grenze liegt unter dem Erweiterungspunkt, nicht daneben.

Was passiert, wenn ein Request keinen Mandanten hat?

Er scheitert geschlossen. Ein mandantengescopter Read ohne aufgelösten Mandanten liefert nichts statt alles, und ein Schreibvorgang verweigert, statt unzugeordnet zu landen. Der gefährliche Default wäre der, in dem Abwesenheit „ungescoped“ bedeutet — und genau das ist bewusst nicht das Verhalten.

Wo wirklich eine systemweite Operation gebraucht wird — ein Wartungsjob, ein Admin-Export — läuft sie unter einem ausdrücklichen System-Subject statt durch Weglassen des Mandanten. Der Unterschied zwischen „kein Mandant“ und „alle Mandanten“ ist eine Entscheidung, die jemand getroffen hat und die im Code sichtbar ist.

Diese Unterscheidung hat dieses Framework direkt getroffen. Das Subject eines geplanten Jobs wurde früher von Entwicklungs- und Produktionspfad getrennt erfunden; einer lieferte einen mandanten-gefärbten Fallback, der andere ein mandantenloses Subject — derselbe Cron las lokal einen Mandanten und in Produktion alle, still. Heute reicht die Engine dem Job sein Subject.

Leckt ein Schreibvorgang eines Mandanten über Realtime zu einem anderen?

Nein — das Fan-out ist mandantengescoped. Ein Subscriber wacht nur für Änderungen im eigenen Mandanten auf; ein lauter Mandant drückt also keine Arbeit auf die Sockets aller anderen, und die Existenz fremder Zeilen lässt sich nicht aus dem Update-Timing erschließen.

Zeilen-Sichtbarkeit wird aufgelöst, bevor ein Delta entsteht, statt nachträglich gefiltert. Nachträglich zu filtern hieße, der Server hätte sich bereits entschieden, etwas zu senden, das er nicht hätte senden dürfen — und genau in dieser Entscheidung verstecken sich Fehler.

Caching folgt demselben Scoping. Ein gecachtes Query-Ergebnis wird vom Change-Stream invalidiert, der den Mandanten trägt — der Schreibvorgang eines Mandanten kann einem anderen also keine veraltete oder, schlimmer, fremde gecachte Liste ausliefern. Dasselbe gilt für Rate-Limits, Audit-Einträge und Hintergrundarbeit: jedes ist nach dem Mandanten geschlüsselt, dem es gehört — denn ein über alle Mandanten geteilter Zähler beantwortet eine Frage, die kein mandantenfähiges Produkt tatsächlich hat.

Was, wenn Mandanten physisch getrennt sein müssen?

Datenresidenz ist deklarierbar. Konfigurier die Regionen, die du bedienst, und das Framework öffnet einen Store pro Region und routet jeden Request in die Heimatregion seines Mandanten — oder lehnt ihn ab. Eine rechtliche Anforderung wird damit zur Boot-Konfiguration statt zu einer Review-Checkliste, an die sich jemand erinnern muss.

Zwischen den Extremen einer geteilten Tabelle und einer Datenbank pro Kunde hält der Mixin-Ansatz den Normalfall billig: geteilte Tabellen mit erzwungenem Prädikat, was fast jedes Produkt braucht — ohne die Option aufzugeben, die Kunden zu isolieren, die es vertraglich verlangen.

Mitgliedschaften und Mandantenwechsel gehören zur Auth-Suite, statt dass du sie selbst modellierst. Ein Nutzer kann mehreren Mandanten angehören, und ein Wechsel erzeugt ein neues, auf den gewählten Mandanten gescoptes Subject — dieselbe Subject-Form, die jeder andere Teil des Frameworks ohnehin liest, nachgelagert muss also niemand vom Wechsel wissen. Einladungen sind adressiert, einmalig nutzbar und tragen die vom Einladenden gewählte Rolle; das hält den Beitrittspfad davon ab, ein zweiter, schwächerer Weg zur Rechtevergabe zu werden.

Wo die Grenze durchgesetzt wird.

Mandanten-Belange und wo jeder behandelt wird
BelangWie er behandelt wird
Lese-ScopingEin Prädikat, das die Runtime in jede Query auf eine mandantengescopte Tabelle merged — keine where-Klausel von dir.
Schreib-ZuordnungAus dem Subject des Aufrufers befüllt; eine Abweichung ist ein getypter Fehler statt einer still umgehängten Zeile.
Fehlender MandantScheitert geschlossen — nichts wird zurückgegeben, Schreibvorgänge verweigern, statt ungescoped zu laufen.
RealtimeFan-out pro Mandant gescoped; Zeilen-Sichtbarkeit aufgelöst, bevor ein Delta entsteht.
PluginsInterceptors laufen nach dem Prädikat-Merge — kein Plugin kann über die Grenze lesen.
ResidenzDeklarierte Regionen öffnen je einen Store; ein Request wird in die Heimatregion geroutet oder abgelehnt.

Häufige Fragen

Brauche ich eine Datenbank pro Mandant?

Nein, und für die meisten Produkte solltest du keine wollen. Geteilte Tabellen mit einem von der Runtime erzwungenen Prädikat sind der billige Normalfall; physische Trennung bleibt über deklarierte Residenz für die Kunden verfügbar, deren Verträge sie verlangen.

Was hindert mich daran, den Mandantenfilter zu vergessen?

Es gibt keine Stelle, ihn zu vergessen. Das Prädikat wird von der Runtime in Queries auf mandantengescopte Tabellen gemerged, unterhalb deines Handlers und unterhalb jedes Plugin-Interceptors — es ist also keine Codezeile, die in einer von zweihundert Queries fehlen kann.

Kann ein Nutzer mehreren Organisationen angehören?

Ja. Mitgliedschaften und ein Switch-Tenant-Flow gehören zur Auth-Suite, und ein Wechsel erzeugt ein neues, auf den gewählten Mandanten gescoptes Subject — dieselbe Form, die alles andere ohnehin liest, nachgelagert muss also niemand vom Wechsel wissen.

Wie werden Hintergrundjobs gescoped?

Ausdrücklich. Ein Job hat keinen Aufrufer, sein Subject wird ihm also von der Engine gereicht statt von dem Codepfad erfunden, der ihn gestartet hat — genau weil zwei erfindende Pfade dazu geführt haben, dass derselbe Cron in der Entwicklung einen Mandanten und in Produktion alle las.

Kann ich Mandantendaten in verschiedenen Regionen speichern?

Ja, durch Deklaration der Residenz. Das Framework öffnet einen Store pro bedienbarer Region und routet jeden Request in die Heimatregion seines Mandanten — und lehnt die ab, die es dort nicht bedienen kann. Die Zusicherung wird also beim Boot durchgesetzt statt in einem Dokument versprochen.

Cross-Tenant-Bugs sind auf einzigartige Weise fatal.

Ein Bug in der Warenkorbsumme ist peinlich. Ein Bug, der Tenant B die Daten von Tenant A zeigt, ist ein Compliance-Vorfall, eine E-Mail ans Board und ein Kunde, der vor deinem VP of Customer Success steht. Voltro nimmt das Muster "pro Projekt von Hand gebaut" vom Tisch — es gibt nichts zu vergessen.

Zwei Fehlermodi, die Voltro beseitigt:

  • Fehlende WHERE-Klausel. Eine Custom-Query in irgendeinem vergessenen Handler filtert nicht nach Tenant. Voltro: Die Runtime merged das Prädikat zur Ausführungszeit per AND. Unmöglich zu vergessen.
  • Blind vertrauter Input. Ein Handler reicht die vom Client gelieferte tenantId direkt an die DB durch. Voltro: assertOwnTenant weist Mismatches mit einem typisierten Fehler ab, bevor der Write landet.

Tenancy zieht sich durch jedes andere Primitiv.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.