SaaS B2B tem uma característica que muda toda a arquitetura: múltiplos clientes (tenants) compartilham a mesma aplicação, mas os dados de um não podem vazar para o outro. E cada cliente quer analytics, relatórios e integrações que consomem esses dados de formas diferentes.
Esse artigo cobre as três decisões de arquitetura de dados que mais impactam um SaaS B2B em produção: a estratégia de isolamento de tenants, o pipeline de analytics e como estruturar o acesso a dados para escalar sem reescrever tudo.
A decisão mais importante: como isolar os dados por tenant
Existem três abordagens. A escolha define custos, complexidade operacional e limites de escala para os próximos anos.
Abordagem 1: Banco separado por tenant
Cada tenant tem seu próprio banco de dados. Isolamento máximo — uma falha no banco do tenant A não afeta o tenant B. Backup, restore e migração são por tenant.
O custo operacional sobe rápido: 100 tenants significa 100 bancos para monitorar, fazer backup e aplicar migrations. Em PostgreSQL na AWS RDS, cada instância tem custo fixo independente do uso.
Quando faz sentido: plataformas enterprise com poucos clientes de alto valor, contratos que exigem isolamento físico de dados (saúde, financeiro, governo) ou tenants que precisam de customizações de schema diferentes entre si.
// Factory que resolve a connection string pelo tenant atual
public class TenantDbConnectionFactory
{
private readonly ITenantContext _tenantContext;
private readonly IConfiguration _config;
public TenantDbConnectionFactory(ITenantContext tenantContext, IConfiguration config)
{
_tenantContext = tenantContext;
_config = config;
}
public string GetConnectionString()
{
var tenantId = _tenantContext.TenantId;
// Connection strings registradas por tenant no config/secrets
return _config[$"ConnectionStrings:Tenant_{tenantId}"]
?? throw new InvalidOperationException($"No connection string for tenant {tenantId}");
}
}
Abordagem 2: Schema separado por tenant (PostgreSQL)
Um único banco, múltiplos schemas. O tenant `acme` tem suas tabelas em `acme.orders`, `acme.customers`. O tenant `globex` tem `globex.orders`, `globex.customers`. As tabelas têm a mesma estrutura — apenas o schema muda.
Melhor equilíbrio entre isolamento e custo operacional. Um banco para monitorar, mas migrations aplicadas por schema. O PostgreSQL suporta bem este modelo; o SQL Server usa um conceito similar.
// DbContext que troca o search_path do PostgreSQL por tenant
public class AppDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Todas as entidades vão para o schema do tenant atual
modelBuilder.HasDefaultSchema(_tenantContext.SchemaName);
base.OnModelCreating(modelBuilder);
}
// Sobrescreve a abertura da conexão para definir search_path
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await Database.ExecuteSqlRawAsync(
$"SET search_path TO {_tenantContext.SchemaName}, public",
cancellationToken);
return await base.SaveChangesAsync(cancellationToken);
}
}
Aplicar migrations em múltiplos schemas exige um job de provisionamento. Ao criar um novo tenant, o sistema cria o schema e aplica todas as migrations existentes:
public class TenantProvisioningService
{
private readonly IServiceProvider _serviceProvider;
public async Task ProvisionTenantAsync(string tenantId)
{
// Cria schema
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.ExecuteSqlRawAsync(
$"CREATE SCHEMA IF NOT EXISTS {tenantId}");
// Aplica todas as migrations no schema do novo tenant
await db.Database.MigrateAsync();
}
}
Abordagem 3: Row-Level Security (RLS)
Todas as tabelas compartilhadas, com uma coluna `tenant_id` em cada linha. O PostgreSQL oferece Row-Level Security nativo: uma policy no banco garante que cada conexão só veja os dados do seu tenant, independente da query.
Menor custo operacional e mais simples de operar. O risco: um bug na configuração do RLS pode expor dados entre tenants — o que num SaaS B2B é catastrófico. Exige testes de segurança rigorosos.
-- Habilita RLS na tabela
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: cada conexão só vê os dados do seu tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Na aplicação, define o tenant antes de qualquer query
SET app.current_tenant_id = '550e8400-e29b-41d4-a716-446655440000';
// Interceptor EF Core que injeta o tenant_id no contexto do PostgreSQL
public class TenantRlsInterceptor : DbConnectionInterceptor
{
private readonly ITenantContext _tenantContext;
public TenantRlsInterceptor(ITenantContext tenantContext)
{
_tenantContext = tenantContext;
}
public override async Task ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken cancellationToken = default)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"SET app.current_tenant_id = '{_tenantContext.TenantId}'";
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
}
Decisão de modelagem: tenant_id em todas as tabelas ou não?
Se você escolheu RLS ou schema separado, há uma decisão secundária: desnormalizar o `tenant_id` em todas as tabelas, ou depender do schema/RLS para garantir o isolamento?
A recomendação prática: mesmo com RLS ou schema, mantenha `tenant_id` explícito nas tabelas de maior volume. Facilita queries analíticas cross-tenant (quando você precisa agregar dados de múltiplos clientes), migrations futuras entre estratégias de isolamento e auditoria.
Pipeline de analytics: separar o banco transacional do analítico
O banco OLTP (transacional) é otimizado para escritas rápidas e leituras de poucos registros por vez. Rodar relatórios pesados nele degrada a performance para todos os tenants.
A solução padrão é um pipeline de dados separado:
[PostgreSQL OLTP] → [Change Data Capture] → [Data Warehouse] → [BI / API de relatórios]
Change Data Capture (CDC) com Debezium
Debezium monitora o WAL (Write-Ahead Log) do PostgreSQL e publica cada inserção, atualização e deleção como evento numa fila Kafka. Zero impacto no banco transacional — o CDC lê apenas o log, não as tabelas.
# Configuração do conector Debezium para PostgreSQL
connector.class: io.debezium.connector.postgresql.PostgresConnector
database.hostname: postgres.internal
database.port: 5432
database.dbname: saas_prod
database.server.name: saas
# Captura apenas as tabelas relevantes
table.include.list: public.orders, public.subscriptions, public.events
# Slot de replicação (criado automaticamente)
slot.name: debezium_slot
plugin.name: pgoutput
Data Warehouse com BigQuery ou ClickHouse
Para SaaS B2B, duas escolhas comuns para o data warehouse analítico:
BigQuery (GCP): serverless, paga por query, escala ilimitada. Ideal para volumes menores com picos irregulares. Integra com Looker Studio para BI out-of-the-box.
ClickHouse: self-hosted ou cloud, performance de queries analíticas até 100x mais rápida que PostgreSQL. Ideal para volumes grandes com custo controlado. Mais complexo de operar.
Para a maioria dos SaaS B2B com menos de 10 TB de dados analíticos, BigQuery é a escolha de menor fricção operacional.
API de relatórios: não exponha o data warehouse diretamente
Crie uma camada de API entre o data warehouse e o frontend. Motivos:
- Controle de acesso: garantir que o tenant A não veja dados do tenant B nos relatórios
- Cache de queries caras: relatórios mensais não precisam rodar a cada requisição
- Versionamento: mudanças no schema do DW não quebram clientes que consomem a API
// Endpoint de relatório com cache por tenant
[HttpGet("reports/revenue-summary")]
public async Task<IActionResult> GetRevenueSummary(
[FromQuery] DateOnly from,
[FromQuery] DateOnly to)
{
var tenantId = _tenantContext.TenantId;
var cacheKey = $"revenue:{tenantId}:{from}:{to}";
if (_cache.TryGetValue(cacheKey, out RevenueSummaryDto? cached))
return Ok(cached);
var result = await _analyticsService.GetRevenueSummaryAsync(tenantId, from, to);
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(15));
return Ok(result);
}
Estratégia de acesso a dados na aplicação: repository vs. specification
Em SaaS B2B, queries são frequentemente compostas por múltiplos filtros dinâmicos: tenant, data, status, plano, usuário. O Specification Pattern resolve isso sem multiplicar métodos no repositório:
public class OrderSpecification : Specification<Order>
{
public OrderSpecification(Guid tenantId, OrderFilter filter)
{
// Tenant isolation sempre presente
Query.Where(o => o.TenantId == tenantId);
if (filter.Status.HasValue)
Query.Where(o => o.Status == filter.Status.Value);
if (filter.From.HasValue)
Query.Where(o => o.CreatedAt >= filter.From.Value);
if (filter.To.HasValue)
Query.Where(o => o.CreatedAt <= filter.To.Value);
if (filter.PlanId.HasValue)
Query.Where(o => o.Subscription.PlanId == filter.PlanId.Value);
// Paginação
Query.Skip(filter.Page * filter.PageSize)
.Take(filter.PageSize)
.OrderByDescending(o => o.CreatedAt);
}
}
// Uso no handler
var spec = new OrderSpecification(tenantId, filter);
var orders = await _orderRepository.ListAsync(spec, cancellationToken);
Monitoramento e alertas específicos para multi-tenant
SaaS B2B tem padrões de acesso muito diferentes entre tenants. Um tenant grande pode disparar queries caras que degradam a experiência dos demais. Monitore:
- Query time por tenant: identifique quais tenants concentram o maior tempo de CPU no banco
- Row count por tenant: tenants que crescem muito rápido podem precisar de migração para estratégia de isolamento mais forte
- Replication lag: atraso no CDC afeta a frescura dos dados no data warehouse
- API latency por tenant: degradação de um tenant específico pode indicar problema de dados ou query mal otimizada
-- Query para identificar os tenants com maior carga no banco (PostgreSQL)
SELECT
tenant_id,
COUNT(*) as query_count,
AVG(mean_exec_time) as avg_ms,
SUM(total_exec_time) as total_ms
FROM pg_stat_statements
JOIN (
SELECT DISTINCT tenant_id FROM orders
) t ON query LIKE '%' || t.tenant_id::text || '%'
GROUP BY tenant_id
ORDER BY total_ms DESC
LIMIT 20;
Checklist de decisões para o seu SaaS B2B
- Quantos tenants você projeta em 2 anos? <50: banco separado. 50-500: schema separado. 500+: RLS com muito teste de segurança.
- Você tem contratos com requisitos de isolamento físico? Banco separado é obrigatório.
- Analytics é uma feature vendida para os tenants ou uso interno? Se vendida, invista cedo em pipeline separado.
- Seu time tem experiência com CDC e data warehouse? Se não, comece com replicação agendada (nightly batch) antes de CDC em tempo real.
- As queries analíticas já estão degradando o banco transacional? Esse é o sinal para separar os pipelines.
A arquitetura de dados de um SaaS B2B raramente está errada no início — ela envelhece mal quando o produto cresce e a estratégia não evoluiu com ele. Escolher a abordagem certa no momento certo é o que separa produtos que escalam de sistemas que precisam ser reescritos.