Zum Hauptinhalt springen

Wie aus einem Wort ein Mandantenmodell wurde

Ein Kunde bezahlt für Funktionalität. Wie Event Sourcing erlaubt, Anmeldung und Mandantentrennung drei Monate offen zu lassen und dann ohne Migration nachzuziehen.

· Von Rebar Hassan · 2 Min. Lesezeit

Ein Kunde bezahlt für das, was seine Software für ihn tut: für den Vorgang, der durchläuft, für das Dokument, das am Ende stimmt. Anmeldung, Rollen und Mandantentrennung bezahlt er mit, und am liebsten hätte er sie umsonst. Ich habe sie ihm drei Monate lang erspart und in dieser Zeit ausgeliefert, was ihm etwas bringt. Dass das ging, ohne später teuer zu werden, liegt an einem einzigen Wort, das am ersten Tag im Code stand.

Die Software, um die es geht, baue ich seit Juni für einen Kunden. Sie nimmt Dokumente an, zieht daraus strukturierte Daten und liefert am Ende etwas aus, mit dem der Kunde arbeitet. Was sie fachlich tut, spielt für diesen Text keine Rolle. Wichtig ist, wie sie gebaut ist: event-sourced. Es gibt eine einzige Tabelle in Postgres, an die Ereignisse angehängt werden, keines wird je überschrieben. Jedes Ereignis trägt Scopes, kleine Schlüssel-Wert-Paare, die sagen, wozu es gehört, und jede Abfrage filtert über genau diese Scopes. Alles, was das System weiß, ist aus diesen Ereignissen gefaltet. Mehr Kontext braucht der Rest nicht.

Am 9. Juni 2026, ganz am Anfang, bekam diese Software eine Naht, die damals niemand brauchte: eine tenantId. Sie hatte genau einen Wert, default, und trotzdem ging sie durch alles hindurch. Jedes Ereignis, das append() in die Postgres-Tabelle schrieb, bekam sie in seine scopes gestempelt. Jede query() filterte auf sie, per JSONB-Containment, wie auf jede andere Scope auch. Der Wert selbst lebte in einem AsyncLocalStorage statt in einem Parameter: runWithTenant(id, fn) setzt ihn, getTenantId() liest ihn, und kein Service musste ihn je in der Hand halten. 113 Routen wurden in defineTenantHandler gewickelt, ein Cookie trug die Wahl, ein Freitextfeld in der Oberfläche ließ einen beliebigen String tippen.

Das war Mehrmandantenfähigkeit, nur ohne Mandanten. Die Trennung der Daten war vollständig: zwei Strings, zwei getrennte Welten im selben Eventstore, keine Zeile Code wusste davon. Die Isolation war fertig. Was fehlte, war die Bedeutung: Wer darf welchen String tippen? Wer ist drin, wer nicht, was darf er dort? Der Kommentar in tenantContext.ts sagte es wörtlich voraus: „When auth lands, a request middleware calls runWithTenant() with the authenticated user's organisation — no data model change required then."

Drei Monate später traf genau das ein. Die Transformation bestand aus dem Hinzufügen von Fakten. Migriert wurde nichts. Fünf Ereignisse, WorkspaceCreated, WorkspaceRenamed, WorkspaceArchived, WorkspaceMembershipGranted, WorkspaceMembershipRevoked, leben unter einem festen Systemmandanten, denn „in welchen Räumen ist diese Person" ist eine Frage über alle Mandanten hinweg, die der Store innerhalb eines Mandanten zu Recht nie beantwortet. Ein Fold macht daraus ein Register; eine reine Funktion decideWorkspaceAccess entscheidet pro Anfrage, ob der angeforderte Schlüssel betretbar ist und mit welcher Rolle. Die Middleware, die früher den Cookie durchreichte, ruft jetzt diese Funktion und setzt danach denselben runWithTenant wie zuvor. Darunter blieb alles, wie es war.

Kein bestehendes Ereignis wurde angefasst. default ist weiterhin das Wort, auf das jedes historische Ereignis zeigt; es wurde nur durch eine sichtbare Handlung, „diesen Arbeitsbereich übernehmen", vom getippten String zum Fakt mit Owner. Neue Arbeitsbereiche tragen als Schlüssel die ID ihres Gründungsereignisses; ein Name ist für Menschen, umbenennbar und absichtlich nicht eindeutig, weil „dieser Name ist belegt" einem Kunden verraten würde, dass ein anderer auf derselben Instanz ist.

Warum war das so einfach? Weil die teure Entscheidung schon getroffen war, als sie noch nichts kostete. Ereignisse als einzige Wahrheit, Scopes als Containment, der Mandant als Kontext statt als Parameter: Das sind drei Eigenschaften, die man nachträglich nur mit einer Migration bekommt und vorab mit einem Wort. Rollen und Mitgliedschaften sind dann nur weitere Ereignisse, und ein Rechtemodell ist eine Tabelle can(role, action) plus ein assertCan an der Route, die es braucht. Nichts davon musste die Pipeline, die Extraktion, die Auslieferung oder den Stream kennen.

Das ist der Grund, warum die Flanke offen bleiben durfte. Der Kunde bekam drei Monate lang Funktionalität statt Anmeldemasken, und als die Tür gebraucht wurde, kostete sie fünf Ereignisse und eine Funktion. Die Reihenfolge wird frei: erst der Mehrwert, dann die Tür.

Aktualisiert am 10. Sep. 2026