Multi-tenancy = Múltiplos clientes (tenants) usam mesma aplicação, mas dados 100% isolados.
Por que multi-tenant:
- ✅ Custo 70% menor (vs instância por cliente)
- ✅ Deploy único (atualiza todos de uma vez)
- ✅ Escala horizontal (adiciona clientes sem infraestrutura nova)
Desafio: Isolar dados sem comprometer segurança ou performance.
Este artigo compara 3 arquiteturas, com código completo e análise de custos.
Os 3 modelos de multi-tenancy
Modelo 1: Database isolado por tenant
Como funciona: Cada cliente tem banco de dados próprio.
Cliente A → Database A
Cliente B → Database B
Cliente C → Database C
Implementação (Prisma + PostgreSQL):
// lib/database/tenant-connection.ts
import { PrismaClient } from '@prisma/client'
const prismaClients = new Map<string, PrismaClient>()
export function getPrismaForTenant(tenantId: string): PrismaClient {
// Reutiliza conexão se já existe
if (prismaClients.has(tenantId)) {
return prismaClients.get(tenantId)!
}
// Cria nova conexão para este tenant
const databaseUrl = `postgresql://user:pass@localhost:5432/tenant_${tenantId}`
const prisma = new PrismaClient({
datasources: {
db: { url: databaseUrl }
}
})
prismaClients.set(tenantId, prisma)
return prisma
}
// Uso em API route
export async function GET(req: Request) {
const tenantId = req.headers.get('X-Tenant-ID')!
const prisma = getPrismaForTenant(tenantId)
const users = await prisma.user.findMany() // Apenas do tenant específico
return Response.json(users)
}
Vantagens:
- ✅ Isolamento total (impossível vazamento entre tenants)
- ✅ Fácil backup/restore por cliente
- ✅ Performance previsível (cada tenant tem recursos próprios)
Desvantagens:
- ❌ Custo alto (N databases = N × custo)
- ❌ Complexidade operacional (migrations em N databases)
- ❌ Limite de conexões (PostgreSQL: ~100 conexões/database)
Quando usar:
- Clientes enterprise (pagam R$ 5K+/mês)
- Compliance rígido (dados em geografias diferentes)
- Performance crítica
Custo (100 clientes):
- 100 databases × R$ 120/mês = R$ 12.000/mês
Modelo 2: Schema isolado por tenant
Como funciona: 1 database, N schemas.
Database único
├── Schema tenant_a
│ ├── users
│ ├── bookings
├── Schema tenant_b
│ ├── users
│ ├── bookings
Implementação:
// lib/database/tenant-schema.ts
import { PrismaClient } from '@prisma/client'
export async function getPrismaWithSchema(tenantId: string) {
const prisma = new PrismaClient()
// Define schema antes de cada query
await prisma.$executeRawUnsafe(`SET search_path TO tenant_${tenantId}`)
return prisma
}
// Criar schema para novo tenant
export async function createTenantSchema(tenantId: string) {
const prisma = new PrismaClient()
// 1. Cria schema
await prisma.$executeRawUnsafe(`CREATE SCHEMA tenant_${tenantId}`)
// 2. Cria tabelas nesse schema
await prisma.$executeRawUnsafe(`
CREATE TABLE tenant_${tenantId}.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`)
// 3. Cria índices
await prisma.$executeRawUnsafe(`
CREATE INDEX idx_users_email ON tenant_${tenantId}.users(email)
`)
}
Migration automático para novos tenants:
// scripts/create-tenant.ts
import { execSync } from 'child_process'
export async function provisionTenant(tenantId: string) {
// 1. Cria schema
await createTenantSchema(tenantId)
// 2. Roda migrations do Prisma nesse schema
process.env.SCHEMA = `tenant_${tenantId}`
execSync('npx prisma migrate deploy', {
env: { ...process.env, SCHEMA: `tenant_${tenantId}` }
})
// 3. Seed inicial (dados padrão)
await seedTenant(tenantId)
}
Vantagens:
- ✅ Isolamento forte (schemas separados)
- ✅ Custo menor (1 database para todos)
- ✅ Migrations centralizadas (mas aplicadas N vezes)
Desvantagens:
- ❌ Complexidade média (gerenciar N schemas)
- ❌ Limite de schemas por database (~1.000)
- ❌ Migrations lentas (roda em cada schema)
Quando usar:
- 50-500 clientes
- Isolamento necessário mas custo importa
- SaaS B2B médio porte
Custo (100 clientes):
- 1 database grande × R$ 600/mês = R$ 600/mês (95% mais barato)
Modelo 3: Row-Level Security (RLS)
Como funciona: 1 database, 1 schema, coluna tenant_id em cada tabela.
-- Schema PostgreSQL
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- ← Identifica qual tenant
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
UNIQUE(tenant_id, email) -- Email único por tenant
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
Row-Level Security (RLS):
-- Ativa RLS na tabela
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Política: Usuários só veem dados do próprio tenant
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- Aplicação define tenant antes de query
SET app.current_tenant = '123e4567-e89b-12d3-a456-426614174000';
-- Agora todas as queries filtram automaticamente
SELECT * FROM users; -- Retorna apenas users do tenant atual
Implementação com Prisma:
// lib/database/rls.ts
import { PrismaClient } from '@prisma/client'
export async function getPrismaWithTenant(tenantId: string) {
const prisma = new PrismaClient()
// Define tenant atual (RLS aplica automaticamente)
await prisma.$executeRawUnsafe(`
SET app.current_tenant = '${tenantId}'
`)
return prisma
}
// Middleware: Define tenant em toda request
export async function tenantMiddleware(req: Request, res: Response, next: Function) {
const tenantId = req.headers.get('X-Tenant-ID')
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID required' })
}
req.prisma = await getPrismaWithTenant(tenantId)
next()
}
// Uso
app.use(tenantMiddleware)
app.get('/api/users', async (req, res) => {
// prisma já está filtrado por tenant
const users = await req.prisma.user.findMany()
res.json(users)
})
Schema Prisma com tenant_id:
// prisma/schema.prisma
model User {
id String @id @default(uuid())
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
email String
@@unique([tenantId, email])
@@index([tenantId])
}
model Booking {
id String @id @default(uuid())
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
date DateTime
@@index([tenantId])
}
model Tenant {
id String @id @default(uuid())
name String
subdomain String @unique
users User[]
bookings Booking[]
createdAt DateTime @default(now())
}
Vantagens:
- ✅ Custo mínimo (1 database único)
- ✅ Migrations simples (roda 1 vez)
- ✅ Performance boa (índice em tenant_id)
- ✅ Queries agregadas fáceis (relatórios multi-tenant)
Desvantagens:
- ❌ Risco de vazamento (se esquecer tenant_id em alguma query)
- ❌ Complexidade em queries (sempre incluir tenant_id)
- ❌ Performance degrada com muitos tenants (maior que 10K)
Quando usar:
- menor que 1.000 clientes
- MVP ou early stage
- SaaS B2B pequeno/médio
Custo (100 clientes):
- 1 database médio × R$ 240/mês = R$ 240/mês (98% mais barato)
Comparação dos 3 modelos
| Aspecto | Database isolado | Schema isolado | RLS |
|---|---|---|---|
| Isolamento | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Custo | 💰💰💰💰💰 | 💰💰 | 💰 |
| Complexidade | 🔧🔧🔧🔧 | 🔧🔧🔧 | 🔧🔧 |
| Performance | ⚡⚡⚡⚡⚡ | ⚡⚡⚡⚡ | ⚡⚡⚡ |
| Escalabilidade | Até 100 | Até 1.000 | Até 10.000 |
| Custo (100 clientes) | R$ 12K/mês | R$ 600/mês | R$ 240/mês |
Migrando entre modelos
Cenário comum: Começa com RLS (barato), migra para schema isolado quando cresce.
// scripts/migrate-rls-to-schema.ts
export async function migrateToSchema(tenantId: string) {
const prisma = new PrismaClient()
// 1. Cria schema novo
await createTenantSchema(tenantId)
// 2. Copia dados
const users = await prisma.user.findMany({ where: { tenantId } })
await prisma.$transaction(async (tx) => {
for (const user of users) {
await tx.$executeRawUnsafe(`
INSERT INTO tenant_${tenantId}.users (id, name, email, created_at)
VALUES ($1, $2, $3, $4)
`, user.id, user.name, user.email, user.createdAt)
}
})
// 3. Valida (contagem deve bater)
const oldCount = await prisma.user.count({ where: { tenantId } })
const newCount = await prisma.$queryRawUnsafe(`
SELECT COUNT(*) FROM tenant_${tenantId}.users
`)
if (oldCount !== newCount[0].count) {
throw new Error('Migration failed: count mismatch')
}
// 4. Marca tenant como migrado
await prisma.tenant.update({
where: { id: tenantId },
data: { architecture: 'SCHEMA_ISOLATED' }
})
console.log(`✅ Tenant ${tenantId} migrated to schema isolation`)
}
Segurança: Prevenindo vazamento entre tenants
Problema: Esquecer de filtrar por tenant_id = desastre.
// ❌ VULNERÁVEL
const users = await prisma.user.findMany() // Retorna TODOS os usuários de TODOS os tenants
// ✅ CORRETO
const users = await prisma.user.findMany({
where: { tenantId: currentTenantId }
})
Solução 1: Prisma Middleware (força tenant_id em todas as queries)
// lib/prisma-tenant-middleware.ts
import { Prisma } from '@prisma/client'
export function createTenantMiddleware(tenantId: string) {
return async (params: Prisma.MiddlewareParams, next: Function) => {
// Lista de modelos com tenant_id
const multiTenantModels = ['User', 'Booking', 'Payment']
if (multiTenantModels.includes(params.model)) {
// Força tenant_id em queries
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args.where = {
...params.args.where,
tenantId
}
}
// Adiciona tenant_id em creates
if (params.action === 'create' || params.action === 'createMany') {
if (params.args.data) {
if (Array.isArray(params.args.data)) {
params.args.data = params.args.data.map(item => ({
...item,
tenantId
}))
} else {
params.args.data = {
...params.args.data,
tenantId
}
}
}
}
}
return next(params)
}
}
// Setup
const prisma = new PrismaClient()
prisma.$use(createTenantMiddleware(currentTenantId))
Solução 2: Database constraints
-- Impede INSERTs sem tenant_id
ALTER TABLE users ALTER COLUMN tenant_id SET NOT NULL;
-- Foreign key garante tenant existe
ALTER TABLE users
ADD CONSTRAINT fk_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;
Identificação do tenant (subdomínio vs header)
Opção 1: Subdomínio
tenant-a.suaapp.com → Tenant A
tenant-b.suaapp.com → Tenant B
// middleware/tenant-resolver.ts
export function resolveTenantFromSubdomain(req: Request): string | null {
const host = req.headers.get('host') // "tenant-a.suaapp.com"
const subdomain = host?.split('.')[0]
if (!subdomain || subdomain === 'www' || subdomain === 'suaapp') {
return null
}
return subdomain
}
Opção 2: Header customizado
X-Tenant-ID: 123e4567-e89b-12d3-a456-426614174000
Opção 3: JWT claim
// Token contém tenant_id
const token = jwt.sign({
userId: '...',
tenantId: '123e4567-...'
}, SECRET)
// Extrai em middleware
const decoded = jwt.verify(token, SECRET)
const tenantId = decoded.tenantId
Provisionamento automatizado de tenants
// app/api/tenants/route.ts
export async function POST(req: Request) {
const { companyName, adminEmail, subdomain } = await req.json()
// 1. Valida subdomain disponível
const exists = await prisma.tenant.findUnique({ where: { subdomain } })
if (exists) {
return Response.json({ error: 'Subdomain taken' }, { status: 400 })
}
// 2. Cria tenant
const tenant = await prisma.tenant.create({
data: {
name: companyName,
subdomain,
plan: 'TRIAL',
trialEndsAt: addDays(new Date(), 14)
}
})
// 3. Cria schema (se modelo = schema isolado)
if (process.env.ARCHITECTURE === 'SCHEMA_ISOLATED') {
await createTenantSchema(tenant.id)
}
// 4. Cria usuário admin
const admin = await prisma.user.create({
data: {
tenantId: tenant.id,
email: adminEmail,
role: 'ADMIN',
name: 'Admin'
}
})
// 5. Seed inicial (configurações padrão)
await seedTenant(tenant.id)
// 6. Envia email de boas-vindas
await sendWelcomeEmail(adminEmail, {
subdomain,
loginUrl: `https://${subdomain}.suaapp.com/login`
})
return Response.json({
tenantId: tenant.id,
subdomain,
loginUrl: `https://${subdomain}.suaapp.com`
})
}
Checklist de multi-tenancy
Antes de lançar:
- Modelo escolhido (database/schema/RLS)
- Tenant_id em todas as tabelas (se RLS)
- Índices em tenant_id
- Middleware força tenant_id (Prisma)
- RLS ativo (se PostgreSQL + RLS)
- Testes de isolamento (tenant A não vê dados de B)
- Provisionamento automático implementado
- Subdomain routing configurado
Operacional:
- Monitoramento por tenant (uso, performance)
- Backup/restore por tenant
- Processo de migração entre modelos documentado
- Alertas de vazamento (queries sem tenant_id)
Próximos passos
- Escolher modelo (para MVP: RLS; para maior que 100 clientes: Schema)
- Implementar middleware de tenant
- Adicionar tenant_id em schema Prisma
- Criar índices apropriados
- Testar isolamento (criar 2 tenants, validar separação)
- Configurar subdomínios (Vercel/AWS)
- Automatizar provisionamento
Lembre-se: Multi-tenancy mal feito = vazamento de dados = fim do negócio. Teste exaustivamente antes de produção.