Arquitectura Multi-Tenant em SaaS: Isolamento, Escala e Trade-offs Reais
TL;DR
- Existem 3 padrões principais: base de dados por tenant, schema por tenant e tabelas particionadas. Nenhum é "o correcto"; a escolha depende do número de tenants, volume de dados e recursos de engenharia.
- Isolamento a nível de schema (tenant_id na chave) é mais comum em SaaS B2B pequeno a médio porque permite escalabilidade inicial com menos complexidade operacional que database-per-tenant.
- Particionamento de tabelas por tenant em PostgreSQL 16+ oferece isolamento lógico com overhead operacional menor que múltiplas databases, mas requer expertise em Row Level Security ou políticas de aplicação rigorosas.
- A maior armadilha é NOT_FOUND queries que retornam dados de múltiplos tenants por falta de filtro tenant_id obrigatório na aplicação. Isto quebra isolamento completamente.
- Custo total de posse varia: database-per-tenant (€500-2000/mês por DB gerenciada) vs schema compartilhado (€50-200/mês base + índices) depende da plataforma.
Por Que a Organização de Dados Importa em Multi-Tenant
Quando constrói um SaaS, a primeira tentação é colocar tudo numa base de dados e deixar o application logic gerir isolamento com filtros WHERE tenant_id = ?. Funciona. Até não funcionar.
Muitos SaaS enfrentam problemas graves em produção porque a arquitectura de dados não foi pensada desde o início. Queries lenta sob carga de múltiplos tenants, migrações de dados complexas, incapacidade de fazer backups selectivos, compliance frágil. A organização de dados não é só um detalhe técnico; é decisão arquitectónica que afecta custo, segurança e velocidade de desenvolvimento durante anos.
Vou ser directo: não existe "solução perfeita". Cada padrão tem trade-offs reais. O objectivo aqui é mostrarte as opções com números concretos e critérios para escolher.
Padrão 1: Tabelas Particionadas com Tenant_ID (Schema Compartilhado)
Este é o padrão mais comum em SaaS B2B pequeno e médio. Uma única base de dados, um schema, todas as tabelas com coluna tenant_id. Isolamento é responsabilidade da aplicação.
Exemplo prático em PostgreSQL 16:
CREATE TABLE organizations (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, id)
);
CREATE TABLE workspace_members (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
organization_id UUID NOT NULL,
user_id UUID NOT NULL,
role VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_org FOREIGN KEY (tenant_id, organization_id)
REFERENCES organizations(tenant_id, id),
INDEX idx_tenant_org (tenant_id, organization_id),
INDEX idx_tenant_user (tenant_id, user_id)
);
-- Partição por tenant para queries mais rápidas em grandes volumes
CREATE TABLE workspace_events (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
workspace_id UUID NOT NULL,
event_type VARCHAR(100),
payload JSONB,
created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY HASH (tenant_id) PARTITIONS 16;
-- Políticas RLS para isolamento automático (PostgreSQL 12+)
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_organizations ON organizations
USING (tenant_id = CURRENT_SETTING('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation_members ON workspace_members
USING (tenant_id = CURRENT_SETTING('app.current_tenant_id')::uuid);
Prós:
- Operacional simples. Uma base de dados, um backup, um conjunto de índices.
- Queries multi-tenant são triviais (SELECT * WHERE tenant_id = ?).
- Custo inicial baixo. No Render, Heroku ou Supabase, podes ter 100+ tenants numa única instância por €50-150/mês.
- Migrations simples; alteras uma tabela, afecta todos os tenants uniformemente.
Contras:
- Complexidade cresce com volume. Com 10 mil tenants ativos, mesmo com particionamento, a contention em índices partilhados começa a doer. P99 latency sobe.
- Backups e restores afectam todos os tenants. Um erro em dados de um cliente pode exigir restore completo.
- Scaling vertical tem limite. A base de dados cresce para um único servidor (a menos que uses replicação leitura).
- Compliance complexo. GDPR delete-right de um tenant afecta integridade referencial de outros.
- Row Level Security não é "mágica". Requer discipline rigorosa: cada query DEVE passar
app.current_tenant_id. Uma query sem filtro tenant_id retorna tudo de todos.
Quando escolher: SaaS com < 1000 tenants activos, volume de dados < 100GB por tenant, equipa com conhecimento sólido de SQL e DevOps.
Padrão 2: Database-Per-Tenant
Cada tenant tem a sua própria base de dados PostgreSQL (ou MongoDB, Mongo, etc.). Isolamento é garantido a nível de infra.
Exemplo de setup em TypeScript com Prisma:
// db.ts
import { PrismaClient } from '@prisma/client';
const tenantConnections: Record<string, PrismaClient> = {};
export async function getPrismaForTenant(tenantId: string): Promise<PrismaClient> {
if (tenantConnections[tenantId]) {
return tenantConnections[tenantId];
}
// Connexão string dinâmica por tenant
const databaseUrl = `postgresql://user:pass@postgres-${tenantId}.prod.rds.amazonaws.com:5432/db_${tenantId}`;
const prisma = new PrismaClient({
datasources: {
db: {
url: databaseUrl,
},
},
});
tenantConnections[tenantId] = prisma;
return prisma;
}
// api/workspaces/[tenantId].ts
import { getPrismaForTenant } from '@/db';
export async function getWorkspace(tenantId: string, workspaceId: string) {
const prisma = await getPrismaForTenant(tenantId);
// Sem filtro tenant_id necessário; está implícito na DB
return prisma.workspace.findUnique({
where: { id: workspaceId },
});
}
Prós:
- Isolamento garantido. Uma query que falhe num tenant não afecta outros.
- Backups selectivos e restore rápido. Cada tenant é independente.
- Compliance automático. GDPR delete é trivial (drop database).
- Escala horizontal infinita. Cada tenant pode ter réplicas, failover, dedicated resources.
- Performance previsível. Sem contention com outros tenants.
Contras:
- Custo operacional alto. AWS RDS, Heroku, ou Neon cobram por base de dados. Mínimo €15-50/mês por DB. Com 500 tenants, estás a €7500-25000/mês.
- Complexidade de deployment. Cada novo tenant requer provisioning automático de DB, migrações, backups dedícados.
- Shared queries complexas. "Mostra-me dados agregados de todos os meus tenants" requer federated queries ou cópia de dados num data warehouse.
- Connection pooling é crítico. 500 tenants * 10 connections = 5000 conexões abertas. PgBouncer ou similar obrigatório.
- Ferramentas DevOps precisam de awareness multi-database. Monitoring, alertas, logs têm que escalar.
Quando escolher: SaaS com > 1000 tenants de alto valor, requisitos de compliance estritos, ou quando a maioria dos tenants têm volumes de dados massivos (> 500GB).
Padrão 3: Schema-Per-Tenant (Namespace Isolation)
Cada tenant tem o seu próprio schema PostgreSQL dentro da mesma instância. Isolamento a nível de schema, não de base de dados.
-- Instalação
CREATE SCHEMA tenant_acme_001 AUTHORIZATION postgres;
CREATE SCHEMA tenant_xyz_002 AUTHORIZATION postgres;
-- Definição de tabelas é igual, mas repetida por schema
CREATE TABLE tenant_acme_001.organizations (
id UUID PRIMARY KEY,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE tenant_xyz_002.organizations (
id UUID PRIMARY KEY,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
-- Search_path selecci0na o schema do tenant activo
SET search_path TO tenant_acme_001, public;
SELECT * FROM organizations; -- Acede só a tenant_acme_001.organizations
Middleware em Node.js/Express para switching automático:
// Middleware para seleccionar schema
export function schemaSwitcher(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string || req.user?.tenantId;
if (!tenantId) {
return res.status(401).json({ error: 'Missing tenant ID' });
}
// Executar SET search_path para este request
const schemaName = `tenant_${tenantId.replace(/-/g, '_')}`;
// Passa para context (Express, Fastify, etc.)
req.locals = { schemaName, tenantId };
next();
}
// Aplicar a queries
app.get('/api/organizations', schemaSwitcher, async (req, res) => {
const { schemaName } = req.locals;
const query = `
SET search_path TO ${schemaName}, public;
SELECT * FROM organizations LIMIT 10;
`;
const result = await db.query(query);
res.json(result.rows);
});
Prós:
- Isolamento mais forte que tabelas particionadas, mais simples que database-per-tenant.
- Backups selectivos por schema.
pg_dump -n tenant_acme_001. - Escala melhor que shared schema em grandes volumes porque índices são separados por schema.
- Custo operacional moderado. Uma DB para N tenants, mas com overhead menor que shared schema em volumes altos.
Contras:
- Ainda operacionalmente complexo. Cada novo tenant requer
CREATE SCHEMA+ setup de permissões. - Queries agregadas continuam problemáticas. Cruzar dados de múltiplos schemas é lento.
- Erro em search_path e tens data leakage. Uma query mal escrita sem
SET search_pathpode retornar dados errados. - Ferramentas ORM (Prisma, SQLAlchemy) não têm suporte nativo para schema-switching dinâmico. Precisas de queries raw.
Quando escolher: SaaS com 100-1000 tenants de médio valor, volume de dados 50-500GB por tenant, quando database-per-tenant é caro demais mas shared schema é arriscado.
Armadilhas Comuns em Produção
1. Queries Sem Filtro Tenant_ID
A mais grave. Um desenvolvedor escreve uma query sem WHERE tenant_id = ?. Se a aplicação usa shared schema, isto retorna dados de TODOS os tenants.
// ERRADO
async function getUserProjects(userId: string) {
return prisma.project.findMany({
where: { userId },
});
}
// Se dois tenants têm users com IDs repetidos (ex: auto-increment),
// este userId pode existir em ambos. A query retorna ambos.
// CORRECTO
async function getUserProjects(userId: string, tenantId: string) {
return prisma.project.findMany({
where: {
userId,
tenantId, // Sempre obrigatório
},
});
}
2. Migrações Que Quebram Isolamento
Uma migração que adiciona coluna com default único quebra. Exemplo:
-- ERRADO em shared schema
ALTER TABLE api_keys ADD COLUMN secret_key VARCHAR(255) UNIQUE;
-- Duas inserts com DEFAULT criarão conflito UNIQUE entre tenants
INSERT INTO api_keys (tenant_id, name) VALUES ('tenant-1', 'key1');
INSERT INTO api_keys (tenant_id, name) VALUES ('tenant-2', 'key1');
-- Erro: duplicate key
3. N+1 Queries Multiplicadas por Tenants
Com database-per-tenant, cada query abre uma conexão. N+1 em shared schema é lento. N+1 em database-per-tenant é catastrófal (centenas de conexões).
// ERRADO em database-per-tenant
const workspaces = await getWorkspaces(tenantId); // 1 conexão
for (const ws of workspaces) {
const members = await getMembers(tenantId, ws.id); // +1 conexão por workspace
}
// Com 100 workspaces: 101 conexões abertas
// CORRECTO: Batch queries
const workspacesWithMembers = await prisma.workspace.findMany({
where: { tenantId },
include: { members: true }, // 1 query com JOIN
});
4. Backups Incompletos
Em database-per-tenant, esqueceres de backupear um schema ou database deixa um tenant vulnerável. Sem automação, é desastre.
Recomendação Prática: Início com Shared Schema, Preparação para Escala
A maioria dos SaaS devem começar com schema compartilhado (padrão 1) porque:
- Operacional simples. Enfoque em product, não em DevOps.
- Custo inicial baixo.
- Migrações e deployments são rápidos.
Mas implementa desde o início:
- Column
tenant_idem TODA a tabela de data. Sem excepções. - Row Level Security (PostgreSQL) ou filtro obrigatório em camada de aplicação.
- Índices sempre incluindo
tenant_idcomo primeira coluna:INDEX idx_user_tenant (tenant_id, user_id). - Tests que verificam isolamento. Query factory que passa
tenantIdsempre.
Quando tens 500+ tenants ativos e P99 latency sobe acima de 200ms, consideras schema-per-tenant. Quando tens enterprise customers com requisitos de isolamento garantido ou compliance estrícto, migras para database-per-tenant.
Ferramentas que Ajudam
PostgreSQL 16+: Particionamento por HASH, RLS, parameterized queries. Tudo suporta multi-tenant nativamente.
Prisma: Middleware para passar tenantId automaticamente a todas as queries. const result = prisma.$queryRaw\SELECT * FROM users`é perigoso; usaprisma.user.findMany({ where: { tenantId } })`.
PgBouncer: Connection pooling essencial em database-per-tenant ou schema-per-tenant com muitos tenants.
Neon ou Vercel Postgres: Managed PostgreSQL com scaling automático. Suporta branch por tenant para testing.
Conclusão
A organização de dados multi-tenant em SaaS não é decisão técnica menor. Determina custo operacional, segurança, compliance e velocidade de escala durante anos. Começa simples (shared schema com filtros rigorosos), investe em testes de isolamento desde o dia 1, e escala para schema-per-tenant ou database-per-tenant conforme o número de tenants e volume de dados exigem.
Se estás a enfrentar um problema parecido, marca uma conversa em https://impact-origin.com/agendamento.
