ADR-002: Multi-Tenant Architecture

Status: ✅ Accepted — 2026-07-03 by CPTO

CPTO Sign-off: 2026-07-03 — Shared DB + RLS ist der richtige Ansatz für MVP. ADR-001 Tech Stack complements this. Implementation muss RLS-Policies in CI/CD integrieren.

Entscheidung: Multi-Tenant Strategie für Cartly SaaS


Kontext

Cartly ist eine SaaS-Plattform für Fashion-Einzelhändler (1–5 Filialen). Alle Kunden teilen sich eine Instanz, müssen aber strikt voneinander isoliert sein. Wir brauchen:


Entscheidung

Gewählter Ansatz: Shared Database + Row-Level Security (RLS)

1. Tenant Identification

Jeder Tenant erhält eine eindeutige tenant_id (UUID v4). Diese ist in ALLEN Tabellen als Foreign Key vorhanden.

-- Zentraler Tenant-Table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  slug        TEXT UNIQUE NOT NULL,          -- URL-friendly identifier
  plan        TEXT NOT NULL DEFAULT 'good',  -- good | better | best
  settings    JSONB DEFAULT '{}',             -- Tenant-spezifische Settings
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

2. Database Schema Pattern

-- Beispiel: products Table
CREATE TABLE products (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  sku         TEXT NOT NULL,
  name        TEXT NOT NULL,
  brand_id    UUID REFERENCES brands(id),
  category    TEXT,
  sizes       TEXT[],        -- z.B. ['XS','S','M','L','XL']
  colors      JSONB,         -- z.B. [{name: 'Navy', hex: '#001f3f'}]
  price       NUMERIC(10,2),
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW(),
  
  -- Constraints
  UNIQUE(tenant_id, sku)
);

-- Index für Performance
CREATE INDEX idx_products_tenant_id ON products(tenant_id);

3. Row-Level Security (RLS)

-- RLS aktivieren
ALTER TABLE products ENABLE ROW LEVEL SECURITY;

-- Policy: Nur eigener Tenant darf lesen
CREATE POLICY tenant_isolation_products_select
  ON products FOR SELECT
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Policy: Nur eigener Tenant darf schreiben
CREATE POLICY tenant_isolation_products_insert
  ON products FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation_products_update
  ON products FOR UPDATE
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation_products_delete
  ON products FOR DELETE
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

4. Application-Layer Tenant Resolution

// Middleware: Tenant aus JWT extrahieren und in Request Context setzen
import { FastifyRequest, FastifyReply } from 'fastify';

async function tenantMiddleware(request: FastifyRequest, reply: FastifyReply) {
  const token = request.user; // JWT ist bereits verifiziert
  
  if (!token?.tenantId) {
    return reply.status(403).send({ error: 'Tenant ID fehlt' });
  }
  
  // Tenant ID im Reply-Kontext setzen (für Prisma)
  reply.locals.tenantId = token.tenantId;
  
  // Für Raw SQL: Set Session Variable (PostgreSQL)
  await request.server.pgClient.query(
    `SET LOCAL app.current_tenant_id = $1`,
    [token.tenantId]
  );
}

// Prisma Middleware für automatischen Tenant-Filter
prisma.$use(async (params, next) => {
  if (params.model && TENANTED_MODELS.includes(params.model)) {
    const tenantId = getCurrentTenantId(); // Aus dem Request Context
    if (tenantId) {
      if (params.action === 'findUnique') {
        params.args.where = { ...params.args.where, tenantId };
      }
      if (params.action === 'findFirst') {
        params.args.where = { ...params.args.where, tenantId };
      }
      if (params.action === 'findMany') {
        params.args.where = { ...params.args.where, tenantId };
      }
    }
  }
  return next(params);
});

5. Connection Pooling

-- PgBouncer configuration (pgbouncer.ini)
[databases]
cartly = host=localhost port=5432 dbname=cartly

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
server_idle_timeout = 600

6. Redis Tenant Isolation

// Redis Keys immer mit Tenant-Prefix
const getKey = (tenantId: string, suffix: string) => 
  `tenant:${tenantId}:${suffix}`;

// Beispiele
`tenant:${tenantId}:session:${sessionId}`
`tenant:${tenantId}:cart:${cartId}`
`tenant:${tenantId}:cache:products:${productId}`

Konsequenzen

Positiv

Negativ


Alternativen

Option B: Database-per-Tenant

Option C: Schema-per-Tenant


Checkliste für Implementation


Verwandte Entscheidungen


Erstellt: 2026-07-03 von Documentation Agent (a66674bf)