Multi-Tenant-SaaS: Isolation, Skalierung, Datenstrategie
Deep Dive in Multi-Tenant-Muster für SaaS: Tenant-Isolation, Datenbankpartitionierung, Request-Routing, Quoten und Shared vs. Database-per-Tenant.

Das Multi-Tenancy-Spektrum
Multi-Tenancy ist keine binäre Entscheidung zwischen „Shared Everything" und „Separate Everything". Es ist ein Spektrum, und die richtige Position auf diesem Spektrum hängt von deinen Compliance-Anforderungen, Kundenerwartungen, Kostenstruktur und Teamkapazität ab.
An einem Ende teilen sich alle Tenants eine Datenbank, eine Anwendungsinstanz und einen Cache-Layer. Am anderen Ende bekommt jeder Tenant eine dedizierte Datenbank, dedizierte Compute-Ressourcen und dediziertes Networking. Die meisten Produktionssysteme landen irgendwo dazwischen.
Modelle zur Tenant-Isolation
type IsolationModel = "shared" | "schema-per-tenant" | "database-per-tenant";
interface TenantConfig {
id: string;
name: string;
isolationModel: IsolationModel;
tier: "free" | "pro" | "enterprise";
region: string;
resourceQuota: ResourceQuota;
}
interface ResourceQuota {
maxStorageGB: number;
maxRequestsPerMinute: number;
maxConcurrentConnections: number;
maxComputeUnits: number;
}
// ❌ One-size-fits-all isolation — forces enterprise customers
// into shared infrastructure they don't want
function getConnection(): DatabaseConnection {
return sharedPool.getConnection();
}
// ✅ Tier-based isolation — matching isolation to customer needs
function getTenantConnection(tenant: TenantConfig): DatabaseConnection {
switch (tenant.isolationModel) {
case "database-per-tenant":
return getDedicatedConnection(tenant.id);
case "schema-per-tenant":
return getSchemaConnection(tenant.id);
case "shared":
return getSharedConnection(tenant.id);
}
}Geteilte Tabellen mit einer tenant_id-Spalte sind am günstigsten zu betreiben, aber am schwierigsten abzusichern. Eine fehlende WHERE-Klausel in einer einzelnen Abfrage lässt Daten über Tenant-Grenzen hinweg durchsickern. Schema-per-Tenant bietet stärkere Isolation mit moderatem Overhead. Database-per-Tenant bietet die stärkste Isolation und die einfachste Compliance, aber die höchsten Betriebskosten.
Request-Routing und Tenant-Auflösung
Jeder Request muss vor der Ausführung jeglicher Geschäftslogik einem Tenant zugeordnet werden. Die Auflösung muss schnell, gecacht und unmöglich zu umgehen sein.
interface TenantResolver {
resolve(req: IncomingRequest): Promise<TenantConfig | null>;
}
class SubdomainTenantResolver implements TenantResolver {
constructor(
private readonly cache: Cache,
private readonly tenantRepo: TenantRepository
) {}
async resolve(req: IncomingRequest): Promise<TenantConfig | null> {
const host = req.headers.host || "";
const subdomain = host.split(".")[0];
if (!subdomain || subdomain === "www" || subdomain === "app") {
return null;
}
// Check cache first
const cached = await this.cache.get<TenantConfig>(
`tenant:${subdomain}`
);
if (cached) return cached;
const tenant = await this.tenantRepo.findBySubdomain(subdomain);
if (!tenant) return null;
await this.cache.set(`tenant:${subdomain}`, tenant, { ttl: 300 });
return tenant;
}
}
// Middleware that enforces tenant resolution
function tenantMiddleware(
resolver: TenantResolver
): RequestMiddleware {
return async (req, res, next) => {
const tenant = await resolver.resolve(req);
if (!tenant) {
res.status(404).json({ error: "Tenant not found" });
return;
}
if (tenant.tier === "free" && tenant.resourceQuota) {
const allowed = await checkRateLimit(
tenant.id,
tenant.resourceQuota.maxRequestsPerMinute
);
if (!allowed) {
res.status(429).json({ error: "Rate limit exceeded" });
return;
}
}
// Attach tenant to request context
req.tenant = tenant;
next();
};
}Datenbankstrategien für Multi-Tenancy
Die Datenbank-Layer ist der Punkt, an dem Multi-Tenancy kompliziert wird. Row-Level-Security, Connection-Pooling, Migrationen und Backups verhalten sich je nach Isolationsmodell unterschiedlich.
// Shared database with row-level security
class SharedTenantRepository<T extends { tenantId: string }> {
constructor(
private readonly db: Database,
private readonly tableName: string
) {}
async findAll(tenantId: string): Promise<T[]> {
// Always filter by tenant — never trust application code alone
return this.db.query<T>(
`SELECT * FROM ${this.tableName} WHERE tenant_id = $1`,
[tenantId]
);
}
async create(tenantId: string, data: Omit<T, "tenantId">): Promise<T> {
const columns = Object.keys(data);
const values = Object.values(data);
return this.db.queryOne<T>(
`INSERT INTO ${this.tableName}
(tenant_id, ${columns.join(", ")})
VALUES ($1, ${columns.map((_, i) => `$${i + 2}`).join(", ")})
RETURNING *`,
[tenantId, ...values]
);
}
async delete(tenantId: string, id: string): Promise<boolean> {
const result = await this.db.execute(
`DELETE FROM ${this.tableName} WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return result.rowCount > 0;
}
}
// Schema-per-tenant strategy
class SchemaTenantManager {
constructor(private readonly db: Database) {}
async createTenantSchema(tenantId: string): Promise<void> {
const schemaName = this.sanitizeSchemaName(tenantId);
await this.db.execute(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Run migrations within the tenant schema
await this.db.execute(`SET search_path TO "${schemaName}"`);
await this.runMigrations();
await this.db.execute(`SET search_path TO public`);
}
async getTenantConnection(tenantId: string): Promise<Database> {
const schemaName = this.sanitizeSchemaName(tenantId);
const conn = await this.db.getConnection();
await conn.execute(`SET search_path TO "${schemaName}"`);
return conn;
}
private sanitizeSchemaName(tenantId: string): string {
return `tenant_${tenantId.replace(/[^a-zA-Z0-9_]/g, "")}`;
}
private async runMigrations(): Promise<void> {
// Apply schema migrations
}
}Tenant-bewusstes Caching und Background-Jobs
Cache-Keys müssen die Tenant-ID enthalten. Background-Jobs müssen den Tenant-Kontext mitführen. Ohne diese Absicherungen führen Cache-Kollisionen zu Datenlecks und Jobs laufen im falschen Tenant-Kontext.
class TenantCache {
constructor(private readonly cache: Cache) {}
async get<T>(tenantId: string, key: string): Promise<T | null> {
return this.cache.get<T>(this.tenantKey(tenantId, key));
}
async set<T>(
tenantId: string,
key: string,
value: T,
ttl?: number
): Promise<void> {
await this.cache.set(this.tenantKey(tenantId, key), value, { ttl });
}
async invalidateAll(tenantId: string): Promise<void> {
const pattern = `tenant:${tenantId}:*`;
await this.cache.deletePattern(pattern);
}
private tenantKey(tenantId: string, key: string): string {
return `tenant:${tenantId}:${key}`;
}
}
// Background job with tenant context
interface TenantJob<T = unknown> {
tenantId: string;
jobType: string;
payload: T;
priority: number;
scheduledAt: Date;
}
class TenantJobProcessor {
constructor(
private readonly queue: JobQueue,
private readonly tenantManager: SchemaTenantManager
) {}
async enqueue<T>(
tenantId: string,
jobType: string,
payload: T
): Promise<string> {
const job: TenantJob<T> = {
tenantId,
jobType,
payload,
priority: this.getTenantPriority(tenantId),
scheduledAt: new Date(),
};
return this.queue.add(job);
}
async process(job: TenantJob): Promise<void> {
// Establish tenant context before processing
const db = await this.tenantManager.getTenantConnection(
job.tenantId
);
try {
const handler = this.getHandler(job.jobType);
await handler.execute(job.payload, db);
} finally {
await db.release();
}
}
private getTenantPriority(tenantId: string): number {
// Enterprise tenants get higher job priority
return 1;
}
private getHandler(jobType: string): JobHandler {
// Resolve handler by type
return {} as JobHandler;
}
}Kernpunkte
Multi-Tenant-Architektur ist ein Spektrum von Kompromissen zwischen Isolation, Kosten und Betriebskomplexität. Beginne für Produkte in der Frühphase mit geteilter Infrastruktur und Row-Level-Tenant-Filterung. Wechsle zu Schema-per-Tenant, wenn Compliance eine stärkere Isolation erfordert. Reserviere Database-per-Tenant für Enterprise-Kunden, die es explizit benötigen und dafür bezahlen.
Jede Layer muss Tenant-bewusst sein: Request-Routing, Datenbankabfragen, Cache-Keys, Background-Jobs und Logging. Eine einzelne Ebene, die den Tenant-Kontext vergisst, erzeugt ein Datenleck. Baue die Tenant-Auflösung als Middleware, die vor jeder Geschäftslogik läuft, und stelle sicher, dass Abfragen ohne Tenant-Kontext nicht ausführbar sind.
Entwerfe den Migrationspfad zwischen Isolationsmodellen von Anfang an. Das schlechteste Ergebnis ist, in geteilten Tabellen festzustecken, wenn dein größter Kunde dedizierte Infrastruktur fordert. Abstrahiere die Datenbank-Layer hinter einem Tenant-bewussten Repository, damit sich das Isolationsmodell ändern lässt, ohne Anwendungscode neu schreiben zu müssen.


