Stack e Engenharia

28 de janeiro de 2026 · 14 min de leitura

O post anterior cobriu os conceitos de multi-tenancy e as três estratégias de isolamento. Este é o post de implementação — o que você realmente escreve no .NET Core para fazer o isolamento funcionar de forma confiável, sem vazar dados entre tenants e sem matar a performance à medida que o número de clientes cresce. Vamos do pipeline de resolução de tenant até os filtros globais do EF Core, passando pelas armadilhas de performance que só aparecem com dados reais em produção.

O pipeline de resolução de tenant: onde tudo começa

Antes de qualquer query no banco, a aplicação precisa saber qual tenant está sendo atendido naquele request. Esse processo — tenant resolution — precisa acontecer cedo no pipeline do ASP.NET Core, antes que qualquer serviço de domínio seja chamado.

A abordagem mais limpa é criar um middleware dedicado que resolve o tenant e o coloca disponível via IHttpContextAccessor ou, melhor ainda, via um serviço com escopo de request (ITenantContext) registrado no contêiner de DI.

// ITenantContext.cs
public interface ITenantContext
{
    Guid TenantId { get; }
    string Plan { get; }
}

// TenantContext.cs
public class TenantContext : ITenantContext
{
    public Guid TenantId { get; set; }
    public string Plan { get; set; } = string.Empty;
}
// TenantResolutionMiddleware.cs
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;

    public TenantResolutionMiddleware(RequestDelegate next)
        => _next = next;

    public async Task InvokeAsync(HttpContext context, TenantContext tenantContext)
    {
        // Estratégia 1: claim do JWT (recomendada para APIs)
        var tenantClaim = context.User.FindFirst("tenant_id")?.Value;

        // Estratégia 2: subdomínio
        // var host = context.Request.Host.Host; // empresa-a.seuapp.com.br
        // var subdomain = host.Split('.').FirstOrDefault();

        if (!Guid.TryParse(tenantClaim, out var tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await context.Response.WriteAsJsonAsync(new { error = "tenant_id inválido ou ausente" });
            return;
        }

        tenantContext.TenantId = tenantId;
        tenantContext.Plan = context.User.FindFirst("plan")?.Value ?? "free";

        await _next(context);
    }
}
// Program.cs
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<TenantContext>();

// Registrar após UseAuthentication e UseAuthorization
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<TenantResolutionMiddleware>();

O TenantContext é scoped — uma instância por request HTTP. Isso garante que o tenantId resolvido no início do request seja o mesmo usado pelo DbContext, pelos serviços de domínio e por qualquer outro componente que precise saber com qual tenant está trabalhando.

DbContext com filtros globais: a camada de segurança que não depende do desenvolvedor

Com o tenant resolvido no pipeline, o próximo passo é garantir que o EF Core aplique o filtro de tenant automaticamente em todas as queries — sem depender de que cada desenvolvedor lembre de adicionar .Where(x => x.TenantId == tenantId) manualmente.

O mecanismo para isso é o HasQueryFilter do EF Core, configurado no OnModelCreating:

// AppDbContext.cs
public class AppDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<Projeto> Projetos => Set<Projeto>();
    public DbSet<Tarefa> Tarefas => Set<Tarefa>();
    public DbSet<Usuario> Usuarios => Set<Usuario>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Aplicar filtro global em todas as entidades que implementam ITenantEntity
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                modelBuilder.Entity(entityType.ClrType)
                    .HasQueryFilter(BuildTenantFilter(entityType.ClrType));
            }
        }

        base.OnModelCreating(modelBuilder);
    }

    private LambdaExpression BuildTenantFilter(Type entityType)
    {
        var param = Expression.Parameter(entityType, "e");
        var tenantIdProperty = Expression.Property(param, nameof(ITenantEntity.TenantId));
        var tenantIdValue = Expression.Constant(_tenantContext.TenantId);
        var condition = Expression.Equal(tenantIdProperty, tenantIdValue);
        return Expression.Lambda(condition, param);
    }
}
// ITenantEntity.cs
public interface ITenantEntity
{
    Guid TenantId { get; }
}

// Exemplo de entidade de domínio
public class Projeto : ITenantEntity
{
    public Guid Id { get; private set; }
    public Guid TenantId { get; private set; }
    public string Nome { get; private set; } = string.Empty;
    // ...
}

Com esse padrão, toda query executada pelo AppDbContext inclui automaticamente WHERE tenant_id = @tenantId. O desenvolvedor não precisa lembrar — o filtro está no modelo.

Quando desativar o filtro global

Há cenários em que você precisa consultar dados entre tenants: jobs administrativos, relatórios de plataforma, scripts de migração. Para esses casos, use IgnoreQueryFilters():

// Consulta administrativa — sem filtro de tenant
var todosOsProjetos = await _context.Projetos
    .IgnoreQueryFilters()
    .Where(p => p.CriadoEm > DateTime.UtcNow.AddDays(-7))
    .ToListAsync();

O importante é que IgnoreQueryFilters() é explícito — você vê no código que o filtro foi removido intencionalmente. Não é uma omissão acidental.

TenantId no SaveChanges: prevenindo inserção em tenant errado

O filtro global protege as leituras. Mas e as escritas? Se um desenvolvedor criar uma entidade sem definir o TenantId, ela vai ser salva sem o tenant correto — e o filtro de leitura vai escondê-la do próprio cliente que a criou.

A solução é interceptar o SaveChangesAsync e definir automaticamente o TenantId em qualquer entidade nova que implemente ITenantEntity:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
    {
        if (entry.State == EntityState.Added)
        {
            // Garantir que o TenantId sempre será o do contexto atual
            entry.Property(nameof(ITenantEntity.TenantId)).CurrentValue = _tenantContext.TenantId;
        }

        if (entry.State == EntityState.Modified)
        {
            // Prevenir que alguém altere o TenantId de uma entidade existente
            entry.Property(nameof(ITenantEntity.TenantId)).IsModified = false;
        }
    }

    return await base.SaveChangesAsync(cancellationToken);
}

Com isso, mesmo que o código de domínio não defina o TenantId explicitamente, ele será preenchido automaticamente na persistência. E nenhum update vai conseguir mover uma entidade de um tenant para outro — o campo fica protegido contra modificação.

Armadilhas de performance que aparecem em produção

Os filtros globais funcionam bem no desenvolvimento. Em produção, com dados reais e múltiplos tenants, três problemas de performance aparecem com frequência.

1. Índice ausente no tenant_id

Sem índice em tenant_id, cada query com o filtro global vira um full table scan. Com mil registros, imperceptível. Com cem mil registros distribuídos entre dezenas de tenants, a latência começa a aparecer.

Adicione o índice em toda entidade que usa o filtro:

modelBuilder.Entity<Projeto>(entity =>
{
    entity.HasIndex(p => p.TenantId);

    // Melhor ainda: índice composto cobrindo as queries mais comuns
    entity.HasIndex(p => new { p.TenantId, p.Status });
    entity.HasIndex(p => new { p.TenantId, p.CriadoEm });
});

2. N+1 com navegação entre entidades

O filtro global é aplicado automaticamente nas queries diretas. Mas quando você navega por propriedades de navegação (lazy loading ou include), cada entidade relacionada também passa pelo filtro — o que pode gerar queries adicionais não esperadas.

Use Include explícito e verifique as queries geradas com EnableSensitiveDataLogging em desenvolvimento:

// Correto: carrega tudo em uma query com JOIN
var projetos = await _context.Projetos
    .Include(p => p.Tarefas)
    .Include(p => p.Membros)
    .Where(p => p.Status == StatusProjeto.Ativo)
    .ToListAsync();

Nunca habilite lazy loading em produção em SaaS multi-tenant. O risco de N+1 silencioso é alto demais.

3. DbContext com escopo errado em Workers

O AppDbContext é registrado como Scoped — uma instância por request HTTP. Em BackgroundService e Workers, não existe request HTTP, então não existe scope automático. Usar o DbContext injetado diretamente em um Worker vai lançar exceção ou, pior, reutilizar o mesmo contexto entre operações de tenants diferentes.

A solução é criar um scope explicitamente no Worker:

public class RelatorioWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public RelatorioWorker(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Cada operação cria seu próprio scope — DbContext e TenantContext isolados
            using var scope = _scopeFactory.CreateScope();
            var tenantContext = scope.ServiceProvider.GetRequiredService<TenantContext>();
            tenantContext.TenantId = /* tenantId obtido da fila ou do job */;

            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            // queries aqui usam o tenantId definido acima

            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

Schema por tenant com EF Core: quando e como implementar

A estratégia de schema por tenant exige uma abordagem diferente porque o DbContext precisa saber qual schema usar antes de abrir a conexão — e isso muda a forma como o contexto é construído.

A abordagem mais limpa é resolver o schema no factory do DbContext:

public class TenantDbContextFactory
{
    private readonly ITenantContext _tenantContext;
    private readonly IConfiguration _configuration;

    public TenantDbContextFactory(ITenantContext tenantContext, IConfiguration configuration)
    {
        _tenantContext = tenantContext;
        _configuration = configuration;
    }

    public AppDbContext Create()
    {
        var schema = $"tenant_{_tenantContext.TenantId:N}";

        var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
        optionsBuilder.UseNpgsql(
            _configuration.GetConnectionString("Default"),
            npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schema)
        );

        return new AppDbContext(optionsBuilder.Options, _tenantContext, schema);
    }
}
// AppDbContext adaptado para schema por tenant
public class AppDbContext : DbContext
{
    private readonly string _schema;

    public AppDbContext(DbContextOptions<AppDbContext> options,
        ITenantContext tenantContext, string schema)
        : base(options)
    {
        _schema = schema;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema(_schema);
        base.OnModelCreating(modelBuilder);
    }
}

O problema das migrations com schema por tenant

Com shared schema, você roda dotnet ef database update uma vez e todos os tenants ficam atualizados. Com schema por tenant, você precisa aplicar a migration em cada schema individualmente.

A solução é um job de migração que itera sobre todos os tenants ativos e aplica pendências:

public class TenantMigrationJob
{
    private readonly ITenantRepository _tenantRepository;
    private readonly TenantDbContextFactory _contextFactory;

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var tenants = await _tenantRepository.GetAllActiveAsync(cancellationToken);

        foreach (var tenant in tenants)
        {
            using var context = _contextFactory.CreateForTenant(tenant.Id);
            var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken);

            if (pendingMigrations.Any())
            {
                await context.Database.MigrateAsync(cancellationToken);
                _logger.LogInformation("Migração aplicada. TenantId={TenantId} Migrations={Count}",
                    tenant.Id, pendingMigrations.Count());
            }
        }
    }
}

Esse job precisa rodar antes de cada deploy que inclua migrations — nunca em paralelo com tráfego real se as migrations incluírem operações destrutivas.

Testando o isolamento: o teste que salva vidas em produção

A parte mais crítica de uma implementação multi-tenant é garantir que o isolamento não vai quebrar silenciosamente quando alguém adicionar uma nova feature meses depois. Testes automatizados são a única garantia sustentável.

Dois testes que devem existir em qualquer projeto multi-tenant:

Teste de não-vazamento de dados entre tenants:

[Fact]
public async Task QueryFilter_NaoRetornaDados_DeOutroTenant()
{
    // Arrange
    var tenantA = Guid.NewGuid();
    var tenantB = Guid.NewGuid();

    await using var dbA = CreateContextForTenant(tenantA);
    await dbA.Projetos.AddAsync(new Projeto { Nome = "Projeto do Tenant A", TenantId = tenantA });
    await dbA.SaveChangesAsync();

    // Act — consulta com contexto do tenant B
    await using var dbB = CreateContextForTenant(tenantB);
    var projetos = await dbB.Projetos.ToListAsync();

    // Assert
    Assert.Empty(projetos);
}

Teste de proteção na escrita:

[Fact]
public async Task SaveChanges_SempreDefine_TenantIdDoCContexto()
{
    // Arrange
    var tenantId = Guid.NewGuid();
    await using var db = CreateContextForTenant(tenantId);

    // Cria projeto sem definir TenantId explicitamente
    var projeto = new Projeto { Nome = "Projeto sem TenantId" };
    await db.Projetos.AddAsync(projeto);
    await db.SaveChangesAsync();

    // Act
    await using var dbVerificacao = CreateContextForTenant(tenantId);
    var salvo = await dbVerificacao.Projetos.IgnoreQueryFilters()
        .FirstAsync(p => p.Id == projeto.Id);

    // Assert
    Assert.Equal(tenantId, salvo.TenantId);
}

Rodar esses testes em cada PR que modifica o modelo de dados é o que garante que uma migration ou refatoração futura não vai criar uma brecha silenciosa de vazamento de dados.

Checklist de implementação: o que validar antes de ir para produção

Antes de colocar o primeiro tenant real em um sistema multi-tenant, valide cada item desta lista:

Pipeline de resolução:

  • ITenantContext é resolvido antes que qualquer serviço de domínio seja chamado
  • Requests sem tenant_id válido retornam 401, nunca 500
  • O TenantId está em claims do JWT — não apenas no header ou no subdomínio

EF Core:

  • Todas as entidades que devem ser isoladas implementam ITenantEntity
  • HasQueryFilter está configurado para todas as entidades ITenantEntity
  • SaveChangesAsync define TenantId automaticamente em entidades novas
  • Modificação de TenantId em entidades existentes está bloqueada
  • tenant_id tem índice em todas as tabelas relevantes

Testes:

  • Teste de não-vazamento de dados entre tenants está passando
  • Teste de atribuição automática de TenantId está passando
  • Lazy loading está desabilitado globalmente

Workers e Jobs:

  • Jobs que operam em nome de tenants específicos criam scope explícito
  • Jobs administrativos usam IgnoreQueryFilters() de forma intencional e documentada

Migrations:

  • Processo de migration está documentado e automatizado no pipeline de deploy
  • Para schema por tenant: job de migração testado com múltiplos tenants ativos

Se você está construindo a fundação de um SaaS multi-tenant e quer uma arquitetura que escale sem reescritas — do isolamento de dados até o pipeline de deploy — fala com a Logik Digital.

Conversar com a Logik Digital

Tags: plataforma multi-tenant .net core, Stack e Engenharia, TOFU