Multi-tenancy em SaaS B2B: Trade-offs e o Modelo Que Escolhi
Multi-tenancy é a decisão arquitetural mais irreversível de um SaaS B2B. A maioria dos times a toma implicitamente, no meio de uma sprint, no momento em que percebe que precisa isolar os dados de um cliente dos de outro.
Erre o modelo no início e você não vai refatorar. Você vai migrar dados em produção de todos os tenants, sob pressão, com risco de vazamento — ou vai conviver com uma arquitetura que nunca fecha direito.
Por que isso é mais que uma questão de segurança
Num produto de compliance, cada tenant guarda dados sensíveis de fornecedores, histórico de alertas de risco e resultados de classificação por IA. Vazar isso entre tenants não é um bug de UX; é um incidente de segurança com consequências regulatórias e contratuais diretas.
Mas o lado operacional é o que surpreende. Com N tenants, cada decisão de arquitetura se multiplica por N. Um banco por tenant significa N backups, N execuções de migration, N instâncias para monitorar. O custo operacional escala linearmente com o número de clientes — exatamente o oposto do que você quer em SaaS.
E a decisão precisa ser tomada antes da primeira linha de modelo de dados. Adicionar isolamento de tenant a um schema que já está em produção é uma reescrita com downtime e risco de migração, não uma refatoração.
Os quatro modelos
Banco por tenant. Isolamento máximo, custo operacional muito alto. Faz sentido para clientes enterprise com exigência de separação física — data residency, auditorias de banco separadas. Inviável para um produto PME com centenas de tenants.
Schema por tenant. Bom isolamento, custo moderado — migrations precisam rodar por schema. É o que a gem apartment faz no Rails. O detalhe: dump de N schemas é operacionalmente chato, e o limite prático de schemas do PostgreSQL começa a incomodar acima de mil.
Banco compartilhado com filtro na aplicação. Só where(tenant_id: ...) em toda query. O mais simples de implementar, e o isolamento depende inteiramente de nunca esquecer o filtro — em lugar nenhum, nunca. Um bug de aplicação equivale a vazamento entre tenants. Inaceitável para compliance.
Banco compartilhado com Row-Level Security. Isolamento garantido pelo banco em vez da aplicação. Migrations únicas, custo mínimo de infraestrutura, baixa complexidade de implementação no Rails. Foi o que escolhi.
A implementação
Habilitar RLS na tabela e definir a policy de isolamento:
ALTER TABLE suppliers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON suppliers
USING (tenant_id = current_setting('app.current_tenant')::uuid);
O PostgreSQL passa a aplicar o filtro de tenant_id automaticamente em toda query contra suppliers. A aplicação não pode esquecer, porque a aplicação não é mais quem garante isso.
O default perigoso
Esta é a parte que vale internalizar: se app.current_tenant não estiver setado, a query retorna vazio em vez de levantar erro.
Esse comportamento silencioso é uma armadilha de verdade. Um job em background que roda sem contexto de tenant não quebra — ele simplesmente não encontra nada, e você recebe um report de dados faltando semanas depois em vez de um stack trace na hora.
A mitigação é tornar a definição de contexto estrutural, não algo lembrado. A cada request autenticado, resolva o tenant a partir do token e sete a variável de sessão antes de qualquer query. Depois propague esse mesmo contexto para os jobs em background via middleware, porque jobs executam fora do ciclo de request onde você setou.
Qualquer caminho de código que chegue ao banco sem passar por um desses dois lugares é um caminho onde o isolamento silenciosamente não faz nada. Enumere-os deliberadamente.
O que isso compra e o que custa
Você ganha isolamento que sobrevive a bugs de aplicação, uma migration para todos os tenants e custo de infraestrutura que não escala com o número de clientes.
Você aceita que todos os tenants compartilham armazenamento físico — então um cliente que exija separação física por contrato não pode ser atendido por esse modelo sem uma válvula de escape. Aceita também que policies de RLS são mais uma coisa para testar, e que o default de resultado vazio vai te morder pelo menos uma vez antes de virar hábito.
Para um MVP com requisitos de compliance e um único desenvolvedor, essa troca foi claramente correta. Para um contrato enterprise exigindo data residency, não seria — e a versão honesta dessa decisão é saber em qual desses futuros você está antes de se comprometer.