Multi-tenancy .NET SaaS C# Arquitetura

Multi-tenancy em SaaS .NET: estratégias de isolamento, schema por tenant e Row-Level Security

Como implementar multi-tenancy em aplicações SaaS com .NET: banco compartilhado vs separado, schema por tenant no PostgreSQL, Row-Level Security.

N
Neryx Digital Architects
31 de dezembro de 2025
14 min de leitura
220 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Multi-tenancy é a capacidade de uma mesma aplicação servir múltiplos clientes (tenants) com isolamento de dados. É o modelo padrão de SaaS: uma instalação, muitos clientes. A escolha da estratégia de isolamento define a segurança, performance, custo operacional e complexidade de manutenção da sua plataforma.

As três estratégias de isolamento

Cada estratégia tem trade-offs claros entre custo, isolamento e complexidade:

1. Banco separado por tenant — máximo isolamento, custo operacional alto, difícil de gerenciar com muitos tenants. Ideal para tenants grandes com requisitos regulatórios rigorosos (LGPD, HIPAA, SOC2).

2. Schema separado por tenant — isolamento forte com custo moderado. Um banco, mas cada tenant tem seu próprio schema PostgreSQL (tenant_abc.pedidos, tenant_xyz.pedidos). Suporta centenas de tenants sem problema. É o ponto de equilíbrio mais comum em SaaS B2B.

3. Banco compartilhado com tenant_id — menor custo, menor isolamento, mais simples. Todas as tabelas têm uma coluna tenant_id e Row-Level Security (RLS) no banco garante o isolamento. Ideal para SaaS com muitos tenants pequenos (B2C, freemium).

Resolução de tenant: identificar quem está chamando

Antes de qualquer coisa, a aplicação precisa saber qual tenant está fazendo a request:

// Interface central de contexto de tenant:
public interface ITenantContext
{
    Guid TenantId { get; }
    string TenantSlug { get; }
    string? DatabaseSchema { get; } // para schema-per-tenant
}

// Resolver por subdomínio (tenant1.seuapp.com.br): public class SubdomainTenantResolver : ITenantResolver { private readonly ITenantRepository _tenants;

public async Task<TenantInfo?> ResolveAsync(HttpContext context)
{
    var host = context.Request.Host.Host; // "tenant1.seuapp.com.br"
    var slug = host.Split('.')[0];        // "tenant1"

    if (slug == "www" || slug == "api") return null;

    return await _tenants.GetBySlugAsync(slug);
}

}

// Resolver por JWT claim (SaaS B2B com login centralizado): public class JwtTenantResolver : ITenantResolver { public Task<TenantInfo?> ResolveAsync(HttpContext context) { var tenantId = context.User.FindFirst(“tenant_id”)?.Value; if (!Guid.TryParse(tenantId, out var id)) return Task.FromResult<TenantInfo?>(null);

    return Task.FromResult&lt;TenantInfo?&gt;(new TenantInfo { TenantId = id });
}

}

// Middleware que resolve o tenant e injeta no contexto: public class TenantMiddleware { private readonly RequestDelegate _next;

public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, ITenantContext tenantCtx)
{
    var tenant = await resolver.ResolveAsync(context);

    if (tenant == null)
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("Tenant não identificado.");
        return;
    }

    // Injeta o tenant no contexto via AsyncLocal (thread-safe, per-request)
    tenantCtx.SetCurrent(tenant);

    await _next(context);
}

}

Estratégia 1: Schema por tenant no PostgreSQL

Cada tenant tem seu próprio schema. A aplicação troca o search_path do EF Core por request:

// DbContext multi-tenant com troca dinâmica de schema:
public class TenantDbContext : DbContext
{
    private readonly ITenantContext _tenant;
public TenantDbContext(DbContextOptions&lt;TenantDbContext&gt; options, ITenantContext tenant)
    : base(options)
{
    _tenant = tenant;
}

public DbSet&lt;Pedido&gt; Pedidos { get; set; }
public DbSet&lt;Cliente&gt; Clientes { get; set; }
public DbSet&lt;Produto&gt; Produtos { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    // Define o search_path do PostgreSQL para o schema do tenant
    // Isso faz com que todas as queries usem as tabelas do tenant correto
    if (!string.IsNullOrEmpty(_tenant.DatabaseSchema))
    {
        // Intercepta a conexão para definir o schema:
        optionsBuilder.AddInterceptors(new SetSearchPathInterceptor(_tenant.DatabaseSchema));
    }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasDefaultSchema(_tenant.DatabaseSchema ?? "public");
    // As entidades herdam o schema automaticamente
}

}

// Interceptor que executa SET search_path na abertura da conexão: public class SetSearchPathInterceptor : DbConnectionInterceptor { private readonly string _schema;

public SetSearchPathInterceptor(string schema) => _schema = schema;

public override async Task ConnectionOpenedAsync(
    DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct)
{
    using var cmd = connection.CreateCommand();
    cmd.CommandText = $"SET search_path TO {_schema}, public";
    await cmd.ExecuteNonQueryAsync(ct);
}

}

Provisionamento automático de schema para novo tenant

public class TenantProvisioningService
{
    private readonly IDbConnection _adminConnection;
    private readonly ILogger<TenantProvisioningService> _logger;
public async Task ProvisionarTenantAsync(Guid tenantId, string slug)
{
    var schema = $"tenant_{slug.Replace("-", "_")}";

    _logger.LogInformation("Provisionando schema {Schema} para tenant {TenantId}", schema, tenantId);

    // 1. Cria o schema
    await _adminConnection.ExecuteAsync($"CREATE SCHEMA IF NOT EXISTS {schema}");

    // 2. Aplica as migrations nesse schema específico
    // (usando EF Core com o schema do novo tenant)
    var optionsBuilder = new DbContextOptionsBuilder&lt;TenantDbContext&gt;();
    optionsBuilder.UseNpgsql(_adminConnectionString);

    var fakeTenant = new TenantInfo { TenantId = tenantId, DatabaseSchema = schema };
    using var ctx = new TenantDbContext(optionsBuilder.Options, new StaticTenantContext(fakeTenant));
    await ctx.Database.MigrateAsync();

    // 3. Registra o tenant no catálogo central
    await _adminConnection.ExecuteAsync(
        "INSERT INTO catalogo.tenants (id, slug, schema, criado_em) VALUES (@Id, @Slug, @Schema, @CriadoEm)",
        new { Id = tenantId, Slug = slug, Schema = schema, CriadoEm = DateTime.UtcNow });

    _logger.LogInformation("Tenant {Slug} provisionado com sucesso", slug);
}

}

Estratégia 2: Banco compartilhado com Row-Level Security (PostgreSQL)

RLS garante no nível do banco que cada conexão só acessa os dados do seu tenant — mesmo que o código cometa um bug de filtragem:

-- No PostgreSQL: habilitar RLS nas tabelas
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
ALTER TABLE clientes ENABLE ROW LEVEL SECURITY;
ALTER TABLE produtos ENABLE ROW LEVEL SECURITY;

— Policy: cada usuário da aplicação só vê seus dados CREATE POLICY tenant_isolation ON pedidos USING (tenant_id = current_setting(‘app.current_tenant_id’)::uuid);

CREATE POLICY tenant_isolation ON clientes USING (tenant_id = current_setting(‘app.current_tenant_id’)::uuid);

— Usuário da aplicação (não é superuser, então RLS é aplicado) CREATE USER app_user WITH PASSWORD ‘senha_segura’; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; — Superusers bypassam RLS — use um role normal para a aplicação!

// No EF Core: define o setting de tenant na abertura da conexão
public class RlsInterceptor : DbConnectionInterceptor
{
    private readonly ITenantContext _tenant;

    public RlsInterceptor(ITenantContext tenant) => _tenant = tenant;

    public override async Task ConnectionOpenedAsync(
        DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct)
    {
        // Define a variável de sessão que o RLS usa
        using var cmd = connection.CreateCommand();
        cmd.CommandText = "SELECT set_config('app.current_tenant_id', @TenantId, false)";
        cmd.Parameters.Add(new NpgsqlParameter("TenantId", _tenant.TenantId.ToString()));
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

// DbContext com RLS:
public class SharedDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(new RlsInterceptor(_tenant));
    }

    // Filtragem adicional no EF Core como segunda camada de segurança:
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Pedido>()
            .HasQueryFilter(p => p.TenantId == _tenant.TenantId);

        modelBuilder.Entity<Cliente>()
            .HasQueryFilter(c => c.TenantId == _tenant.TenantId);
    }
}

Migrations em multi-tenancy: schema e shared

// Para schema-per-tenant: um script de migration por schema
// Execute migrations em paralelo para múltiplos tenants:
public class MultiTenantMigrationService
{
    private readonly ITenantRepository _tenants;
public async Task MigrarTodosTenantsAsync()
{
    var todos = await _tenants.ListarTodosAsync();

    // Migrar em paralelo (controle de concorrência com SemaphoreSlim)
    var semaphore = new SemaphoreSlim(5); // max 5 tenants simultâneos

    await Parallel.ForEachAsync(todos, async (tenant, ct) =>
    {
        await semaphore.WaitAsync(ct);
        try
        {
            using var ctx = CreateContextForTenant(tenant);
            await ctx.Database.MigrateAsync(ct);
            _logger.LogInformation("Tenant {Slug} migrado", tenant.Slug);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Falha ao migrar tenant {Slug}", tenant.Slug);
        }
        finally
        {
            semaphore.Release();
        }
    });
}

}

Cache por tenant: isolamento no Redis

// Prefix de cache sempre inclui o tenant ID para evitar vazamento de dados entre tenants:
public class TenantAwareCache
{
    private readonly IDistributedCache _cache;
    private readonly ITenantContext _tenant;
public async Task&lt;T?&gt; GetAsync&lt;T&gt;(string key, CancellationToken ct = default) where T : class
{
    // Chave com namespace por tenant: "tenant:{id}:{key}"
    var tenantKey = $"tenant:{_tenant.TenantId}:{key}";
    var cached = await _cache.GetStringAsync(tenantKey, ct);
    return cached != null ? JsonSerializer.Deserialize&lt;T&gt;(cached) : null;
}

public async Task SetAsync&lt;T&gt;(string key, T value, TimeSpan? ttl = null, CancellationToken ct = default)
{
    var tenantKey = $"tenant:{_tenant.TenantId}:{key}";
    await _cache.SetStringAsync(
        tenantKey,
        JsonSerializer.Serialize(value),
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl ?? TimeSpan.FromMinutes(10) },
        ct);
}

// Invalida todo o cache de um tenant (ex: na troca de plano):
public async Task InvalidarTudoDoTenantAsync()
{
    // Requer StackExchange.Redis diretamente para scan por prefixo
    var pattern = $"NeryxSaaS:tenant:{_tenant.TenantId}:*";
    // ... (usando IServer.Keys como mostrado no artigo de Redis)
}

}

Plano de dados por tenant (quotas e limites)

// Aplicar limites de uso por plano do tenant:
public class TenantPlanMiddleware
{
    private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, ITenantPlanService planService)
{
    // Verifica uso do mês atual
    var uso = await planService.GetUsoAtualAsync(tenant.TenantId);
    var plano = await planService.GetPlanoAsync(tenant.TenantId);

    if (uso.ApiCalls >= plano.LimiteApiCallsMes)
    {
        context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        await context.Response.WriteAsJsonAsync(new
        {
            erro = "Limite de API do plano atingido",
            limite = plano.LimiteApiCallsMes,
            uso_atual = uso.ApiCalls,
            reset_em = uso.ResetEm.ToString("yyyy-MM-dd")
        });
        return;
    }

    // Registra a chamada (async, sem bloquear o request)
    _ = planService.RegistrarChamadaAsync(tenant.TenantId);

    await _next(context);
}

}

Qual estratégia escolher?

Para SaaS B2B com menos de 500 tenants e dados sensíveis por cliente, use schema-per-tenant no PostgreSQL — boa combinação de isolamento e custo. Para SaaS B2C ou freemium com milhares de tenants, banco compartilhado com RLS é mais prático. Banco separado por tenant só faz sentido para enterprise com contratos com cláusulas de isolamento físico obrigatório.

Conclusão

Multi-tenancy bem implementado é invisível para os usuários finais e robusto o suficiente para nunca misturar dados entre clientes. A escolha entre schema-per-tenant e banco compartilhado com RLS depende do perfil dos seus tenants e dos requisitos de compliance. O padrão de contexto de tenant injetado via middleware funciona para as duas abordagens.

Se você está construindo uma plataforma SaaS ou migrando de uma aplicação single-tenant para multi-tenant, a Neryx tem experiência com essas arquiteturas em produção. Consultoria inicial gratuita.

Leitura complementar:

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.