Como Estruturar um Backend SaaS Multi-Tenant
Um guia prático sobre arquitetura SaaS multi-tenant — desde estratégias de isolamento de banco de dados até autenticação, cobrança e feature flags.

No momento em que você decide que sua aplicação vai atender mais de uma organização, toda decisão arquitetural se bifurca. Design de banco de dados, autenticação, autorização, cobrança, até logging — tudo agora carrega a pergunta: para qual tenant?
Já entreguei backends multi-tenant em domínios muito diferentes. Os padrões que sobrevivem à produção raramente são os que ficam mais bonitos no quadro branco. Este post cobre as decisões que realmente importam, os tradeoffs sobre os quais ninguém te avisa e padrões concretos de implementação que você pode usar.
Estratégias de Isolamento de Banco de Dados
Esta é a primeira e mais consequente decisão. Erre aqui e você estará diante de uma migração que toca cada query na sua base de código.
Existem três abordagens principais:
| Estratégia | Isolamento | Complexidade | Custo | Ideal Para |
|---|---|---|---|---|
| BD compartilhado, schema compartilhado | Baixo | Baixa | Mais baixo | SaaS em estágio inicial, < 100 tenants |
| BD compartilhado, schema por tenant | Médio | Média | Médio | Média escala, indústrias regulamentadas |
| Banco de dados por tenant | Alto | Alta | Mais alto | Clientes enterprise, conformidade rigorosa |
Banco de Dados Compartilhado, Schema Compartilhado
Todos os dados dos tenants ficam nas mesmas tabelas. Você distingue as linhas por uma coluna tenant_id. É por aqui que a maioria dos produtos SaaS deve começar.
A vantagem é a simplicidade. Um caminho de migração, um pool de conexões, queries diretas. A desvantagem é que uma cláusula WHERE tenant_id = ? faltando significa um vazamento de dados. Não há rede de segurança no nível da infraestrutura — o isolamento está inteiramente no seu código de aplicação.
Essa abordagem funciona até que as queries de um tenant grande comecem a degradar o desempenho de todos os outros, ou até que a equipe de conformidade de um cliente enterprise pergunte como seus dados estão fisicamente separados dos concorrentes. Nesse ponto, você evolui.
Schema por Tenant
Cada tenant recebe seu próprio schema PostgreSQL (ou equivalente) dentro de uma instância de banco de dados compartilhada. As tabelas são idênticas entre schemas, mas fisicamente separadas. Você troca o search path no momento da conexão.
Isso te dá isolamento real sem gerenciar dezenas de instâncias de banco de dados. Migrações são mais trabalhosas — você as executa N vezes — mas ferramentas como node-pg-migrate ou o suporte multi-schema do Prisma lidam com isso razoavelmente bem.
O ponto de atenção: pool de conexões fica complicado. Se você está usando PgBouncer, precisa ser cuidadoso sobre como a troca de schema interage com conexões do pool. Já vi isso causar bugs sutis onde o Tenant A momentaneamente lê o schema do Tenant B porque uma conexão foi devolvida ao pool no meio de uma transação.
Banco de Dados por Tenant
Isolamento máximo. Cada tenant tem uma instância de banco de dados dedicada (ou pelo menos um banco de dados lógico dedicado). Você roteia conexões dinamicamente com base no contexto do tenant.
É caro e pesado operacionalmente, mas alguns clientes vão pagar por isso. Saúde e serviços financeiros frequentemente exigem. O principal benefício além do isolamento é que você pode escalar, fazer backup e restaurar tenants independentemente. Um vizinho barulhento não pode derrubar sua instância compartilhada.
Na prática, a maioria dos times roda um híbrido: BD compartilhado para a maioria, instâncias dedicadas para contas enterprise que pagam por isso.
Autenticação e Resolução de Tenant
Toda requisição de entrada precisa responder duas perguntas: quem é este usuário? e a qual tenant ele pertence?
Existem três estratégias comuns para resolver o contexto do tenant:
Baseado em subdomínio: acme.seuapp.com mapeia para o tenant Acme. Limpo, intuitivo, funciona muito bem para produtos B2B onde cada cliente recebe seu próprio workspace. Você extrai o slug do tenant do header Host.
Baseado em claim JWT: O ID do tenant é incorporado no token de acesso no momento do login. Nenhum roteamento no nível de infraestrutura é necessário. Funciona bem para aplicativos mobile e SPAs onde roteamento por subdomínio é impraticável.
Baseado em header: O cliente envia um header X-Tenant-ID. Simples mas perigoso se não for validado contra as permissões do usuário autenticado. Use apenas para chamadas internas de serviço para serviço.
Na maioria dos sistemas, combino resolução por subdomínio para o fluxo de login inicial com claims JWT para chamadas de API subsequentes. Veja como fica o middleware:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
Monte isso antes dos seus route handlers e toda função downstream terá acesso a req.tenantId. O resto da sua aplicação nunca precisa pensar em como a resolução de tenant funciona — apenas lê o ID do contexto da requisição.
Escopando Todas as Queries do Banco de Dados
Uma vez que você tem o contexto do tenant em cada requisição, precisa garantir que ele seja aplicado a cada query. Faltar um escopo é um vazamento de dados. Isso não é o tipo de coisa que você deixa para a disciplina do desenvolvedor.
O melhor padrão que encontrei é uma camada de repositório que impõe o escopo automaticamente:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
Nos seus route handlers, você nunca constrói queries brutas. Sempre passa pela interface escopada:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
Se você está usando um ORM como Prisma, pode alcançar o mesmo resultado com middleware que injeta o filtro tenant_id em toda chamada findMany, findFirst, update e delete. O ponto é tornar o caminho inseguro mais difícil que o seguro.
Controle de Acesso Baseado em Papéis Dentro dos Tenants
Multi-tenancy introduz um modelo de autorização em duas camadas. Primeiro: este usuário pertence a este tenant? Segundo: o que ele pode fazer dentro dele?
Mantenho isso simples com uma tabela tenant_memberships:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
Os papéis são tipicamente owner, admin, member, e às vezes viewer ou um papel específico do domínio. Evite construir uma matriz completa de permissões a menos que seu produto genuinamente precise — a maioria dos SaaS B2B funciona bem com 3-4 papéis.
A verificação de autorização acontece após a resolução do tenant:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
Uma escolha de design que evita dores de cabeça depois: sempre permita que um usuário pertença a múltiplos tenants. Mesmo que seu produto seja single-workspace hoje, no momento em que você adicionar contas de agência, revendedores white-label ou um modo consultor, vai precisar disso.
Feature Flags por Tenant
Nem todo tenant deve ver toda funcionalidade. Às vezes você está restringindo por nível de plano. Às vezes está fazendo um rollout gradual. Às vezes um cliente enterprise está pagando por uma capacidade customizada.
Uso uma tabela simples tenant_features em vez de contratar um serviço de feature flags de terceiros. Para a maioria dos produtos SaaS, isso é mais que suficiente:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
Nos route handlers:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
Quando um tenant faz upgrade do plano, você ativa as linhas de funcionalidade relevantes e invalida o cache. Direto, auditável e sem dependências externas.
Integração de Cobrança
Stripe é o padrão aqui por boas razões. O mapeamento é natural: um Stripe Customer por tenant, uma Subscription por tenant, níveis de plano mapeiam para Products e Prices.
A regra de design crítica: armazene o Stripe Customer ID no registro do tenant, não no registro do usuário. A cobrança pertence à organização, não à pessoa que inseriu o cartão de crédito.
O fluxo de webhook se parece com isso:
checkout.session.completed— vincule o Stripe Customer ID ao tenant, ative a assinatura.invoice.paid— estenda o período de cobrança, atualizetenant.paidUntil.invoice.payment_failed— marque o tenant como inadimplente, envie e-mails de cobrança.customer.subscription.updated— sincronize mudanças de nível de plano, atualize feature flags de acordo.customer.subscription.deleted— faça downgrade para plano gratuito ou suspenda.
A chave é a idempotência. O Stripe pode e vai enviar o mesmo webhook múltiplas vezes. Todo handler deve ser seguro para executar duas vezes com o mesmo payload de evento.
Isolamento de Dados e Segurança
Além do escopo de queries, existem algumas coisas não óbvias que você deve acertar desde cedo:
Row-Level Security (RLS): Se você está no PostgreSQL, habilite RLS como segunda linha de defesa. Mesmo que seu código de aplicação tenha um bug, o banco de dados em si vai se recusar a retornar linhas que não correspondam ao contexto do tenant atual. Defina app.current_tenant no momento da conexão e escreva policies contra ele.
Contexto de logging: Toda linha de log deve incluir tenantId. Quando algo quebra às 3 da manhã, você precisa saber qual tenant foi afetado sem vasculhar traces de requisição. Logging estruturado com um campo de tenant embutido no contexto do logger torna isso automático.
Backups e exclusão de dados: Tenants vão cancelar, e alguns vão invocar GDPR ou direitos de exclusão de dados. Se todos os seus dados estão em tabelas compartilhadas, purgar um único tenant significa instruções DELETE cuidadosamente elaboradas em cada tabela. Com schema ou banco de dados por tenant, é um DROP SCHEMA ou DROP DATABASE. Planeje para isso desde o primeiro dia.
Rate limiting por tenant: Um rate limiter global não é suficiente. A importação em lote de um tenant não deveria consumir o budget de rate do outro tenant. Escope seu rate limiter por tenantId, não apenas por IP ou usuário.
Criptografia em repouso: Para indústrias sensíveis, criptografe os dados do tenant em repouso com chaves por tenant. Isso adiciona complexidade ao gerenciamento de chaves, mas significa que revogar o acesso de um tenant é tão simples quanto destruir sua chave. Nenhum dado permanece legível em disco após o offboarding.
Considerações Finais
Multi-tenancy não é uma biblioteca que você instala. É um conjunto de decisões arquiteturais que toca cada camada da sua stack, do banco de dados ao CDN. A boa notícia é que os padrões são bem compreendidos. A parte complicada é aplicá-los com o nível certo de isolamento para o seu mercado e estágio específicos.
Comece com tudo compartilhado e uma camada disciplinada de escopo. Adicione limites de isolamento conforme seus clientes exigirem. Não faça engenharia excessiva para requisitos de conformidade que você ainda não tem, mas certifique-se de que sua arquitetura pode evoluir em direção a isolamento mais rigoroso sem uma reescrita.
Já construí sistemas multi-tenant desde plataformas de restaurantes até SaaS médico — cada um com seus próprios requisitos de isolamento e escalabilidade. Os padrões neste post são os que se sustentaram em todos eles.
Projetos Relacionados
RestoHub
Restaurantes param de perder 30% para o Uber Eats — ganham seu próprio sistema de pedidos, cardápio, site e programa de fidelidade em uma única plataforma. Experiência completa no estilo Uber Eats, mas o restaurante fica com cada centavo.
TakeCare
Agora uma enfermeira monitora 250 pacientes remotamente — substituindo ligações manuais e visitas domiciliares nos maiores hospitais de Quebec. Em operação no Jewish General, CHUM e Douglas Mental Health Institute.