Multi-Tenant-Architekturen
Technologie & Architektur

Multi-Tenant-Architekturen: Eine Codebasis, viele Kunden

Carola Schulte
13. Oktober 2025
22 min

Sie haben eine erfolgreiche Business-App – und plötzlich wollen 50 Kunden sie nutzen. Kopieren Sie den Code 50-mal? Natürlich nicht. Multi-Tenant-Architektur ist die Antwort. Nach dutzenden mandantenfähigen Systemen zeige ich, welcher Ansatz wann der richtige ist.

TL;DR – Die Kurzfassung

  • Shared Database + Tenant-ID: Einfachster Start, aber Isolation nur auf Applikationsebene
  • Shared Database + Separate Schemas: Bessere Isolation, komplexere Migrations
  • Database per Tenant: Maximale Isolation, höchster Aufwand
  • Hybrid: Kleine Kunden shared, Enterprise-Kunden isoliert
  • Goldene Regel: Tenant-Context muss unveränderlich durch jeden Request fließen

Was ist Multi-Tenancy?

Multi-Tenancy bedeutet: Eine Applikation, eine Codebasis, ein Deployment – aber viele voneinander isolierte Kunden (Tenants). Jeder Tenant sieht nur seine eigenen Daten, als wäre die App exklusiv für ihn.

┌─────────────────────────────────────────────────────────────────┐
│                     MULTI-TENANT vs. SINGLE-TENANT              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  SINGLE-TENANT (pro Kunde eine Instanz)                         │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐                         │
│  │ App     │  │ App     │  │ App     │                         │
│  │ Kunde A │  │ Kunde B │  │ Kunde C │   → 3x Wartung          │
│  │ DB A    │  │ DB B    │  │ DB C    │   → 3x Deployments      │
│  └─────────┘  └─────────┘  └─────────┘   → 3x Server           │
│                                                                  │
│  MULTI-TENANT (eine Instanz für alle)                           │
│  ┌─────────────────────────────────────┐                        │
│  │            Applikation              │                        │
│  │  ┌───────┬───────┬───────┐         │   → 1x Wartung         │
│  │  │ A     │ B     │ C     │ Tenants │   → 1x Deployment      │
│  │  └───────┴───────┴───────┘         │   → 1x Server          │
│  └─────────────────────────────────────┘                        │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Die drei Haupt-Strategien

StrategieIsolationKomplexitätKostenUse Case
Shared DB + Tenant-IDNiedrigNiedrigNiedrigSaaS-Startups, <100 Tenants
Shared DB + Schema/TenantMittelMittelMittelRegulierte Branchen
Database per TenantHochHochHochEnterprise, Compliance

Strategie 1: Shared Database mit Tenant-ID

Der einfachste und häufigste Ansatz: Alle Tenants teilen sich eine Datenbank. Jede Tabelle hat eine tenant_id-Spalte.

Datenbank-Schema

-- Tenants-Tabelle
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug VARCHAR(50) UNIQUE NOT NULL,  -- für Subdomain: acme.app.de
    name VARCHAR(255) NOT NULL,
    plan VARCHAR(50) DEFAULT 'starter',
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMP DEFAULT NOW(),

    -- Constraints
    CONSTRAINT valid_slug CHECK (slug ~ '^[a-z0-9-]+$')
);

-- Jede Business-Tabelle hat tenant_id
CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL
    -- ...weitere Felder
);

-- Hinweis: ON DELETE CASCADE ist bei SaaS oft okay, bei Enterprise/Compliance
-- häufig unerwünscht (Audit-Trail, Datenaufbewahrungspflichten).

-- Composite Index für alle Tenant-Queries
CREATE INDEX idx_projects_tenant ON projects(tenant_id, id);
CREATE INDEX idx_projects_tenant_name ON projects(tenant_id, name);

PHP: Tenant-Context als Service

<?php

declare(strict_types=1);

final class Tenant
{
    public function __construct(
        public readonly string $id,
        public readonly string $slug,
        public readonly string $name,
        public readonly string $plan,
        public readonly array $settings,
    ) {}
}

final class TenantContext
{
    private ?Tenant $tenant = null;

    public function set(Tenant $tenant): void
    {
        if ($this->tenant !== null) {
            throw new LogicException('Tenant already set for this request');
        }
        $this->tenant = $tenant;
    }

    public function get(): Tenant
    {
        if ($this->tenant === null) {
            throw new RuntimeException('No tenant in context');
        }
        return $this->tenant;
    }

    public function getId(): string
    {
        return $this->get()->id;
    }

    public function has(): bool
    {
        return $this->tenant !== null;
    }

    // WICHTIG: Bei long-lived Prozessen (RoadRunner, Swoole, Worker)
    // muss der Context am Request-Ende resettet werden!
    public function reset(): void
    {
        $this->tenant = null;
    }
}

Wichtig bei Long-Lived Prozessen: Bei PHP-FPM ist jeder Request isoliert. Bei RoadRunner, Swoole oder Queue-Workern bleibt der Prozess bestehen – reset() muss am Request-Ende aufgerufen werden, sonst "klebt" Tenant A im Memory.

Middleware: Tenant aus Subdomain ermitteln

<?php

final class TenantMiddleware
{
    public function __construct(
        private TenantRepository $tenants,
        private TenantContext $context,
    ) {}

    public function __invoke(Request $request, callable $next): Response
    {
        $slug = $this->extractSlug($request);

        if ($slug === null) {
            return new Response(400, 'Tenant not identifiable');
        }

        $tenant = $this->tenants->findBySlug($slug);

        if ($tenant === null) {
            return new Response(404, 'Tenant not found');
        }

        if ($tenant->isSuspended()) {
            return new Response(403, 'Account suspended');
        }

        $this->context->set($tenant);

        return $next($request);
    }

    private function extractSlug(Request $request): ?string
    {
        $host = strtolower($request->getHost()); // Immer lowercase!

        // acme.meineapp.de → acme
        if (preg_match('/^([a-z0-9-]+)\.meineapp\.de$/', $host, $m)) {
            return $m[1];
        }

        return null; // Public Web: NUR aus Host/Subdomain!
    }
}

Security-Warnung zu X-Tenant-ID Header: Niemals den Tenant direkt aus einem freien HTTP-Header lesen! Ein Angreifer kann X-Tenant-ID: anderer-kunde setzen und fremde Daten sehen.

  • Public Web: Tenant immer aus Host/Subdomain
  • APIs: Tenant aus JWT-Claims oder API-Key→Tenant Lookup, nie aus freiem Header
  • Internes API-Gateway: Header nur wenn technisch erzwungen (mTLS, private Network, WAF-Rule)

Repository mit automatischer Tenant-Filterung

<?php

abstract class TenantAwareRepository
{
    public function __construct(
        protected PDO $db,
        protected TenantContext $tenant,
    ) {}

    protected function tenantCondition(): string
    {
        return 'tenant_id = :tenant_id';
    }

    protected function tenantParams(): array
    {
        return ['tenant_id' => $this->tenant->getId()];
    }
}

final class ProjectRepository extends TenantAwareRepository
{
    public function findAll(): array
    {
        $sql = "SELECT * FROM projects
                WHERE {$this->tenantCondition()}
                ORDER BY created_at DESC";

        $stmt = $this->db->prepare($sql);
        $stmt->execute($this->tenantParams());

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function findById(string $id): ?array
    {
        // KRITISCH: Immer tenant_id prüfen, auch bei ID-Lookup!
        $sql = "SELECT * FROM projects
                WHERE id = :id AND {$this->tenantCondition()}";

        $stmt = $this->db->prepare($sql);
        $stmt->execute(['id' => $id, ...$this->tenantParams()]);

        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }

    public function create(array $data): string
    {
        // tenant_id automatisch setzen
        $data['tenant_id'] = $this->tenant->getId();
        $data['id'] = Uuid::v4();

        $columns = implode(', ', array_keys($data));
        $placeholders = ':' . implode(', :', array_keys($data));

        $sql = "INSERT INTO projects ({$columns}) VALUES ({$placeholders})";

        $stmt = $this->db->prepare($sql);
        $stmt->execute($data);

        return $data['id'];
    }
}

Sicherheitsnetz: Row-Level Security (PostgreSQL)

-- Zusätzliche Absicherung auf DB-Ebene
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: User sieht nur eigenen Tenant (fail-closed!)
CREATE POLICY tenant_isolation ON projects
    USING (
        tenant_id = current_setting('app.current_tenant', true)::uuid
    );

Fail-Closed: Der zweite Parameter true bei current_setting() verhindert einen Error wenn das Setting fehlt – stattdessen gibt's NULL. Da NULL = uuid immer false ist, wird der Zugriff blockiert. Ohne true fliegt dir ein Error um die Ohren.

// Transaction-Wrapper Pattern für RLS
$db->beginTransaction();
try {
    $db->exec("SET LOCAL app.current_tenant = " . $db->quote($tenantId));

    // ... Queries hier ...

    $db->commit();
} catch (\Throwable $e) {
    $db->rollBack();
    throw $e;
}

Vorteil RLS: Selbst wenn ein Bug in der Applikation die tenant_id-Prüfung vergisst, blockiert PostgreSQL den Zugriff. Defense in Depth.

Wichtig: SET LOCAL statt SET verwenden! Bei Connection Pooling (z.B. PgBouncer) könnte ein SET ohne LOCAL die Session "vergiften" und der nächste Request sieht den falschen Tenant.

Strategie 2: Separate Schemas pro Tenant

Jeder Tenant bekommt ein eigenes PostgreSQL-Schema innerhalb derselben Datenbank. Bessere Isolation, aber komplexere Migrations.

Schema-Erstellung bei Tenant-Onboarding

<?php

final class TenantProvisioner
{
    public function __construct(
        private PDO $db,
        private string $templateSchema = 'tenant_template',
    ) {}

    public function provision(string $tenantSlug): void
    {
        $schema = $this->schemaName($tenantSlug);

        $this->db->beginTransaction();

        try {
            // Schema aus Template klonen
            $this->db->exec("CREATE SCHEMA {$schema}");

            // Alle Tabellen aus Template kopieren
            $tables = $this->getTemplateTables();
            foreach ($tables as $table) {
                $this->db->exec(
                    "CREATE TABLE {$schema}.{$table}
                     (LIKE {$this->templateSchema}.{$table} INCLUDING ALL)"
                );
            }

            $this->db->commit();
        } catch (Throwable $e) {
            $this->db->rollBack();
            throw new TenantProvisioningException($e->getMessage(), 0, $e);
        }
    }

    public function schemaName(string $slug): string
    {
        // tenant_acme, tenant_globex, etc.
        return 'tenant_' . preg_replace('/[^a-z0-9]/', '_', $slug);
    }

    // PRODUKTIONS-HINWEIS: Besser eine Whitelist/Mapping aus der DB!
    // Schema-Namen sollten nicht dynamisch aus User-Input gebaut werden.
    // Stattdessen: tenants.schema_name Spalte, die bei Onboarding gesetzt wird.

    private function getTemplateTables(): array
    {
        $sql = "SELECT tablename FROM pg_tables
                WHERE schemaname = :schema";
        $stmt = $this->db->prepare($sql);
        $stmt->execute(['schema' => $this->templateSchema]);

        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
}

Search Path dynamisch setzen

<?php

final class SchemaBasedTenantMiddleware
{
    public function __construct(
        private PDO $db,
        private TenantContext $context,
        private TenantProvisioner $provisioner,
    ) {}

    public function __invoke(Request $request, callable $next): Response
    {
        $tenant = $this->context->get();
        $schema = $this->provisioner->schemaName($tenant->slug);

        // Search Path auf Tenant-Schema setzen
        $this->db->exec("SET search_path TO {$schema}, public");

        // Ab jetzt gehen alle Queries automatisch ins richtige Schema
        return $next($request);
    }
}

Achtung bei PgBouncer: SET search_path ist session-level und funktioniert nicht zuverlässig mit PgBouncer im transaction Pooling-Modus. Alternativen:

  • session Pooling-Modus (weniger performant, mehr Connections)
  • Alle Queries explizit mit Schema-Prefix: SELECT * FROM {$schema}.projects
  • Separate Pools pro Tenant mit search_path in der Connection-Config

Migrations für alle Schemas

<?php

final class MultiSchemasMigrator
{
    public function migrate(string $migrationSql): void
    {
        $schemas = $this->getAllTenantSchemas();

        foreach ($schemas as $schema) {
            $this->db->exec("SET search_path TO {$schema}");

            try {
                $this->db->exec($migrationSql);
                echo "✓ {$schema}\n";
            } catch (PDOException $e) {
                echo "✗ {$schema}: {$e->getMessage()}\n";
                // Entscheidung: Abbrechen oder weitermachen?
            }
        }
    }

    private function getAllTenantSchemas(): array
    {
        $sql = "SELECT schema_name FROM information_schema.schemata
                WHERE schema_name LIKE 'tenant_%'";
        return $this->db->query($sql)->fetchAll(PDO::FETCH_COLUMN);
    }
}

Strategie 3: Database per Tenant

Maximale Isolation: Jeder Tenant bekommt seine eigene Datenbank. Typisch für Enterprise-Kunden mit Compliance-Anforderungen oder wenn Kunden ihre eigene DB-Instanz verlangen.

Connection Pool / Factory

<?php

final class TenantDatabaseFactory
{
    private array $connections = [];

    public function __construct(
        private TenantContext $context,
        private TenantRepository $tenants,
    ) {}

    public function getConnection(): PDO
    {
        $tenantId = $this->context->getId();

        if (!isset($this->connections[$tenantId])) {
            $this->connections[$tenantId] = $this->createConnection($tenantId);
        }

        return $this->connections[$tenantId];
    }

    private function createConnection(string $tenantId): PDO
    {
        $tenant = $this->tenants->findById($tenantId);
        $config = $tenant->getDatabaseConfig();

        // Jeder Tenant hat eigene DB-Credentials
        $dsn = sprintf(
            'pgsql:host=%s;port=%d;dbname=%s',
            $config['host'],
            $config['port'],
            $config['database']
        );

        return new PDO($dsn, $config['user'], $config['password'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
    }
}

Tenant-Datenbank provisionieren

<?php

final class DatabasePerTenantProvisioner
{
    public function __construct(
        private PDO $adminDb,  // Verbindung mit CREATE DATABASE Rechten
        private string $templateDb = 'app_template',
    ) {}

    public function provision(Tenant $tenant): array
    {
        $dbName = 'app_' . $tenant->slug;
        $dbUser = 'user_' . $tenant->slug;
        $dbPass = bin2hex(random_bytes(16));

        // Datenbank aus Template erstellen
        $this->adminDb->exec(
            "CREATE DATABASE {$dbName} TEMPLATE {$this->templateDb}"
        );

        // Eigener DB-User für Tenant
        $this->adminDb->exec(
            "CREATE USER {$dbUser} WITH PASSWORD '{$dbPass}'"
        );
        $this->adminDb->exec(
            "GRANT ALL PRIVILEGES ON DATABASE {$dbName} TO {$dbUser}"
        );

        return [
            'host' => 'localhost',
            'port' => 5432,
            'database' => $dbName,
            'user' => $dbUser,
            'password' => $dbPass,  // Verschlüsselt speichern!
        ];
    }
}

⚠️ Demo-Code – nicht produktionsreif! Das DDL hier zeigt nur das Prinzip. In Produktion:

  • Kein String-Interpolieren: Identifier mit quote_ident() oder format('%I', ...) escapen
  • Passwörter: Niemals in SQL-Strings! Externes Auth (LDAP, IAM) oder Admin-Tool nutzen
  • Provisioning: Besser über dediziertes Admin-Tool, Terraform, oder Migration-Service – nicht aus der App heraus

Hybrid-Ansatz: Das Beste aus beiden Welten

In der Praxis oft am sinnvollsten: Kleine Tenants shared, große Tenants isoliert.

<?php

final class HybridTenantResolver
{
    public function __construct(
        private PDO $sharedDb,
        private TenantDatabaseFactory $dedicatedFactory,
    ) {}

    public function getConnection(Tenant $tenant): PDO
    {
        return match ($tenant->plan) {
            'starter', 'professional' => $this->sharedDb,
            'enterprise' => $this->dedicatedFactory->getConnection(),
            default => $this->sharedDb,
        };
    }
}

Tenant-spezifische Anpassungen

Feature Flags pro Tenant

<?php

final class TenantFeatures
{
    public function __construct(
        private TenantContext $context,
    ) {}

    public function isEnabled(string $feature): bool
    {
        $tenant = $this->context->get();

        // Plan-basierte Features
        $planFeatures = [
            'starter' => ['basic_reports'],
            'professional' => ['basic_reports', 'api_access', 'custom_fields'],
            'enterprise' => ['basic_reports', 'api_access', 'custom_fields',
                            'sso', 'audit_log', 'white_label'],
        ];

        // Tenant-spezifische Overrides aus settings
        $overrides = $tenant->settings['features'] ?? [];

        $enabled = $planFeatures[$tenant->plan] ?? [];
        $enabled = array_merge($enabled, $overrides['enable'] ?? []);
        $enabled = array_diff($enabled, $overrides['disable'] ?? []);

        return in_array($feature, $enabled, true);
    }
}

// Nutzung
if ($features->isEnabled('api_access')) {
    // API-Routen registrieren
}

White-Labeling: Custom Branding

<?php

final class TenantBranding
{
    public function __construct(
        private TenantContext $context,
    ) {}

    public function getLogo(): string
    {
        $tenant = $this->context->get();
        return $tenant->settings['branding']['logo']
            ?? '/assets/default-logo.svg';
    }

    public function getPrimaryColor(): string
    {
        return $this->context->get()->settings['branding']['primary_color']
            ?? '#1a355e';
    }

    public function getEmailFrom(): string
    {
        $tenant = $this->context->get();

        // Enterprise: Custom Domain
        if (isset($tenant->settings['email']['from'])) {
            return $tenant->settings['email']['from'];
        }

        // Standard: noreply@tenant.meineapp.de
        return "noreply@{$tenant->slug}.meineapp.de";
    }
}

Security-Kritische Aspekte

1. Niemals Tenant-ID aus User-Input

// ❌ NIEMALS SO
$tenantId = $_GET['tenant_id'];

// ✅ Immer aus verifizierter Quelle
$tenantId = $this->context->getId();  // Gesetzt durch Middleware

2. Jeden Datenzugriff absichern

// ❌ Gefährlich: Nur ID prüfen
public function delete(string $projectId): void
{
    $this->db->exec("DELETE FROM projects WHERE id = '{$projectId}'");
}

// ✅ Sicher: Immer auch tenant_id
public function delete(string $projectId): void
{
    $sql = "DELETE FROM projects WHERE id = :id AND tenant_id = :tenant_id";
    $stmt = $this->db->prepare($sql);
    $stmt->execute([
        'id' => $projectId,
        'tenant_id' => $this->context->getId(),
    ]);

    if ($stmt->rowCount() === 0) {
        throw new NotFoundException('Project not found');
    }
}

3. Cross-Tenant-Leaks in Logs verhindern

<?php

final class TenantAwareLogger implements LoggerInterface
{
    public function __construct(
        private LoggerInterface $inner,
        private TenantContext $context,
    ) {}

    public function info(string $message, array $context = []): void
    {
        // Tenant-ID automatisch in jeden Log-Eintrag
        $context['tenant_id'] = $this->context->has()
            ? $this->context->getId()
            : 'no-tenant';

        $this->inner->info($message, $context);
    }

    // ... weitere Methoden
}

4. Background Jobs: Tenant-Context wiederherstellen

<?php

// Job erstellen: Tenant-ID mitspeichern
$queue->push([
    'job' => 'SendReport',
    'tenant_id' => $this->context->getId(),  // WICHTIG!
    'report_id' => $reportId,
]);

// Job verarbeiten
final class JobProcessor
{
    public function process(array $job): void
    {
        // Tenant-Context für diesen Job setzen
        $tenant = $this->tenants->findById($job['tenant_id']);
        $this->context->set($tenant);

        // Jetzt kann der Job sicher ausgeführt werden
        $handler = $this->resolveHandler($job['job']);
        $handler->handle($job);
    }
}

Performance-Optimierungen

Tenant-Aware Caching

<?php

final class TenantCache
{
    public function __construct(
        private CacheInterface $cache,
        private TenantContext $context,
    ) {}

    public function get(string $key): mixed
    {
        return $this->cache->get($this->prefixKey($key));
    }

    public function set(string $key, mixed $value, int $ttl = 3600): void
    {
        $this->cache->set($this->prefixKey($key), $value, $ttl);
    }

    private function prefixKey(string $key): string
    {
        // tenant:acme:projects:list
        return "tenant:{$this->context->get()->slug}:{$key}";
    }

    public function invalidateTenant(): void
    {
        // Alle Cache-Keys dieses Tenants löschen
        $pattern = "tenant:{$this->context->get()->slug}:*";
        $this->cache->deletePattern($pattern);
    }
}

Connection Pooling bei Database-per-Tenant

// PgBouncer Config für Multi-Tenant
// /etc/pgbouncer/pgbouncer.ini

[databases]
app_acme = host=localhost dbname=app_acme
app_globex = host=localhost dbname=app_globex
; ... oder dynamisch via auth_query

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Testing von Multi-Tenant-Code

<?php

final class ProjectRepositoryTest extends TestCase
{
    private TenantContext $context;
    private ProjectRepository $repo;

    protected function setUp(): void
    {
        // Test-Tenant erstellen
        $this->context = new TenantContext();
        $this->context->set(new Tenant(
            id: 'test-tenant-123',
            slug: 'test',
            name: 'Test Company',
            plan: 'professional',
            settings: [],
        ));

        $this->repo = new ProjectRepository($this->db, $this->context);
    }

    public function testCannotAccessOtherTenantData(): void
    {
        // Projekt für anderen Tenant erstellen
        $this->db->exec("
            INSERT INTO projects (id, tenant_id, name)
            VALUES ('other-project', 'other-tenant', 'Secret Project')
        ");

        // Sollte nicht gefunden werden
        $result = $this->repo->findById('other-project');

        $this->assertNull($result);
    }

    public function testAutomaticTenantIdOnCreate(): void
    {
        $id = $this->repo->create(['name' => 'My Project']);

        $stmt = $this->db->prepare(
            "SELECT tenant_id FROM projects WHERE id = ?"
        );
        $stmt->execute([$id]);

        $this->assertEquals('test-tenant-123', $stmt->fetchColumn());
    }
}

Entscheidungshilfe

┌─────────────────────────────────────────────────────────────────┐
│              WELCHE STRATEGIE FÜR MEIN PROJEKT?                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Anzahl Tenants?                                                │
│  ├── <100 → Shared DB + Tenant-ID (einfachster Start)          │
│  └── >100 → Weiter prüfen ↓                                    │
│                                                                  │
│  Compliance-Anforderungen?                                      │
│  ├── Streng (DSGVO Art. 32, ISO 27001)                         │
│  │   └── Separate Schemas oder DB-per-Tenant                   │
│  └── Normal → Shared DB reicht                                  │
│                                                                  │
│  Enterprise-Kunden mit eigenen Anforderungen?                   │
│  ├── JA → Hybrid (Shared + dediziert für Enterprise)           │
│  └── NEIN → Eine Strategie für alle                            │
│                                                                  │
│  Tenant-Daten stark unterschiedlich groß?                       │
│  ├── JA → DB-per-Tenant (Backup/Restore einfacher)             │
│  └── NEIN → Shared DB performanter                             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Fazit

Multi-Tenancy ist kein Hexenwerk, aber die Details entscheiden über Erfolg oder Datenleck:

  • Shared DB + Tenant-ID: Für die meisten SaaS-Startups der richtige Start
  • Tenant-Context als unveränderliches Request-Objekt: Die wichtigste Architektur-Entscheidung
  • Row-Level Security: Defense in Depth auf Datenbankebene
  • Jeder Query muss tenant_id prüfen: Keine Ausnahmen, auch nicht bei ID-Lookups
  • Hybrid-Ansätze: In der Praxis oft am sinnvollsten

Mein Stack für neue Projekte: Shared DB + Tenant-ID + PostgreSQL RLS. Einfach genug für den Start, sicher genug für Produktion, migrierbar zu Database-per-Tenant wenn ein Enterprise-Kunde es braucht.


Weiterführende Ressourcen

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Multi-Tenant-System geplant?

Lassen Sie uns besprechen, welche Architektur für Ihr SaaS-Projekt die richtige ist – kostenlos und unverbindlich.

Kostenloses Erstgespräch