Multimandantenfähige SaaS-Architekturen, die skalieren
Multimandantenfähige SaaS-Architektur mit Datenbankisolierung, mandantenbewusster Middleware, Quoten, Partitionierung und Sicherheitsgrenzen.

Das Isolationsspektrum
Multitenancy reicht von vollständig geteilt (eine Datenbank, ein Schema) bis hin zu vollständig isoliert (separate Datenbanken pro Mandant). Die richtige Wahl hängt von deinen Compliance-Anforderungen, der erwarteten Mandantenzahl und dem operativen Budget ab. Die meisten Teams beginnen geteilt und bereuen es; die Trade-offs früh zu verstehen, erspart schmerzhafte Migrationen.
Middleware für den Mandantenkontext
Jede Anfrage muss die Mandantenidentität tragen. Extrahiere sie einmal in der Middleware und propagiere sie durch den gesamten Lebenszyklus der Anfrage.
// ❌ Passing tenantId manually through every function
// async function getOrders(tenantId: string, userId: string) { ... }
// async function getProducts(tenantId: string, category: string) { ... }
// ✅ Tenant context propagated via AsyncLocalStorage
import { AsyncLocalStorage } from "node:async_hooks";
interface TenantContext {
tenantId: string;
plan: "free" | "pro" | "enterprise";
databaseSchema: string;
}
const tenantStorage = new AsyncLocalStorage<TenantContext>();
function getTenantContext(): TenantContext {
const ctx = tenantStorage.getStore();
if (!ctx) throw new Error("No tenant context — middleware not applied");
return ctx;
}
// Express middleware
function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const tenantId = req.headers["x-tenant-id"] as string;
if (!tenantId) {
res.status(400).json({ error: "Missing tenant identifier" });
return;
}
const tenant = tenantRegistry.get(tenantId);
if (!tenant) {
res.status(404).json({ error: "Tenant not found" });
return;
}
tenantStorage.run(
{
tenantId: tenant.id,
plan: tenant.plan,
databaseSchema: `tenant_${tenant.id}`,
},
() => next()
);
}Strategien zur Datenbankisolierung
Es gibt drei gängige Muster: geteiltes Schema mit Mandantenspalte, Schema pro Mandant und Datenbank pro Mandant.
// Strategy 1: Shared schema — tenant_id column on every table
// Pros: Simple, low overhead
// Cons: One bad query leaks data across tenants
class SharedSchemaRepository {
async getOrders(userId: string): Promise<Order[]> {
const { tenantId } = getTenantContext();
// Every query MUST include tenant_id — missing it leaks data
return db.query(
`SELECT * FROM orders WHERE tenant_id = $1 AND user_id = $2`,
[tenantId, userId]
);
}
}
// Strategy 2: Schema-per-tenant — PostgreSQL schemas
// Pros: Strong isolation, easy per-tenant backup/restore
// Cons: Schema migrations must run N times
class SchemaPerTenantRepository {
private getSchema(): string {
return getTenantContext().databaseSchema;
}
async getOrders(userId: string): Promise<Order[]> {
const schema = this.getSchema();
// search_path isolates queries to tenant schema
await db.query(`SET search_path TO ${schema}`);
return db.query(
`SELECT * FROM orders WHERE user_id = $1`,
[userId]
);
}
}
// Strategy 3: Database-per-tenant — separate connections
// Pros: Complete isolation, per-tenant scaling
// Cons: Connection pool overhead, operational complexity
class DatabasePerTenantRepository {
constructor(private pools: Map<string, DatabasePool>) {}
private getPool(): DatabasePool {
const { tenantId } = getTenantContext();
const pool = this.pools.get(tenantId);
if (!pool) throw new Error(`No database pool for tenant ${tenantId}`);
return pool;
}
async getOrders(userId: string): Promise<Order[]> {
const pool = this.getPool();
return pool.query(
`SELECT * FROM orders WHERE user_id = $1`,
[userId]
);
}
}Row-Level Security für geteilte Schemas
Bei der Verwendung geteilter Schemas verhindert PostgreSQL Row-Level Security Datenlecks auf Datenbankebene – selbst wenn der Anwendungscode den Mandantenfilter vergisst.
-- Enable RLS on the orders table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Force RLS even for table owners
ALTER TABLE orders FORCE ROW LEVEL SECURITY;// Set tenant context at the database session level
async function withTenantSession<T>(
pool: Pool,
tenantId: string,
operation: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Set tenant for RLS policies
await client.query(
`SET app.current_tenant = $1`,
[tenantId]
);
return await operation(client);
} finally {
// Reset to prevent tenant leakage on connection reuse
await client.query(`RESET app.current_tenant`);
client.release();
}
}
// Usage — no tenant_id in WHERE clause needed
const orders = await withTenantSession(pool, tenantId, async (client) => {
// RLS automatically filters to current tenant
const result = await client.query(`SELECT * FROM orders WHERE user_id = $1`, [
userId,
]);
return result.rows;
});Ressourcenkontingente und Rate Limiting
Wenn ein Mandant alle Ressourcen verbraucht, leidet der Service für alle. Implementiere Mandantenkontingente auf Anwendungsebene.
interface TenantQuota {
maxRequestsPerMinute: number;
maxStorageBytes: number;
maxUsersPerTenant: number;
}
const PLAN_QUOTAS: Record<string, TenantQuota> = {
free: {
maxRequestsPerMinute: 60,
maxStorageBytes: 100 * 1024 * 1024,
maxUsersPerTenant: 5,
},
pro: {
maxRequestsPerMinute: 600,
maxStorageBytes: 10 * 1024 * 1024 * 1024,
maxUsersPerTenant: 50,
},
enterprise: {
maxRequestsPerMinute: 6000,
maxStorageBytes: 100 * 1024 * 1024 * 1024,
maxUsersPerTenant: 500,
},
};
class TenantRateLimiter {
private counters = new Map<string, { count: number; resetAt: number }>();
check(tenantId: string, plan: string): { allowed: boolean; retryAfter?: number } {
const quota = PLAN_QUOTAS[plan];
if (!quota) return { allowed: false };
const now = Date.now();
const counter = this.counters.get(tenantId);
if (!counter || counter.resetAt < now) {
this.counters.set(tenantId, {
count: 1,
resetAt: now + 60_000,
});
return { allowed: true };
}
if (counter.count >= quota.maxRequestsPerMinute) {
return {
allowed: false,
retryAfter: Math.ceil((counter.resetAt - now) / 1000),
};
}
counter.count++;
return { allowed: true };
}
}Mandantenbewusste Migrationen
Schema pro Mandant erfordert, dass Migrationen über alle Mandantenschemas hinweg ausgeführt werden. Automatisiere das mit einem Migrations-Runner, der den Migrationsstatus pro Mandant verfolgt.
async function migrateAllTenants(
migration: Migration,
tenants: Tenant[]
): Promise<MigrationReport> {
const results: MigrationReport = { succeeded: [], failed: [] };
// Run migrations sequentially to avoid overloading the database
for (const tenant of tenants) {
try {
await db.query(`SET search_path TO ${tenant.schema}`);
await db.query(migration.sql);
await db.query(
`INSERT INTO migration_history (version, applied_at)
VALUES ($1, NOW())`,
[migration.version]
);
results.succeeded.push(tenant.id);
} catch (error) {
results.failed.push({
tenantId: tenant.id,
error: error instanceof Error ? error.message : "Unknown error",
});
// Continue with other tenants — don't let one failure block all
}
}
return results;
}Wichtige Erkenntnisse
Entscheidungen über die Multitenant-Architektur verstärken sich im Laufe der Zeit. Die von dir gewählte Isolierungsstrategie – geteiltes Schema, Schema pro Mandant oder Datenbank pro Mandant – wirkt sich während der gesamten Lebensdauer des Produkts auf Sicherheit, Betrieb und Skalierung aus. Beginne mit Schema pro Mandant, wenn du starke Isolierung ohne den operativen Mehraufwand separater Datenbanken benötigst.
Verwende AsyncLocalStorage, um den Mandantenkontext implizit zu propagieren, anstatt Mandanten-IDs durch jede Funktionssignatur zu schleifen. Aktiviere Row-Level Security als Sicherheitsnetz für geteilte Schemas – sie fängt die Abfragen ab, bei denen der Anwendungscode den Mandantenfilter vergisst. Implementiere von Anfang an Rate Limiting und Ressourcenkontingente pro Mandant, denn früher oder später wird ein lauter Mandant alle geteilten Ressourcen verbrauchen. Jeder Mandant sollte die Anwendung so erleben, als wäre er der einzige Kunde – unabhängig davon, wie viele Mandanten die Infrastruktur teilen.


