Wie man ein Multi-Tenant-SaaS-Backend strukturiert
Ein praktischer Leitfaden zur Multi-Tenant-SaaS-Architektur — von Datenbank-Isolationsstrategien bis hin zu Auth, Billing und Feature Flags.

In dem Moment, in dem Sie entscheiden, dass Ihre App mehr als eine Organisation bedienen soll, verzweigt sich jede architektonische Entscheidung. Datenbankdesign, Authentifizierung, Autorisierung, Billing, sogar Logging — alles traegt nun die Frage: fuer welchen Tenant?
Ich habe Multi-Tenant-Backends in sehr unterschiedlichen Domaenen ausgeliefert. Die Muster, die die Produktion ueberleben, sind selten die, die auf einem Whiteboard am saubersten aussehen. Dieser Beitrag behandelt die Entscheidungen, die tatsaechlich zaehlen, die Kompromisse, vor denen niemand warnt, und konkrete Implementierungsmuster, die Sie uebernehmen koennen.
Strategien zur Datenbank-Isolation
Das ist die erste und folgenreichste Entscheidung. Wenn Sie sie falsch treffen, steht Ihnen eine Migration bevor, die jede Query in Ihrer Codebasis betrifft.
Es gibt drei Hauptansaetze:
| Strategie | Isolation | Komplexitaet | Kosten | Geeignet fuer |
|---|---|---|---|---|
| Gemeinsame DB, gemeinsames Schema | Niedrig | Niedrig | Am niedrigsten | Fruehphasen-SaaS, < 100 Tenants |
| Gemeinsame DB, Schema pro Tenant | Mittel | Mittel | Mittel | Mittlere Groesse, regulierte Branchen |
| Datenbank pro Tenant | Hoch | Hoch | Am hoechsten | Enterprise-Kunden, strenge Compliance |
Gemeinsame Datenbank, gemeinsames Schema
Alle Daten aller Tenants liegen in denselben Tabellen. Sie unterscheiden Zeilen durch eine tenant_id-Spalte. Hier sollten die meisten SaaS-Produkte starten.
Der Vorteil ist Einfachheit. Ein Migrationspfad, ein Connection Pool, einfache Queries. Der Nachteil ist, dass eine fehlende WHERE tenant_id = ?-Klausel ein Datenleck bedeutet. Es gibt kein Sicherheitsnetz auf Infrastrukturebene — die Isolation liegt vollstaendig in Ihrem Anwendungscode.
Dieser Ansatz funktioniert, bis die Queries eines grossen Tenants die Performance fuer alle anderen verschlechtert, oder bis das Compliance-Team eines Enterprise-Kunden fragt, wie seine Daten physisch von denen der Konkurrenz getrennt sind. An diesem Punkt steigen Sie auf.
Schema pro Tenant
Jeder Tenant bekommt sein eigenes PostgreSQL-Schema (oder Aequivalent) innerhalb einer gemeinsamen Datenbankinstanz. Die Tabellen sind schemauebergreifend identisch, aber physisch getrennt. Sie wechseln den Search Path bei der Verbindung.
Das gibt Ihnen echte Isolation, ohne Dutzende von Datenbankinstanzen zu verwalten. Migrationen sind aufwendiger — Sie fuehren sie N-mal aus — aber Tools wie node-pg-migrate oder Prismas Multi-Schema-Unterstuetzung handhaben das recht gut.
Der Haken: Connection Pooling wird knifflig. Wenn Sie PgBouncer verwenden, muessen Sie bewusst damit umgehen, wie das Schema-Switching mit gepoolten Verbindungen interagiert. Ich habe gesehen, wie dies subtile Bugs verursacht hat, bei denen Tenant A kurzzeitig das Schema von Tenant B liest, weil eine Verbindung mitten in einer Transaktion zum Pool zurueckgegeben wurde.
Datenbank pro Tenant
Maximale Isolation. Jeder Tenant hat eine dedizierte Datenbankinstanz (oder zumindest eine dedizierte logische Datenbank). Sie routen Verbindungen dynamisch basierend auf dem Tenant-Kontext.
Das ist teuer und operativ aufwendig, aber einige Kunden werden dafuer bezahlen. Gesundheitswesen und Finanzdienstleistungen erfordern es oft. Der Hauptvorteil ueber die Isolation hinaus ist, dass Sie Tenants unabhaengig skalieren, sichern und wiederherstellen koennen. Ein lauter Nachbar kann Ihre gemeinsame Instanz nicht zum Absturz bringen.
In der Praxis betreiben die meisten Teams ein Hybrid: gemeinsame DB fuer die Mehrheit, dedizierte Instanzen fuer Enterprise-Konten, die dafuer bezahlen.
Authentifizierung und Tenant-Aufloesung
Jede eingehende Anfrage muss zwei Fragen beantworten: Wer ist dieser Benutzer? und Zu welchem Tenant gehoert er?
Es gibt drei gaengige Strategien zur Aufloesung des Tenant-Kontexts:
Subdomain-basiert: acme.ihreapp.com wird dem Acme-Tenant zugeordnet. Sauber, intuitiv, funktioniert hervorragend fuer B2B-Produkte, bei denen jeder Kunde seinen eigenen Workspace bekommt. Sie extrahieren den Tenant-Slug aus dem Host-Header.
JWT-Claim-basiert: Die Tenant-ID ist beim Login in das Access Token eingebettet. Kein infrastrukturseitiges Routing noetig. Funktioniert gut fuer Mobile Apps und SPAs, wo Subdomain-Routing unpraktisch ist.
Header-basiert: Der Client sendet einen X-Tenant-ID-Header. Einfach, aber gefaehrlich, wenn nicht gegen die Berechtigungen des authentifizierten Benutzers validiert. Verwenden Sie dies nur fuer interne Service-zu-Service-Aufrufe.
In den meisten Systemen kombiniere ich Subdomain-Aufloesung fuer den initialen Login-Flow mit JWT-Claims fuer nachfolgende API-Aufrufe. So sieht die Middleware aus:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
Mounten Sie dies vor Ihren Route-Handlern und jede nachgelagerte Funktion hat Zugriff auf req.tenantId. Der Rest Ihrer Anwendung muss nie darueber nachdenken, wie die Tenant-Aufloesung funktioniert — er liest einfach die ID aus dem Request-Kontext.
Scoping jeder Datenbankabfrage
Sobald Sie bei jeder Anfrage einen Tenant-Kontext haben, muessen Sie garantieren, dass er auf jede Query angewendet wird. Ein fehlendes Scoping ist ein Datenleck. Das ist nicht die Art von Sache, die man der Disziplin der Entwickler ueberlaesst.
Das beste Muster, das ich gefunden habe, ist eine Repository-Schicht, die Scoping automatisch erzwingt:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
In Ihren Route-Handlern konstruieren Sie niemals rohe Queries. Sie gehen immer ueber das gescopte Interface:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
Wenn Sie ein ORM wie Prisma verwenden, koennen Sie dasselbe mit Middleware erreichen, die den tenant_id-Filter in jeden findMany-, findFirst-, update- und delete-Aufruf injiziert. Der Punkt ist, den unsicheren Pfad schwieriger zu machen als den sicheren.
Rollenbasierte Zugriffskontrolle innerhalb von Tenants
Multi-Tenancy fuehrt ein zweischichtiges Autorisierungsmodell ein. Erstens: Gehoert dieser Benutzer zu diesem Tenant? Zweitens: Was kann er innerhalb des Tenants tun?
Ich halte das einfach mit einer tenant_memberships-Tabelle:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
Rollen sind typischerweise owner, admin, member und manchmal viewer oder eine domaenenspezifische Rolle. Vermeiden Sie es, eine vollstaendige Berechtigungsmatrix zu bauen, es sei denn, Ihr Produkt braucht sie wirklich — die meisten B2B-SaaS kommen mit 3-4 Rollen gut zurecht.
Die Autorisierungspruefung erfolgt nach der Tenant-Aufloesung:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
Eine Designentscheidung, die spaeter Kopfschmerzen spart: Erlauben Sie immer, dass ein Benutzer zu mehreren Tenants gehoeren kann. Auch wenn Ihr Produkt heute nur einen Workspace hat, brauchen Sie das in dem Moment, in dem Sie Agentur-Konten, White-Label-Reseller oder einen Berater-Modus hinzufuegen.
Feature Flags pro Tenant
Nicht jeder Tenant sollte jedes Feature sehen. Manchmal gaten Sie nach Plan-Stufe. Manchmal fuehren Sie ein schrittweises Rollout durch. Manchmal bezahlt ein Enterprise-Kunde fuer eine individuelle Faehigkeit.
Ich verwende eine einfache tenant_features-Tabelle statt eines Drittanbieter-Feature-Flag-Services. Fuer die meisten SaaS-Produkte ist das mehr als genug:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
In Route-Handlern:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
Wenn ein Tenant seinen Plan upgradet, schalten Sie die relevanten Feature-Zeilen um und invalidieren den Cache. Einfach, pruefbar und ohne externe Abhaengigkeiten.
Billing-Integration
Stripe ist hier aus gutem Grund der Standard. Die Zuordnung ist natuerlich: ein Stripe Customer pro Tenant, eine Subscription pro Tenant, Plan-Stufen werden auf Products und Prices abgebildet.
Die entscheidende Designregel: Speichern Sie die Stripe Customer ID am Tenant-Datensatz, nicht am Benutzer-Datensatz. Billing gehoert zur Organisation, nicht zur Person, die die Kreditkarte eingegeben hat.
Der Webhook-Flow sieht so aus:
checkout.session.completed— verknuepfe die Stripe Customer ID mit dem Tenant, aktiviere sein Abonnement.invoice.paid— verlaengere den Abrechnungszeitraum, aktualisieretenant.paidUntil.invoice.payment_failed— markiere den Tenant als ueberfaellig, sende Mahnungs-E-Mails.customer.subscription.updated— synchronisiere Plan-Stufen-Aenderungen, aktualisiere Feature Flags entsprechend.customer.subscription.deleted— downgrade auf Free-Tier oder suspendiere.
Der Schluessel ist Idempotenz. Stripe kann und wird denselben Webhook mehrfach senden. Jeder Handler sollte sicher zweimal mit dem gleichen Event-Payload ausgefuehrt werden koennen.
Datenisolation und Sicherheit
Ueber das Query-Scoping hinaus gibt es einige nicht offensichtliche Dinge, die Sie frueh richtig machen sollten:
Row-Level Security (RLS): Wenn Sie auf PostgreSQL sind, aktivieren Sie RLS als zweite Verteidigungslinie. Selbst wenn Ihr Anwendungscode einen Bug hat, wird die Datenbank selbst sich weigern, Zeilen zurueckzugeben, die nicht zum aktuellen Tenant-Kontext passen. Setzen Sie app.current_tenant bei der Verbindung und schreiben Sie Policies dagegen.
Logging-Kontext: Jede Logzeile sollte tenantId enthalten. Wenn um 3 Uhr morgens etwas kaputtgeht, muessen Sie wissen, welcher Tenant betroffen ist, ohne durch Request Traces zu graben. Strukturiertes Logging mit einem in den Logger-Kontext eingebauten Tenant-Feld macht das automatisch.
Backups und Datenloeschung: Tenants werden abwandern, und einige werden DSGVO- oder Datenloeschungsrechte geltend machen. Wenn alle Ihre Daten in gemeinsamen Tabellen liegen, bedeutet das Bereinigen eines einzelnen Tenants sorgfaeltig erstellte DELETE-Statements ueber jede Tabelle. Mit Schema- oder Datenbank-pro-Tenant ist es ein DROP SCHEMA oder DROP DATABASE. Planen Sie das von Anfang an ein.
Rate Limiting pro Tenant: Ein globaler Rate Limiter reicht nicht. Der Batch-Import eines Tenants sollte nicht das Rate-Budget eines anderen Tenants auffressen. Scopen Sie Ihren Rate Limiter nach tenantId, nicht nur nach IP oder Benutzer.
Verschluesselung im Ruhezustand: Fuer sensitive Branchen verschluesseln Sie Tenant-Daten im Ruhezustand mit pro-Tenant-Schluesseln. Das fuegt Komplexitaet zum Schluesselmanagement hinzu, aber es bedeutet, dass das Widerrufen des Zugangs eines Tenants so einfach ist wie das Zerstoeren seines Schluessels. Keine Daten bleiben nach dem Offboarding lesbar auf der Festplatte.
Abschliessende Gedanken
Multi-Tenancy ist keine Bibliothek, die man installiert. Es ist eine Reihe architektonischer Entscheidungen, die jede Schicht Ihres Stacks beruehren, von der Datenbank bis zum CDN. Die gute Nachricht ist, dass die Muster gut verstanden sind. Der knifflige Teil ist, sie mit dem richtigen Isolationsgrad fuer Ihren spezifischen Markt und Ihre Phase anzuwenden.
Beginnen Sie mit Shared-Everything und einer disziplinierten Scoping-Schicht. Fuegen Sie Isolationsgrenzen hinzu, wenn Ihre Kunden sie verlangen. Ueberengineeren Sie nicht fuer Compliance-Anforderungen, die Sie noch nicht haben, aber stellen Sie sicher, dass sich Ihre Architektur in Richtung strengerer Isolation weiterentwickeln kann, ohne ein Rewrite zu erfordern.
Ich habe Multi-Tenant-Systeme von Restaurantplattformen bis zu medizinischem SaaS gebaut — jedes mit seinen eigenen Isolations- und Skalierungsanforderungen. Die Muster in diesem Beitrag sind diejenigen, die sich in allen bewaehrt haben.
Verwandte Projekte
RestoHub
Restaurants verlieren keine 30 % mehr an Uber Eats — sie bekommen ihr eigenes Bestell-, Menü-, Website- und Treueprogramm-System auf einer Plattform. Vollwertiges Uber-Eats-Erlebnis, aber das Restaurant behält jeden Cent.
TakeCare
Eine Pflegekraft überwacht jetzt 250 Patienten aus der Ferne — anstelle von manuellen Telefonaten und Hausbesuchen in Quebecs größten Krankenhäusern. Im Einsatz im Jewish General, CHUM und Douglas Mental Health Institute.
Danil Ulmashev
Full Stack Developer
Interesse an einer Zusammenarbeit?