Arquitetura multi-tenant para SaaS: isolamento de dados com custo 70% menor

3 modelos de multi-tenancy: schema isolado, banco isolado e row-level security. Comparação de custos, segurança e escalabilidade.

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

AspectoDatabase isoladoSchema isoladoRLS
Isolamento⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Custo💰💰💰💰💰💰💰💰
Complexidade🔧🔧🔧🔧🔧🔧🔧🔧🔧
Performance⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
EscalabilidadeAté 100Até 1.000Até 10.000
Custo (100 clientes)R$ 12K/mêsR$ 600/mêsR$ 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

  1. Escolher modelo (para MVP: RLS; para maior que 100 clientes: Schema)
  2. Implementar middleware de tenant
  3. Adicionar tenant_id em schema Prisma
  4. Criar índices apropriados
  5. Testar isolamento (criar 2 tenants, validar separação)
  6. Configurar subdomínios (Vercel/AWS)
  7. Automatizar provisionamento

Lembre-se: Multi-tenancy mal feito = vazamento de dados = fim do negócio. Teste exaustivamente antes de produção.

Pronto para sair do manual?

Agende o diagnóstico gratuito. Vamos mapear o gargalo, estimar o impacto e definir o primeiro resultado mensurável.

Você sai com clareza — não com um pitch de vendas.