Files
Portfolio/blog-posts/85-multi-tenancy-architecture.md
T
damjan_savicandClaude Opus 4.5 e1bbe5455d Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit:
- Blog-System mit 100+ Artikeln
- Supabase-Integration
- Responsive Design mit Tailwind CSS
- TypeScript-Konfiguration
- Testing-Setup mit Vitest und Playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:07:20 +01:00

620 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Multi-Tenancy Architecture
**Meta-Description:** Multi-Tenant SaaS Architektur. Database Isolation, Tenant Context, Row-Level Security und Scaling Strategien.
**Keywords:** Multi-Tenancy, SaaS Architecture, Database Isolation, Row-Level Security, Tenant, B2B, Scaling
---
## Einführung
**Multi-Tenancy** ermöglicht es, eine Anwendung für viele Kunden (Tenants) zu betreiben. Von **Database per Tenant** bis **Row-Level Security** die richtige Strategie hängt von Sicherheit, Kosten und Skalierung ab.
---
## Multi-Tenancy Overview
```
┌─────────────────────────────────────────────────────────────┐
│ MULTI-TENANCY STRATEGIES │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. DATABASE PER TENANT: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [App] → [Tenant A DB] [Tenant B DB] [Tenant C DB] │ │
│ │ │ │
│ │ ✓ Maximum isolation │ │
│ │ ✓ Easy data migration │ │
│ │ ✗ Higher infrastructure cost │ │
│ │ ✗ Complex connection management │ │
│ │ Use case: Enterprise, regulated industries │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 2. SCHEMA PER TENANT: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Database] │ │
│ │ ├── schema_tenant_a │ │
│ │ ├── schema_tenant_b │ │
│ │ └── schema_tenant_c │ │
│ │ │ │
│ │ ✓ Good isolation │ │
│ │ ✓ Easier backup per tenant │ │
│ │ ✗ Schema migrations complexity │ │
│ │ Use case: Mid-size SaaS │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3. SHARED DATABASE (Row-Level): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Database] │ │
│ │ └── [Tables with tenant_id column] │ │
│ │ ├── users (tenant_id, ...) │ │
│ │ ├── projects (tenant_id, ...) │ │
│ │ └── documents (tenant_id, ...) │ │
│ │ │ │
│ │ ✓ Cost efficient │ │
│ │ ✓ Simple deployment │ │
│ │ ✗ Requires careful query design │ │
│ │ Use case: SMB SaaS, high tenant count │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 4. HYBRID APPROACH: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ - Shared DB for small tenants │ │
│ │ - Dedicated DB for enterprise tenants │ │
│ │ ✓ Flexible pricing tiers │ │
│ │ ✓ Meet enterprise requirements │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Tenant Context
```typescript
// lib/tenant/context.ts
import { AsyncLocalStorage } from 'async_hooks';
export interface TenantContext {
tenantId: string;
tenantSlug: string;
plan: string;
features: string[];
limits: Record<string, number>;
}
const tenantStorage = new AsyncLocalStorage<TenantContext>();
export function getTenantContext(): TenantContext {
const context = tenantStorage.getStore();
if (!context) {
throw new Error('Tenant context not initialized');
}
return context;
}
export function runWithTenant<T>(
tenant: TenantContext,
fn: () => T
): T {
return tenantStorage.run(tenant, fn);
}
// Optional: Get tenant without throwing
export function getTenantContextSafe(): TenantContext | null {
return tenantStorage.getStore() || null;
}
```
```typescript
// middleware.ts - Tenant Resolution
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Strategy 1: Subdomain-based
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
response.headers.set('x-tenant-slug', subdomain);
}
// Strategy 2: Path-based (/org/tenant-slug/...)
const pathname = request.nextUrl.pathname;
const pathMatch = pathname.match(/^\/org\/([^\/]+)/);
if (pathMatch) {
response.headers.set('x-tenant-slug', pathMatch[1]);
}
// Strategy 3: Header-based (for API)
const headerTenant = request.headers.get('x-tenant-id');
if (headerTenant) {
response.headers.set('x-tenant-id', headerTenant);
}
return response;
}
// lib/tenant/resolver.ts
import { db } from '@/lib/db';
import { TenantContext } from './context';
export async function resolveTenant(
identifier: string,
type: 'slug' | 'id' = 'slug'
): Promise<TenantContext | null> {
const tenant = await db.tenant.findUnique({
where: type === 'slug' ? { slug: identifier } : { id: identifier },
include: {
subscription: true,
features: true
}
});
if (!tenant) return null;
return {
tenantId: tenant.id,
tenantSlug: tenant.slug,
plan: tenant.subscription?.plan || 'free',
features: tenant.features.map(f => f.name),
limits: {
users: tenant.subscription?.userLimit || 5,
storage: tenant.subscription?.storageLimit || 1024,
projects: tenant.subscription?.projectLimit || 10
}
};
}
```
---
## Database with Row-Level Security
```typescript
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
createdAt DateTime @default(now())
// Relations
users User[]
projects Project[]
subscription Subscription?
}
model User {
id String @id @default(cuid())
email String
name String?
// Tenant relation
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, email])
@@index([tenantId])
}
model Project {
id String @id @default(cuid())
name String
description String?
// Tenant relation
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
// Other relations
documents Document[]
@@index([tenantId])
}
model Document {
id String @id @default(cuid())
title String
content String?
// Both tenant and project for redundant filtering
tenantId String
projectId String
project Project @relation(fields: [projectId], references: [id])
@@index([tenantId])
@@index([projectId])
}
```
```typescript
// lib/db/tenant-client.ts
import { PrismaClient } from '@prisma/client';
import { getTenantContext } from '@/lib/tenant/context';
// Extend Prisma with tenant filtering
function createTenantPrismaClient() {
const prisma = new PrismaClient();
return prisma.$extends({
query: {
$allModels: {
async findMany({ model, operation, args, query }) {
const context = getTenantContext();
// Models that need tenant filtering
const tenantModels = ['User', 'Project', 'Document', 'Team'];
if (tenantModels.includes(model)) {
args.where = {
...args.where,
tenantId: context.tenantId
};
}
return query(args);
},
async findFirst({ model, operation, args, query }) {
const context = getTenantContext();
const tenantModels = ['User', 'Project', 'Document', 'Team'];
if (tenantModels.includes(model)) {
args.where = {
...args.where,
tenantId: context.tenantId
};
}
return query(args);
},
async create({ model, operation, args, query }) {
const context = getTenantContext();
const tenantModels = ['User', 'Project', 'Document', 'Team'];
if (tenantModels.includes(model)) {
args.data = {
...args.data,
tenantId: context.tenantId
};
}
return query(args);
},
async update({ model, operation, args, query }) {
const context = getTenantContext();
const tenantModels = ['User', 'Project', 'Document', 'Team'];
if (tenantModels.includes(model)) {
args.where = {
...args.where,
tenantId: context.tenantId
};
}
return query(args);
},
async delete({ model, operation, args, query }) {
const context = getTenantContext();
const tenantModels = ['User', 'Project', 'Document', 'Team'];
if (tenantModels.includes(model)) {
args.where = {
...args.where,
tenantId: context.tenantId
};
}
return query(args);
}
}
}
});
}
export const tenantDb = createTenantPrismaClient();
```
---
## PostgreSQL Row-Level Security
```sql
-- Enable RLS on tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation_projects ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation_documents ON documents
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Function to set tenant context
CREATE OR REPLACE FUNCTION set_tenant_context(tenant_id uuid)
RETURNS void AS $$
BEGIN
PERFORM set_config('app.current_tenant_id', tenant_id::text, false);
END;
$$ LANGUAGE plpgsql;
```
```typescript
// lib/db/rls-client.ts
import { Pool } from 'pg';
import { getTenantContext } from '@/lib/tenant/context';
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
export async function query<T>(
sql: string,
params?: unknown[]
): Promise<T[]> {
const client = await pool.connect();
const context = getTenantContext();
try {
// Set tenant context for RLS
await client.query(
"SELECT set_config('app.current_tenant_id', $1, true)",
[context.tenantId]
);
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
client.release();
}
}
// Usage
const projects = await query<Project>(
'SELECT * FROM projects WHERE status = $1',
['active']
);
// RLS automatically filters by tenant!
```
---
## API Routes with Tenant
```typescript
// app/api/projects/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { resolveTenant, runWithTenant } from '@/lib/tenant';
import { tenantDb } from '@/lib/db/tenant-client';
export async function GET(request: Request) {
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug');
if (!tenantSlug) {
return NextResponse.json({ error: 'Tenant not found' }, { status: 400 });
}
const tenant = await resolveTenant(tenantSlug);
if (!tenant) {
return NextResponse.json({ error: 'Invalid tenant' }, { status: 404 });
}
// Run with tenant context
const projects = await runWithTenant(tenant, async () => {
return tenantDb.project.findMany({
orderBy: { createdAt: 'desc' }
});
});
return NextResponse.json(projects);
}
export async function POST(request: Request) {
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug');
const tenant = await resolveTenant(tenantSlug!);
if (!tenant) {
return NextResponse.json({ error: 'Invalid tenant' }, { status: 404 });
}
const body = await request.json();
// Check limits
const projectCount = await runWithTenant(tenant, async () => {
return tenantDb.project.count();
});
if (tenant.limits.projects !== -1 && projectCount >= tenant.limits.projects) {
return NextResponse.json(
{ error: 'Project limit reached. Please upgrade your plan.' },
{ status: 403 }
);
}
const project = await runWithTenant(tenant, async () => {
return tenantDb.project.create({
data: {
name: body.name,
description: body.description
}
});
});
return NextResponse.json(project, { status: 201 });
}
```
---
## Tenant Provisioning
```typescript
// lib/tenant/provisioning.ts
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
interface CreateTenantInput {
name: string;
slug: string;
ownerEmail: string;
ownerName: string;
plan?: string;
}
export async function provisionTenant(input: CreateTenantInput) {
// Validate slug
const existingTenant = await db.tenant.findUnique({
where: { slug: input.slug }
});
if (existingTenant) {
throw new Error('Tenant slug already exists');
}
// Create Stripe customer
const stripeCustomer = await stripe.customers.create({
email: input.ownerEmail,
name: input.name,
metadata: {
tenantSlug: input.slug
}
});
// Create tenant with owner in transaction
const tenant = await db.$transaction(async (tx) => {
// Create tenant
const newTenant = await tx.tenant.create({
data: {
name: input.name,
slug: input.slug,
stripeCustomerId: stripeCustomer.id,
settings: {
create: {
theme: 'light',
timezone: 'Europe/Berlin'
}
}
}
});
// Create owner user
await tx.user.create({
data: {
email: input.ownerEmail,
name: input.ownerName,
tenantId: newTenant.id,
role: 'owner'
}
});
// Create default resources
await tx.project.create({
data: {
name: 'My First Project',
tenantId: newTenant.id
}
});
return newTenant;
});
// Send welcome email
await sendWelcomeEmail(input.ownerEmail, tenant.slug);
return tenant;
}
// Tenant deletion (with data cleanup)
export async function deprovisionTenant(tenantId: string) {
const tenant = await db.tenant.findUnique({
where: { id: tenantId },
include: { subscription: true }
});
if (!tenant) {
throw new Error('Tenant not found');
}
// Cancel Stripe subscription
if (tenant.subscription?.stripeSubscriptionId) {
await stripe.subscriptions.cancel(tenant.subscription.stripeSubscriptionId);
}
// Delete all tenant data in transaction
await db.$transaction([
db.document.deleteMany({ where: { tenantId } }),
db.project.deleteMany({ where: { tenantId } }),
db.user.deleteMany({ where: { tenantId } }),
db.subscription.deleteMany({ where: { tenantId } }),
db.tenantSettings.deleteMany({ where: { tenantId } }),
db.tenant.delete({ where: { id: tenantId } })
]);
// Archive data (optional)
await archiveTenantData(tenantId);
}
```
---
## Strategy Comparison
| Strategy | Isolation | Cost | Complexity | Best For |
|----------|-----------|------|------------|----------|
| **DB per Tenant** | Highest | High | High | Enterprise |
| **Schema per Tenant** | High | Medium | Medium | Mid-market |
| **Shared + RLS** | Medium | Low | Medium | SMB/Startup |
| **Hybrid** | Flexible | Variable | High | Mixed segments |
---
## Fazit
Multi-Tenancy Architektur erfordert:
1. **Isolation Strategy**: Passend zum Use Case wählen
2. **Tenant Context**: Durchgängig in der Anwendung
3. **Security**: RLS oder Application-Level Filtering
4. **Provisioning**: Automatisierte Tenant-Erstellung
Die richtige Strategie hängt von Kunden und Skalierung ab.
---
## Bildprompts
1. "Multi-tenant architecture diagram, database isolation strategies"
2. "Tenant provisioning flow, signup to activation"
3. "Row-level security visualization, data filtering per tenant"
---
## Quellen
- [Microsoft Multi-Tenancy Patterns](https://docs.microsoft.com/en-us/azure/architecture/guide/multitenant/overview)
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Prisma Multi-Tenancy](https://www.prisma.io/docs/guides/other/multi-tenancy)
- [AWS Multi-Tenant SaaS](https://aws.amazon.com/blogs/apn/building-a-multi-tenant-saas-solution-using-aws-serverless-services/)