Projekte

multiverse

3 min Lesezeit GitHub
TypeScript PostgreSQL Multi-Tenancy Node.js

Ein fehlendes WHERE tenant_id = ? und ein Kunde sieht die Daten eines anderen. Ein vergessenes Rate Limit und ein Tenant hungert alle anderen aus. Ein Event, das außerhalb einer Transaction published wird, verschwindet beim Crash. Das sind keine Edge Cases. Das passiert in jeder Multi-Tenant Codebase irgendwann.

multiverse verhindert das auf Framework-Ebene.

Schema-per-Tenant Isolation

Die meisten Multi-Tenant Systeme arbeiten mit einer gemeinsamen Tabelle und einer tenant_id Spalte. Das funktioniert, bis jemand eine Query ohne Filter schreibt. Oder ein ORM eine Relation lädt, ohne den Tenant Context mitzugeben. Oder ein Background Job den falschen Tenant setzt.

multiverse geht einen anderen Weg: jeder Tenant bekommt sein eigenes Postgres Schema. Queries laufen physisch in einem isolierten Namespace. Eine fehlende WHERE-Clause kann keine Daten leaken, weil die Tabellen des anderen Tenants schlicht nicht sichtbar sind.

import { TenantPool, TenantQuery, TenantContext } from "multiverse";

const pool = new TenantPool({
  connectionString: process.env.DATABASE_URL,
});

// Innerhalb eines Request-Handlers:
const tenant = TenantContext.current();
const query = new TenantQuery(pool);

// Diese Query läuft automatisch im Schema des aktuellen Tenants
const users = await query.sql`SELECT * FROM users`;

TenantContext nutzt AsyncLocalStorage, um den aktuellen Tenant durch die gesamte Async Call Chain zu propagieren. Kein manuelles Parameter-Drilling. Kein vergessener Tenant in einem nested Function Call.

Tenant Resolution

Woher weiß das System, welcher Tenant gerade dran ist? multiverse bietet pluggable Resolver: Header, Subdomain, URL Path, JWT Claim. Oder du chainst mehrere mit Fallback.

import {
  HeaderTenantResolver,
  SubdomainTenantResolver,
  ChainResolver,
} from "multiverse";

const resolver = new ChainResolver([
  new HeaderTenantResolver("x-tenant-id"),
  new SubdomainTenantResolver(),
]);

Enterprise-Kunden kommen über ihre Subdomain (acme.app.com), API-Clients schicken einen Header. Beides landet im gleichen Pipeline.

Transactional Outbox

Du schreibst einen User in die Datenbank und publishst ein user.created Event. Wenn der Publish nach dem Commit fehlschlägt, ist der User da, aber das Event nicht. Wenn du das Event vor dem Commit publishst und der Commit fehlschlägt, ist das Event da, aber der User nicht. Classic Dual Write Problem.

multiverse schreibt Events in dieselbe Transaktion wie die Business-Daten:

const outbox = new Outbox(pool);

await outbox.withTransaction(async (tx) => {
  await tx.sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;
  await tx.emit("user.created", { name, email });
});
// Event und User landen atomar in der gleichen Transaction

Ein Background Poller liest die Outbox-Tabelle und dispatcht die Events. Wenn der Prozess crashed, sind die Events noch in der Tabelle und werden beim nächsten Poll abgearbeitet.

Per-Tenant Rate Limiting

Token Bucket und Sliding Window, konfigurierbar pro Tier. Enterprise-Tenants bekommen automatisch höhere Limits.

const limiter = new TenantRateLimiter({
  strategy: { type: "token-bucket", capacity: 100, refillRate: 10 },
  tiers: {
    enterprise: { capacity: 1000, refillRate: 100 },
  },
});

Das Rate Limit greift vor deinem Handler. Ein Tenant, der seine Limits ausschöpft, bekommt 429 Too Many Requests, ohne dass dein Business Code davon etwas mitbekommt.

Scoped Authentication

JWT Validation von externen OIDC Providern. Das Besondere: multiverse gleicht den Tenant Claim im JWT gegen den resolved Tenant ab. Ein Token, das für Tenant A ausgestellt wurde, kann nicht gegen die API von Tenant B verwendet werden, selbst wenn der Token technisch valide ist.

Enterprise-Kunden mit eigenem IdP? Konfigurierbar pro Tenant.

Was ich gelernt hab

Der schwierigste Teil war nicht die Isolation oder das Rate Limiting. Es war das Zusammenspiel. Wenn ein Request reinkommt, muss der Tenant resolved werden, der JWT validiert und gegen den Tenant geprüft werden, das Rate Limit geprüft werden, die Datenbankverbindung auf das richtige Schema gesetzt werden, und der AsyncLocalStorage Context gesetzt werden. In dieser Reihenfolge. Und jeder Schritt kann fehlschlagen.

Die Lösung ist eine composable Middleware Chain. Jede Concern ist ein eigener Middleware Layer, und die Reihenfolge ist fix. Das macht das System testbar (jeder Layer isoliert) und debugbar (du weißt genau, welcher Layer einen Request rejected hat).