.NET Arquitetura SaaS Banco de Dados Analytics

Arquitetura de dados para SaaS B2B: multi-tenant, analytics e pipelines em produção

Como estruturar dados em SaaS B2B: estratégias de multi-tenancy (schema, row-level, banco separado), pipelines de analytics e decisões de arquitetura para produção real.

N
Neryx Digital Architects
22 de abril de 2026
16 min de leitura
280 profissionais leram
Categoria: Arquitetura .NET Público: Arquitetos de software e tech leads construindo ou evoluindo produtos SaaS B2B Etapa: Consideração

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.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.