Multi-Tenancy an Tag 2 gebaut. An Tag 67 neu gebaut.
Ich dachte, org_id zu jeder Tabelle hinzuzufügen bedeute kugelsichere Multi-Tenancy. Dann zeigte mein Sicherheitsaudit, dass Agenturen in SME-Tabellen schrieben – nicht durch einen Bug, sondern by Design.
- Oktober 2025, 23:47 Uhr. Ich führe ein Sicherheitsaudit durch, das ich für Routine hielt, auf STRAŦUM. Alles läuft seit Wochen einwandfrei – SMEs haben ihre Daten, Agenturen haben ihre, Multi-Tenancy ist solide.
Der Kaffee ist kalt. Das Audit-Skript kämpft sich durch Logs. Dann sehe ich es: Agenturen schrieben in SME-Tabellen.
Nicht durch einen Bug. Nicht durch eine Sicherheitslücke. Durch die Architektur selbst.
Vor zwei Monaten traf ich die Entscheidung, Multi-Tenant-Architektur ab Tag 2 aufzubauen. Mutiger Schritt für einen Solo-Founder mit einem einzigen funktionierenden AI-Agenten. Ich fügte org\_id zu jeder Tabelle hinzu, schrieb RLS-Richtlinien und baute separates Routing für SMEs und Agenturen. Es funktionierte – SMEs hatten ihre Kampagnen, Agenturen ihre Kunden, Daten flossen an die richtigen Stellen.
Oder so dachte ich.
Ich saß da wohl 20 Minuten und starrte einfach auf das Schema. Wie konnte mir das entgehen? Ich hatte wochenlang Multi-Tenant-Architektur aufgebaut, 83 RLS-Richtlinien geschrieben und mit sowohl SME- als auch Agentur-Konten getestet. Alles *funktionierte*. Aber „funktionieren" und „korrekt sein" sind nicht dasselbe.
Das ist die Art von Bug, die einen zweifeln lässt, ob man überhaupt Software entwickeln sollte. Denn es ist kein Tippfehler. Kein übersehener Randfall. Es ist architektonische Naivität.
Ich hatte den klassischen Fehler gemacht: Ich nahm an, org\_id-Filterung sei ausreichend für Multi-Tenant-Isolation. Das war sie nicht.
Dies ist die Geschichte, wie ich entdeckte, dass echte Multi-Tenant-Isolation mehr erfordert als nur org\_id zu jeder Tabelle hinzuzufügen – und die 33 Migrationen über 48 Stunden, die das Problem schließlich lösten.
---
> **Hinweis**: SQL-Beispiele in diesem Beitrag verwenden generalisierte Schema- und Tabellennamen (tenant\_b, workspace\_entities, entity\_data) aus Sicherheitsgründen. Die Konzepte bleiben dieselben, unabhängig von deinen spezifischen Namenskonventionen.
---
Das Problem: Nicht alle Tenants sind gleich
So war das, was ich anfänglich aufgebaut hatte:
```sql
-- Markenrichtlinien-Tabelle (gemeinsam genutzt von SMEs und Agenturen)
CREATE TABLE brand_guidelines (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
name TEXT,
guidelines JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS-Richtlinie (scheint sicher)
CREATE POLICY brand_guidelines_org_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
Das funktioniert perfekt für SMEs. Jede Organisation hat ihre eigenen Markenrichtlinien. Row-Level Security stellt sicher, dass sie die Daten der anderen nicht sehen können. (Zumindest dachte ich das. Mehr dazu in einer Minute.)
Aber Agenturen sind anders.
Agenturen haben nicht nur einen Satz von Markenrichtlinien. Sie haben einen pro Kunde:
- Markenrichtlinien von Kunde A (lebendige Farbpalette, fette Typografie, innovationsorientiertes Messaging)
- Markenrichtlinien von Kunde B (gedämpfte Farbpalette, minimales Design, qualitätsorientierte Positionierung)
Gleiche Agentur, verschiedene Kunden, vollständig unterschiedliche Marken.
Die naive Lösung (was ich zuerst baute):
```sql
-- entity_id zur gemeinsamen Tabelle hinzufügen
ALTER TABLE brand_guidelines ADD COLUMN entity_id UUID;
-- RLS-Richtlinie aktualisieren
CREATE POLICY brand_guidelines_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (
organization_id = get_user_org_id() AND
(entity_id IS NULL OR entity_id = get_user_entity_id())
);
```
Problem: Das erzeugte eine Tabelle mit zwei verschiedenen Datenmodellen:
```
SME-Zeile: organization_id='org-123', entity_id=NULL, guidelines=\{...\}
Agentur-Zeile: organization_id='org-456', entity_id='entity-a', guidelines=\{...\}
Agentur-Zeile: organization_id='org-456', entity_id='entity-b', guidelines=\{...\}
```
Abfragen wurden ein Durcheinander mit komplexem NULL-Handling, und jede Funktion benötigte „if SME, else Agentur"-Logik im Anwendungscode.
Und ja, ich schrieb all das, bevor ich merkte, dass es Symptome eines tieferen Problems waren. Wochenlange Arbeit, alle auf dasselbe Ergebnis hinweisend: Ich hatte mich in eine Ecke gebaut.
Jede Funktion benötigte eigene Logik: „Wenn SME, mache das. Wenn Agentur, mache jenes."
Schlimmer noch, die Architektur traf falsche Annahmen:
- Agenturen, die entity\_id=NULL schrieben, würden SME-Daten verschmutzen
- SMEs konnten keine Sub-Entitäten haben, selbst wenn sie Unterkonten wollten
- Schema wurde zum „Schweizer Käse" mit nullable Columns
Das war keine Multi-Tenant-Architektur. Das war eine einzelne Tabelle, die versucht, zwei verschiedene Datenmodelle zu bedienen.
---
Die Erkenntnis: Verschiedene Tenants brauchen verschiedene Schemas
Ende Oktober erkannte ich die Wahrheit: SMEs und Agenturen teilen nicht dasselbe Datenmodell.
SME-Datenmodell:
```
organization → campaigns → agent_outputs
```
Agentur-Datenmodell:
```
organization → workspace_entities → campaigns → agent_outputs
↓
entity_data (z.B. Markenrichtlinien, Personas)
```
Agenturen haben eine ganze Ebene (Workspace-Entitäten), die SMEs nicht haben. Sie haben auch entitätsspezifische Intelligenz, die in der SME-Welt nicht existieren sollte.
Die Lösung: Separate Datenbank-Schemas.
```sql
-- SME-Tabellen (public Schema)
public.brand_guidelines
public.campaigns
public.outputs
-- Agentur-Tabellen (tenant_b Schema)
tenant_b.workspace_entities
tenant_b.entity_data -- Enthält Markenrichtlinien, Personas, etc.
tenant_b.campaigns
tenant_b.outputs
```
Jetzt haben SMEs und Agenturen vollständig verschiedene Tabellen. Kein gemeinsames Schema. Keine nullable entity\_id-Verschmutzung. Keine „if SME, else Agentur"-Logik.
---
Warum das wichtig ist: Der Business Case für Schema Routing
Bevor wir in die technische Implementierung eintauchen, lass uns darüber sprechen, warum diese Architekturentscheidung über „saubereren Code" hinaus wichtig ist.
Zukunftssicherheit für Wachstum (vielleicht)
Schema Routing geht nicht nur darum, das heutige Problem zu lösen. Es geht darum, Türen für Möglichkeiten offenzuhalten, die ich noch nicht mal vorhersehen kann.
Ich bin noch in der privaten Alpha mit 15 Nutzern. Ich habe keine Enterprise-Kunden. Ich habe noch nicht mit einem DSGVO-Anwalt gesprochen. Aber hier ist, was Schema Routing *ermöglichen könnte*, wenn STRAŦUM wächst:
Internationale Expansion:
- Bei Expansion in die EU: Separate Schemas könnten Data Residency ermöglichen (EU-Kundendaten im eu\_agency-Schema auf EU-Servern)
- Recht auf Löschung wird einfacher: Eine Schema-Abfrage, kein Filtern durch gemischte Tabellen
- Audit-Trails: „Zeig mir alle Daten von Kunde X" = eine Schema-Abfrage
Compliance-Gespräche:
- Wenn jemand irgendwann fragt: „Wie garantiert ihr Datenisolation?"
- Mit org\_id-Filterung: „Wir verwenden Row-Level Security Policies" (vage, schwer zu verifizieren)
- Mit Schema Routing: „Die Daten jedes Kunden befinden sich in einem separaten Datenbank-Schema" (konkret, prüfbar)
- Ich weiß nicht, ob das schon wichtig ist. Aber es *könnte* wichtig werden, wenn wir diese Gespräche führen.
Die ehrliche Wahrheit:
Ich baue gerade nicht für HIPAA- oder SOC 2-Compliance. Ich baue für SMEs und kleine Agenturen, die eine bessere Marketingstrategie brauchen.
Aber Schema Routing bedeutet: Wenn jemand irgendwann fragt „Könnt ihr Healthcare-Kunden bedienen?" oder „Unterstützt ihr Data Residency?", ist die Antwort „ja, lass mich dir die Architektur zeigen" statt „lass mich erstmal alles neu bauen".
Die Nachteile (ehrlich sein)
Schema Routing hat nicht nur Vorteile. Hier ist, was es wirklich kostet:
Entwicklungskomplexität:
- Jede WRITE-Operation braucht eine Router-Funktion
- Jede READ-Operation braucht einen Security-View
- Tests erfordern sowohl SME- als auch Agentur-Pfade
- Mit Claude Code: 2 Tage intensiver Arbeit (27.-29. Oktober 2025) abends
- Ohne AI-Tools: Wären Wochen gewesen
Migrationsrisiko:
- 33 sequenzielle Migrationen = 33 Möglichkeiten für Tippfehler
- Ein falsches ALTER TABLE = beschädigte Produktionsdaten
- Musste jede Migration 3x auf Staging ausführen, bevor ich Prod anfasste
- Die Paranoia war real
Query-Performance-Overhead:
- Views mit UNION ALL = etwas langsamere Lesevorgänge
- Router-Funktionen = zusätzlicher Funktionsaufruf bei Schreibvorgängen
- RLS + Views = komplexere Query-Pläne
- (In der Praxis: Ich habe noch keine Verlangsamungen bemerkt, aber ich habe auch nur 15 Alpha-Nutzer)
Operationelle Komplexität:
- Schema-Migrationen betreffen jetzt 2+ Schemas (public + agency)
- Datenbank-Backups brauchen schema-bewusstes Restore
- Monitoring-Queries müssen mehrere Schemas prüfen
- Das wird mich irgendwann beißen, ich weiß nur nicht wann
Warum ich den Trade-Off trotzdem gemacht habe
Der Optionswert könnte enorm sein. Oder er könnte überhaupt keine Rolle spielen.
Schema Routing hält Türen offen, durch die ich mir nicht einmal sicher bin, ob ich gehen will:
- White-Label-Partnerschaften: Könnte einem Partner ihr eigenes Schema geben, UI rebrandden
- Reseller-Möglichkeiten: Agenturen könnten mit nachweisbarer Datenisolation weiterverkaufen
- Verschiedene Preisstufen: „Premium"-Kunden könnten dedizierte Schemas bekommen
- Geografische Expansion: EU-Schema, US-Schema, APAC-Schema – gleiche Codebasis
Das Ding ist: Ich bin in der privaten Alpha. Ich weiß nicht, ob irgendetwas davon wichtig wird. Vielleicht bekomme ich nie eine White-Label-Anfrage. Vielleicht ist geografische Expansion Jahre entfernt. Vielleicht schwenkt das gesamte Geschäft und nichts davon ist relevant.
Aber hier ist, was ich weiß: Mit Schema Routing existieren diese Optionen. Mit org\_id-Filterung würden die meisten davon einen vollständigen Neubau erfordern.
Das ist die Wette, die ich gemacht habe: Jetzt 2 extra Tage (mit Claude Code) investieren, um Optionen später offenzuhalten.
Ist das die richtige Wette? Frag mich in einem Jahr.
---
Die Architektur: Schema Routing
Muster 1: Schema-spezifische Tabellen
Einige Tabellen existieren nur für einen Tenant-Typ:
```sql
-- Spezialisiertes Tenant-Schema
CREATE SCHEMA tenant_b;
-- Workspace-Entitäten (spezifisch für diesen Tenant-Typ)
CREATE TABLE tenant_b.workspace_entities (
id UUID PRIMARY KEY,
organization_id UUID,
name TEXT,
metadata JSONB
);
-- Entitätsspezifische Daten
CREATE TABLE tenant_b.entity_data (
id UUID PRIMARY KEY,
organization_id UUID,
entity_id UUID REFERENCES tenant_b.workspace_entities(id),
data_type TEXT,
content JSONB
);
```
SMEs berühren diese Tabellen nie. Sie existieren nicht im public-Schema.
Muster 2: Datenbank-Router-Funktionen
Wie schreibt man in das richtige Schema? **Router-Funktionen**.
Hier das Konzept (vereinfacht):
```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER -- Mit erhöhten Berechtigungen ausführen
AS $$
BEGIN
-- Schritt 1: Organisationstyp erkennen
SELECT type INTO org_type FROM organizations WHERE id = p_org_id;
-- Schritt 2: Basierend auf Typ in korrektes Schema routen
IF org_type = 'TENANT_B' THEN
INSERT INTO tenant_b.entity_data (...) VALUES (...);
ELSE
INSERT INTO public.brand_guidelines (...) VALUES (...);
END IF;
RETURN result;
END;
$$;
```
Wie es funktioniert:
1. Org-Typ erkennen: Organizations-Tabelle abfragen, um Tenant-Typ zu bestimmen
2. In korrektes Schema routen: Basierend auf Typ in das passende Schema schreiben
3. Ergebnis zurückgeben: Einschließlich welches Schema verwendet wurde (für Debugging)
Anwendungscode (gleich für alle Tenant-Typen):
```typescript
// Einfach die Router-Funktion aufrufen – keine tenant-spezifische Logik
const result = await supabase.rpc('save_resource_routed', \{
p_org_id: orgId,
p_entity_id: entityId, // null für einfache Tenants
p_data: { ... \}
});
```
Kein if/else im Anwendungscode. Die Datenbank übernimmt das Routing.
Das Schreiben meiner ersten Router-Funktion dauerte 4 Stunden. Debuggen, warum sie nicht funktionierte? Weitere 6 Stunden. Das Problem? Ich hatte vergessen, EXECUTE-Berechtigungen zu erteilen. Klassische Solo-Founder-Energie: architektonische Brillanz, Permission-Versäumnisse. :P
Muster 3: Security-Invoker Views für Lesevorgänge
Schreiben verwendet Router-Funktionen. Lesen verwendet **Views**.
```sql
-- Vereinheitlichter View, der beide Schemas kombiniert
CREATE VIEW resources_unified
WITH (security_invoker = on) -- Berücksichtigt RLS-Richtlinien
AS
SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
FROM public.brand_guidelines
UNION ALL
SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
FROM tenant_b.entity_data
WHERE data_type = 'brand_guidelines';
```
Anwendungscode (vereinheitlichte Lesevorgänge):
```typescript
// Ressourcen lesen (funktioniert für alle Tenant-Typen)
const \{ data \} = await supabase
.from('resources_unified')
.select('*')
.eq('organization_id', orgId);
// RLS-Richtlinien filtern korrekt, unabhängig vom Quell-Schema
```
Wichtiges Detail: WITH (security\_invoker = on) stellt sicher, dass RLS-Richtlinien durchgesetzt werden. Ohne das umgehen Views RLS (Sicherheitskatastrophe).
---
Die Migration: 33 Migrationen in 48 Stunden
Schema Routing hinzuzufügen war keine einzelne Migration. Es war eine Reise.
Weißt du, was Spaß macht? 33 Datenbank-Migrationen hintereinander schreiben, während man weiß, dass wenn auch nur EINE einen Tippfehler hat, man Produktionsdaten beschädigt. „Spaß" ist eigentlich nicht das richtige Wort. „Beängstigend" trifft es besser. Ich führte jede Migration dreimal auf Staging aus, bevor ich Production anfasste.
27.-29. Oktober 2025: 33 sequenzielle Migrationen für vollständiges Schema Routing.
Die Migrations-Phasen:
1. Spezialisiertes Schema erstellen – tenant\_b-Schema mit richtigen Berechtigungen einrichten
2. Schema-spezifische Tabellen erstellen – Notwendige Tabellen im neuen Schema spiegeln
3. Router-Funktionen aufbauen – Eine für jeden Ressourcentyp, der Routing braucht
4. Security Views erstellen – Vereinheitlichte Views mit UNION ALL für Lesevorgänge
5. RLS-Richtlinien aktualisieren – Sicherstellen, dass beide Schemas richtige Isolation haben
6. Datenmigration – Vorhandene Daten in korrekte Schemas verschieben
7. Anwendungs-Updates – Von direkten Abfragen zu Router-Funktionen/Views wechseln
Gesamtaufwand: 33 Migrationen, 2 Tage mit Claude Code, 100% wert.
---
Die Ergebnisse: Echte Multi-Tenant-Isolation
Vorher (gemeinsame Tabellen mit org_id)
Datenmodell:
```sql
public.brand_guidelines (organization_id, entity_id, guidelines)
```
Probleme:
- ❌ Nullable entity\_id für einen Tenant-Typ (Datenmodell-Verwirrung)
- ❌ Komplexe Abfragen mit NULL-Handling
- ❌ Anwendungslogik: if (tenantTypeA) \{ ... \} else \{ ... \}
- ❌ Risiko von Kreuzkontamination
Nachher (Schema Routing)
Datenmodell:
```sql
public.brand_guidelines (organization_id, guidelines) -- Tenant-Typ A
tenant_b.entity_data (organization_id, entity_id, data) -- Tenant-Typ B
```
Vorteile:
- ✅ Saubere Datenmodelle (keine nullable Foreign Keys)
- ✅ Einfache Abfragen ohne komplexes NULL-Handling
- ✅ Kein Anwendungs-if/else (Datenbank übernimmt Routing)
- ✅ Unmöglich für Schema-übergreifende Kontamination (physisch getrennt)
Sicherheitsverbesserungen
Vorher: Risiko von Kreuzkontamination mit nullable Columns und gemeinsamen Tabellen
Nachher: Router-Funktionen leiten Schreibvorgänge automatisch in das richtige Schema basierend auf dem Organisationstyp. Physische Schema-Trennung macht Kreuzkontamination unmöglich.
Isolationslevel: Datenbank-erzwungene Trennung. Keine Prüfungen auf Anwendungsebene.
---
Wann Schema Routing vs. org_id-Filterung verwenden
Nicht jede Multi-Tenant-App braucht Schema Routing. Nutze diesen Entscheidungsbaum:
org\_id-Filterung verwenden (einfacher), wenn:
✅ Alle Tenants haben dasselbe Datenmodell (z.B. Todo-App)
✅ Keine hierarchische Tenancy (keine Sub-Entitäten innerhalb von Organisationen)
✅ Einfache Abfragen (WHERE org\_id = X funktioniert überall)
✅ B2C oder kleines B2B (kein Enterprise-Vertrieb, keine Compliance-Anforderungen)
✅ MVP-Geschwindigkeit ist wichtig (in Wochen, nicht Monaten auf den Markt)
Business-Begründung: Du validierst Product-Market Fit, baust nicht für Fortune-500-Compliance-Anforderungen. Schnell liefern, später refactoren wenn du Enterprise-Traction bekommst.
Beispiel: Projektmanagement-Tool, bei dem jede Organisation ihre eigenen Projekte verwaltet.
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
org_id UUID, -- Einfache Filterung
name TEXT
);
```
Schema Routing verwenden (komplexer), wenn:
✅ Verschiedene Tenant-Typen brauchen verschiedene Datenmodelle (Typ A vs. Typ B vs. Typ C)
✅ Hierarchische Tenancy (Organisationen → workspace_entities → Sub-Entitäten)
✅ Enterprise-Vertrieb auf der Roadmap (Fortune 500, Healthcare, Finanzen, Behörden)
✅ Regulatorische Compliance erforderlich (DSGVO, HIPAA, SOC 2, FedRAMP)
✅ White-Label- oder Reseller-Potenzial (Partner brauchen vollständige Datenisolation)
✅ Internationale Expansion geplant (Data-Residency-Anforderungen)
Business-Begründung: Wenn du Enterprise-Kunden anvisierst, wird „Datenisolation" zu einem Checkbox auf Sicherheitsfragebögen. Schema Routing lässt dich selbstbewusst antworten. Row-Level-Filterung lässt dich absichern.
Beispiel: Plattform, bei der einige Tenants hierarchische Workspace-Strukturen haben, die sich grundlegend von direkten Kunden unterscheiden.
Kosten/Nutzen (meine Erfahrung):
- Schema Routing kostet: 2 Tage mit Claude Code (ohne KI-Unterstützung wären es Wochen gewesen)
- Potenzieller Vorteil: Sauberere Architektur, Compliance-Bereitschaft, Partnerschaftsoptionen
- Tatsächlicher Vorteil: Unbekannt – ich bin noch in der privaten Alpha
- Break-even: Wenn Schema Routing auch nur eine Tür öffnet, durch die ich sonst nicht gehen könnte, hat es sich selbst bezahlt
Die eigentliche Frage: Optimierst du für Speed-to-Market oder Optionalität? Beides ist gültig. Ich wählte Optionalität.
---
Alternative Isolationsstrategien
Schema Routing ist nicht der einzige Ansatz. Hier das Spektrum:
Level 1: Separate Datenbanken (höchste Isolation)
```
database_tenant_1
database_tenant_2
database_tenant_3
```
Vorteile:
- ✅ Vollständige physische Isolation
- ✅ Per-Tenant-Backups
- ✅ Unabhängige Skalierung
- ✅ Regulatorische Compliance (Data Residency)
Nachteile:
- ❌ Hohe operationelle Komplexität (N Datenbanken verwalten)
- ❌ Teuer (Datenbankinstanz pro Tenant)
- ❌ Tenant-übergreifende Abfragen unmöglich
- ❌ Schema-Migrationen über alle Datenbanken
Use Case: Enterprise-SaaS mit regulatorischen Anforderungen, hochwertige Kunden ($10k+/Monat).
Level 2: Separate Schemas (starke Isolation)
```
database
├── schema_tenant_1
├── schema_tenant_2
└── public (gemeinsame Tabellen)
```
Vorteile:
- ✅ Starke logische Isolation
- ✅ Gemeinsame Infrastruktur (eine Datenbank)
- ✅ Berechtigungen auf Schema-Ebene
- ✅ Verschiedene Datenmodelle pro Tenant-Typ
Nachteile:
- ❌ Komplexer als Row-Level
- ❌ Erfordert Router-Funktionen
- ❌ Migrations-Komplexität (N Schemas)
Use Case: B2B-SaaS mit verschiedenen Kundenstufen (STRAŦUMs Ansatz).
Level 3: Row-Level-Filterung mit RLS (moderate Isolation)
```
database
└── public
└── table (mit org_id-Spalte)
```
Vorteile:
- ✅ Einfach zu implementieren
- ✅ Einfache Migrationen (ein Schema)
- ✅ Tenant-übergreifende Analysen möglich
- ✅ Postgres RLS erzwingt Isolation
Nachteile:
- ❌ Alle Tenants teilen dasselbe Datenmodell
- ❌ RLS-Performance-Overhead
- ❌ Risiko von RLS-Fehlkonfigurationen
Use Case: B2B-SaaS mit einheitlichen Datenmodellen (Projektmanagement, CRM).
---
Implementierungs-Checkliste: Schema Routing
Wenn du Schema Routing implementierst, nutze diese Checkliste:
Phase 1: Schema-Design
- [ ] Tenant-Typ-Diskriminator erstellen (organizations.type)
- [ ] Schema-spezifische Tabellen entwerfen (was gehört wohin?)
- [ ] Spezialisiertes Schema erstellen: CREATE SCHEMA tenant\_b;
- [ ] Notwendige Tabellen im spezialisierten Schema spiegeln
- [ ] Dokumentieren, welche Tabellen in welchem Schema liegen
Phase 2: Router-Funktionen
- [ ] Router-Funktion für jeden Ressourcentyp schreiben
- [ ] SECURITY DEFINER für erhöhte Berechtigungen verwenden
- [ ] search\_path = public, tenant\_b für Multi-Schema-Zugriff setzen
- [ ] Tenant-Typ-Erkennung behandeln: SELECT type FROM organizations
- [ ] Schema-Info für Debugging zurückgeben
- [ ] EXECUTE der authentifizierten Rolle erteilen
Phase 3: Security Views
- [ ] Vereinheitlichte Views für Lesevorgänge erstellen (UNION ALL über Schemas)
- [ ] WITH (security\_invoker = on) für RLS-Durchsetzung verwenden
- [ ] source\_schema-Spalte für Debugging hinzufügen
- [ ] Testen, dass RLS-Richtlinien auf Views funktionieren
- [ ] SELECT der authentifizierten Rolle erteilen
Phase 4: Anwendungsintegration
- [ ] Schreibvorgänge auf Router-Funktionen umstellen: supabase.rpc('save\_resource\_routed', ...)
- [ ] Lesevorgänge auf Views umstellen: supabase.from('resource\_unified').select()
- [ ] if/else-Logik auf Anwendungsebene entfernen
- [ ] Tenant-Typ-A-Flow testen (schreibt in public)
- [ ] Tenant-Typ-B-Flow testen (schreibt in tenant\_b)
- [ ] Datenisolation verifizieren (Entität A ≠ Entität B)
Phase 5: Migration & Testing
- [ ] Migrations-Skripte schreiben (30+ für vollständige Abdeckung)
- [ ] Auf Staging mit realistischen Daten testen
- [ ] Sicherheitsaudit durchführen (Schema-übergreifende Lecks?)
- [ ] Load-Test (RLS + Views Performance)
- [ ] In Production monitoren (langsame Queries?)
---
Lessons Learned
1. org_id ist notwendig, aber nicht ausreichend
org\_id zu jeder Tabelle hinzuzufügen gibt dir Row-Level-Filterung. Aber wenn verschiedene Tenant-Typen verschiedene Datenmodelle brauchen, brauchst du Schema Routing.
Erkenntnis: „Multi-Tenant" ist nicht binär. Es gibt Isolationslevel. Ich wählte ein höheres Level als ich für meine aktuellen 15 Nutzer unbedingt bräuchte. Die Zeit wird zeigen, ob das klug oder nur extra Arbeit war.
2. Anwendungslogik → Datenbanklogik
Jedes if (tenantType === 'TYPE\_B') in deiner Anwendung ist ein Code Smell. Verschiebe Tenant-bewusste Logik in die Datenbank mit Router-Funktionen.
Erkenntnis: Datenbankfunktionen sind schwieriger zu schreiben, aber potenziell einfacher zu prüfen. Wenn ich jemals Enterprise-Kunden habe, die „beweise deine Datenisolation" fordern, kann ich auf gespeicherte Prozeduren verweisen. Aber momentan ist das hypothetisch.
3. Views + RLS = vereinheitlichte Lesevorgänge
Aus mehreren Schemas zu lesen ist komplex. Views + security\_invoker = on geben dir vereinheitlichte Lesevorgänge mit richtiger Isolation.
Erkenntnis: Views schaffen Abstraktionsschichten, die Compliance irgendwann einfacher machen könnten. Oder sie fügen einfach Komplexität hinzu, die ich nicht brauchte. Wir werden sehen.
4. Security Definer Funktionen sind mächtig
SECURITY DEFINER lässt Funktionen mit erhöhten Berechtigungen laufen und respektiert dabei trotzdem RLS-Richtlinien. Wesentlich für Router-Funktionen.
5. Migrationen sind es wert (wahrscheinlich)
33 Migrationen für Schema Routing fühlten sich nach viel an. Aber das Ergebnis? Saubere Architektur, echte Isolation und null Schema-übergreifende Bugs.
Erkenntnis: Technische Schulden haben einen Preis. Ich entschied mich, ihn früh zu zahlen, als ich 15 Alpha-Nutzer habe, statt zu warten, bis ich zahlende Kunden habe. War das der richtige Aufruf? Ich werde es wissen, wenn ich irgendwann 100 Kunden habe, um die ich mir Sorgen machen muss.
6. Architekturelle Fehler schmerzen mehr als Code-Bugs
Einen Null-Pointer-Exception um 23:47 Uhr zu finden? Nervig. Herauszufinden, dass deine gesamte Multi-Tenant-Architektur grundlegend gebrochen ist? Das ist die Art von Entdeckung, die einen nachts wach hält.
Aber hier ist, was ich gelernt habe: Architekturelle Fehler sind behebbar. Sie sind teuer, ja. Zeitaufwändig, absolut. Aber ich ging von „ein Tenant-Typ kann versehentlich die Daten eines anderen kontaminieren" zu „datenbank-erzwungene Isolation, die unmöglich zu umgehen ist".
Früh beheben, richtig beheben – und du schläfst besser.
7. Architektur könnte Strategie sein (oder vielleicht nur Over-Engineering)
Die Schema-Routing-Entscheidung ging nicht nur um „sauberen Code". Es ging darum, zukünftige Optionen offenzuhalten – White-Label-Partnerschaften, Enterprise-Vertrieb, internationale Expansion.
Aber hier ist die ehrliche Wahrheit: Ich bin in der privaten Alpha mit 15 Nutzern. Ich habe keine anklopfenden Enterprise-Kunden. Ich habe keine einzige DSGVO-Frage bekommen. Die Partnerschaften, die ich mir vorstelle, materialisieren sich vielleicht nie.
Ich dachte, ich träffe eine technische Entscheidung. Vielleicht traf ich eine Geschäftsstrategie-Entscheidung. Oder vielleicht war es einfach Over-Engineering, weil ich Datenbankarchitektur interessant finde. :)
Hast du jemals einen architekturellen Fehler entdeckt, der kein Bug war, sondern ein Designfehler? Wie hast du den Neubau angegangen – hast du inkrementell gefixt oder alles rausgerissen wie ich?
Viele Grüße,
Chandler
Die STRAŦUM-Architektur-Serie: Das ist Teil 2 der Multi-Tenancy-Reise. Sie begann mit dem Aufbau von Multi-Tenancy an Tag 2. Nach dem Schema-Neubau entdeckte ich 31 leere Bildschirme durch verlorenen Navigationskontext und dass meine Datenbank korrekt aber 296x zu langsam war.
---
Baust du Multi-Tenant-SaaS mit komplexen Isolationsanforderungen? STRAŦUM verwendet Schema Routing, um verschiedene Tenant-Typen mit echter Datenisolation zu bedienen. Alpha-Zugang beantragen unter https://stratum.chandlernguyen.com/request-invitation
---
*Lerne immer noch, dass „Multi-Tenant" viele Isolationslevel hat. Debugge immer noch RLS-Richtlinien um Mitternacht. Hinterfrage immer noch meine Tag-2-Architekturentscheidungen (aber weniger als vorher). Mehr Datenbankabenteuern auf https://www.chandlernguyen.com/ .
---





