8 min de leitura
saas
arquitectura
postgresql
multi-tenant
isolamento-dados

Arquitectura Multi-Tenant em SaaS: Isolamento, Escala e Trade-offs Reais

Guia prático sobre organização de bases de dados multi-tenant. Estratégias de isolamento, comparação de padrões (database-per-tenant vs shared schema), implementação em produção e armadilhas comuns.

Arquitectura Multi-Tenant em SaaS: Isolamento, Escala e Trade-offs Reais

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_path pode 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_id em 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_id como primeira coluna: INDEX idx_user_tenant (tenant_id, user_id).
  • Tests que verificam isolamento. Query factory que passa tenantId sempre.

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.

Impact OriginGostaste deste artigo?Na Impact Origin ajudamos fundadores e equipas a construir e escalar software à medida, do MVP da startup ao próximo passo. Se tens um projeto em mente, vamos falar.