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.
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 |
