Pular para o conteúdo
banco-de-dados

Estrutura de Banco de Dados para SaaS: Multi-tenancy, Isolamento e Escalabilidade

Como projetar o banco de dados de um SaaS: estratégias de multi-tenancy (schema separado, tenant_id, banco separado), trade-offs e quando usar cada abordagem.

Douglas M. Pereira5 min de leitura
saasbanco de dadosmulti-tenancypostgresqlarquiteturaescalabilidade

A decisão arquitetural mais importante de um SaaS

Como você isola os dados de clientes diferentes no mesmo sistema? Essa decisão afeta segurança, performance, custo operacional e capacidade de customização por cliente. Não tem resposta universal — depende do porte, da sensibilidade dos dados e do modelo de negócio.

As três abordagens de multi-tenancy

1. Shared Database, Shared Schema (tenant_id em todas as tabelas)

-- Todas as tabelas têm tenant_id
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id),
  email text NOT NULL,
  name text NOT NULL,
  created_at timestamptz DEFAULT now(),
  UNIQUE (tenant_id, email)
);

CREATE TABLE orders (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id),
  user_id uuid NOT NULL REFERENCES users(id),
  total numeric(10,2) NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- Índices incluindo tenant_id (CRÍTICO para performance)
CREATE INDEX idx_users_tenant_email ON users (tenant_id, email);
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at DESC);

Implementação: Row-Level Security no PostgreSQL

-- RLS garante que queries nunca vazem dados entre tenants
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_users ON users
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

CREATE POLICY tenant_isolation_orders ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
// Na aplicação: set tenant antes de executar queries
async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
  return db.$transaction(async (tx) => {
    await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`
    return fn()
  })
}

// Uso
const orders = await withTenant(req.user.tenantId, () => 
  db.orders.findMany({ orderBy: { createdAt: 'desc' } })
)
// RLS garante que só orders do tenant correto são retornados

Prós: operacionalmente simples, um banco para todos os clientes, uso eficiente de recursos
Contras: bug de segurança pode vazar dados entre tenants, performance pode degradar para tenants grandes, backups/restores granulares por cliente são complexos

2. Shared Database, Separate Schema

-- Cada tenant tem seu próprio schema
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;

-- Mesmas tabelas, schemas diferentes
CREATE TABLE tenant_acme.users (id uuid PRIMARY KEY, email text, ...);
CREATE TABLE tenant_globex.users (id uuid PRIMARY KEY, email text, ...);
// Conexão com schema específico do tenant
async function getTenantConnection(tenantId: string) {
  const schemaName = `tenant_${tenantId.replace(/-/g, '_')}`
  
  return prisma.$extends({
    query: {
      $allModels: {
        async $allOperations({ model, operation, args, query }) {
          await prisma.$executeRaw`SET search_path TO ${schemaName}`
          return query(args)
        }
      }
    }
  })
}

Prós: melhor isolamento sem banco separado, backup por schema é possível, customizações de schema por tenant viáveis
Contras: migrações precisam rodar em todos os schemas (pode ser lento com muitos tenants), search_path é risco de segurança se não gerenciado corretamente

3. Separate Database por Tenant

// Cada tenant tem seu banco completamente separado
const tenantConnections = new Map<string, PrismaClient>()

async function getConnection(tenantId: string): Promise<PrismaClient> {
  if (tenantConnections.has(tenantId)) {
    return tenantConnections.get(tenantId)!
  }
  
  // Buscar credenciais do banco do tenant
  const tenant = await masterDb.tenants.findUnique({ where: { id: tenantId } })
  if (!tenant) throw new Error('Tenant não encontrado')
  
  const client = new PrismaClient({
    datasources: {
      db: { url: tenant.databaseUrl }
    }
  })
  
  tenantConnections.set(tenantId, client)
  return client
}

Prós: isolamento máximo, backup/restore por cliente trivial, performance dedicada, compliance mais fácil (LGPD, HIPAA)
Contras: muitos bancos para gerenciar (N tenants = N bancos), custo de infra muito maior, migrações são mais complexas

Como decidir: uma árvore de decisão

Os dados dos clientes são altamente sensíveis?
(saúde, financeiro, jurídico)
  └─ SIM → Separate Database ou Separate Schema
  └─ NÃO → Continue

Os clientes exigem SLA de performance isolada?
(enterprise tier)
  └─ SIM → Separate Database
  └─ NÃO → Continue

Você espera diferenças de schema entre clientes?
(customizações profundas por cliente)
  └─ SIM → Separate Schema ou Separate Database
  └─ NÃO → Shared Schema com RLS

Migrações em SaaS multi-tenant

O ponto de dor mais frequente. Estratégias:

// Migração em todos os schemas (abordagem com schemas separados)
async function runMigrations() {
  const tenants = await masterDb.tenants.findMany()
  
  for (const tenant of tenants) {
    const schemaName = `tenant_${tenant.id.replace(/-/g, '_')}`
    
    try {
      await runMigrationOnSchema(schemaName)
      console.log(`✓ Migração aplicada: ${tenant.name}`)
    } catch (error) {
      console.error(`✗ Erro em ${tenant.name}:`, error)
      // Logar para remediation manual, não pausar todas as migrations
    }
  }
}

Provisionamento de novos tenants

async function provisionTenant(name: string, plan: 'starter' | 'pro' | 'enterprise') {
  // 1. Criar registro do tenant
  const tenant = await masterDb.tenants.create({
    data: { name, plan, status: 'provisioning' }
  })
  
  // 2. Criar schema/banco (dependendo da estratégia)
  if (plan === 'enterprise') {
    await createDedicatedDatabase(tenant.id)
  } else {
    await createTenantSchema(tenant.id)
  }
  
  // 3. Rodar migrations no novo schema
  await migrateSchema(tenant.id)
  
  // 4. Seed data inicial (planos, configurações padrão)
  await seedTenantDefaults(tenant.id)
  
  // 5. Marcar como ativo
  await masterDb.tenants.update({
    where: { id: tenant.id },
    data: { status: 'active' }
  })
  
  return tenant
}

Recomendação por estágio

| Estágio | Tenants | Abordagem recomendada | |---|---|---| | MVP / Early stage | < 50 | Shared schema com tenant_id + RLS | | Crescimento | 50–500 | Separate schema | | Escala / Enterprise | 500+ ou dados sensíveis | Separate database para enterprise, shared schema para self-serve |