Destino Dev

Blog

Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database

Por John Pires · 19 de março de 2026

Contexto e motivacao

Multitenancy é uma preocupação real quando um produto precisa atender múltiplos clientes (tenants) dentro de uma única base de código. Existem três abordagens comuns com Entity Framework Core: discriminator (coluna tenant), schema por tenant e database por tenant. Cada uma tem trade-offs óbvios em isolamento, custo operacional e complexidade de deployment. Este artigo compara as abordagens e mostra implementações práticas em ASP.NET Core, com padrões para hook de resolução de tenant, DbContext dinâmico, filtros globais e aplicação de migrations.

Por que considerar multitenancy?

Empresas preferem multitenancy para reduzir custo de infraestrutura e simplificar atualizações. Mas essa consolidação pode separar dados de clientes de formas distintas: uma tabela com coluna tenant (mais simples), schemas isolados (isolamento lógico) ou databases separados (isolamento físico). A escolha impacta segurança, performance e operações de CI/CD.

Quando usar cada abordagem

Use discriminator quando quiser a solução mais simples e com menor overhead operacional. Schema por tenant é um bom meio-termo: facilita backups e roles por schema. Database por tenant é indicado quando clientes exigem isolamento físico, requisitos regulatórios ou picos de carga independentes.

Como funciona por baixo dos panos

Com EF Core a multitenancy se materializa em três pontos principais: modelagem do domínio, construção do DbContext e execução de queries. Conceitos chaves são global query filters, conventions para tabelas/schemas e dinamismo do connection string para databases por tenant. Internamente, filtros são traduzidos para SQL WHERE, enquanto schema e database mudam como o EF gera nomes e conexões.

Discriminator (coluna tenant)

Na abordagem discriminator, toda entidade multi-tenant possui uma coluna TenantId. Um filtro global aplica WHERE TenantId = . Isso é eficiente na maioria dos casos, mantém pooling e migrations simples, mas exige atenção a índices e tamanho de tabelas que crescem indefinidamente.

Schemas e databases

No modo schema, o EF mapeia tabelas para schemas dinâmicos: ToTable("Orders", schema). No modo database, o DbContext precisa construir a connection string para o banco do tenant. Ambos implicam em mudanças na estratégia de migrations: migrations por tenant (schema) ou execução de migrations em múltiplos bancos (database).

Implementacao passo a passo

Vamos implementar um pipeline prático: resolver tenant via host/header, registrar um ITenantProvider scoped, configurar DbContext dinâmico, aplicar global filters e mostrar comandos de migração. O objetivo é código real e utilizável em projetos ASP.NET Core.

Resolver o tenant: middleware e provider

Primeiro, um serviço que representa o tenant corrente. Ele deve ser scoped e populado por um middleware. Aqui usamos o domínio simples: tenant identificado por header "X-Tenant-ID" ou subdomínio.

O código abaixo implementa um provider e middleware que extrai tenant da request. O bloco demonstra o que o middleware faz: popula o TenantInfo na pipeline para injeção no DbContext.

using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;

public class TenantInfo
{
    public string Id { get; set; }
    public string ConnectionString { get; set; }
    public string Schema { get; set; }
}

public interface ITenantProvider { TenantInfo Current { get; } }

public class TenantProvider : ITenantProvider
{
    public TenantInfo Current { get; private set; }
    public void Set(TenantInfo info) => Current = info;
}

public class TenantMiddleware
{
    private readonly RequestDelegate _next;
    public TenantMiddleware(RequestDelegate next) => _next = next;

    public async Task Invoke(HttpContext http, TenantProvider provider)
    {
        // Exemplo simples: header X-Tenant-ID
        var id = http.Request.Headers["X-Tenant-ID"].ToString();
        if (string.IsNullOrEmpty(id)) id = "public"; // fallback

        // Em produção, resolver connection string e schema via store/config
        var info = new TenantInfo
        {
            Id = id,
            ConnectionString = $"Server=.;Database=app_{id};Trusted_Connection=True;",
            Schema = id == "public" ? "dbo" : id
        };

        provider.Set(info);
        await _next(http);
    }
}

Depois do middleware, o provider contém TenantInfo válido. O próximo passo é integrar com DbContext.

DbContext dinâmico e filtros globais

O DbContext precisa acessar TenantInfo no momento de construir opções ou durante OnModelCreating. Duas abordagens: criar factory que gera DbContextOptions por tenant, ou injetar o provider no DbContext e aplicar filtros no OnModelCreating.

O exemplo abaixo demonstra um DbContext que aplica um filtro global para TenantId e configura tabelas com schema dinâmico baseado no tenant corrente. Note que a injeção do provider é scoped e o OnModelCreating usa o valor atual, aplicando HasQueryFilter para filtrar dados automaticamente.

using Microsoft.EntityFrameworkCore;

public class AppEntity
{
    public int Id { get; set; }
    public string TenantId { get; set; }
    public string Name { get; set; }
}

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;

    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    public DbSet<AppEntity> Entities { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var tenant = _tenantProvider.Current?.Id ?? "public";

        // Aplica filtro global para isolar dados por tenant
        modelBuilder.Entity<AppEntity>().HasQueryFilter(e => e.TenantId == tenant);

        // Mapear tabela para schema do tenant - útil em migrations por schema
        modelBuilder.Entity<AppEntity>().ToTable("AppEntities", schema: tenant);

        base.OnModelCreating(modelBuilder);
    }
}

Com isso, qualquer consulta via DbContext inclui automaticamente WHERE TenantId = 'X' e as operações geram SQL com schema correspondente.

Configurando DI no Startup/Program

Registrar o TenantProvider como scoped e adicionar o middleware. Para database-per-tenant você precisa de um DbContextFactory que constrói opções com connection string do TenantInfo.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Builder;

// No Program.cs / Startup.cs
services.AddScoped<TenantProvider>();
services.AddScoped<ITenantProvider>(sp => sp.GetRequiredService<TenantProvider>());

// DbContext com options configuradas no provider (per-request)
services.AddDbContext<AppDbContext>((sp, options) =>
{
    var tenant = sp.GetRequiredService<ITenantProvider>().Current;
    var conn = tenant?.ConnectionString ?? "Server=.;Database=app_public;Trusted_Connection=True;";
    options.UseSqlServer(conn);
});

// Middleware
app.UseMiddleware<TenantMiddleware>();

Acima, ao resolver o DbContext, o EF recebe a connection string do tenant corrente — isso permite database por tenant. Para schema-based, você pode usar uma única connection string e mudar schema no OnModelCreating como mostrado antes.

Patterns e boas praticas

Existem padrões que reduzem bugs e facilitam manutenção: tenant provider scoped, dbcontext per request, global query filters, e migrations strategy explícita. Também adotar contratos explícitos em entidades (ITenantEntity) ajuda a aplicar convenções via reflection ou ModelBuilder.

Interfaces e convenções

Crie uma interface que garanta TenantId nas entidades e use um extension method para aplicar filtros automaticamente no OnModelCreating. Isso evita esquecer de mapear uma entidade e reduz erro humano.

public interface ITenantEntity
{
    string TenantId { get; set; }
}

public static class ModelBuilderExtensions
{
    public static void ApplyTenantConventions(this ModelBuilder mb, string tenant)
    {
        foreach (var entityType in mb.Model.GetEntityTypes())
        {
            if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                // aplica filtro dinamicamente
                mb.Entity(entityType.ClrType).HasQueryFilter(GetFilterLambda(entityType.ClrType, tenant));
            }
        }
    }

    // Implementação simplificada de criação de expressão
    private static LambdaExpression GetFilterLambda(Type type, string tenant)
    {
        var param = Expression.Parameter(type, "e");
        var prop = Expression.Property(param, "TenantId");
        var body = Expression.Equal(prop, Expression.Constant(tenant));
        return Expression.Lambda(body, param);
    }
}

Depois chamar mb.ApplyTenantConventions(tenant) no OnModelCreating.

Migrations e deploy

Escolha uma estratégia e documente-a. Para discriminator: uma única migration. Para schema: gere migrations e execute CreateSchema + Apply in cada schema. Para database: aplique migrations em cada database separado automaticamente ou via pipeline.

  • Discriminator: manter migrations centralizadas.
  • Schema: script para criar schema e rodar migrations por schema.
  • Database: pipeline para rodar migrations por cada banco do tenant.
Tip: scripts idempotentes para schema são essenciais. Prefira checar IF NOT EXISTS antes de criar schema/tables nas migrations personalizadas.

Armadilhas comuns e como evitar

Multitenancy introduz armadilhas: vazamento de dados por não aplicar filtro, leaks por caching, pool de conexões esgotado em database-per-tenant, e migrações inconsistentes. Abaixo listamos as mais comuns e como mitigá-las.

Não aplicar filtros corretamente

Esquecer de adicionar a TenantId em uma entidade ou não aplicar o filtro global leva a vazamento de dados. Solução: usar interface ITenantEntity, convenção no ModelBuilder e revisão automatizada que valida metadados das entidades.

Problemas com connection pooling

Database-per-tenant cria muitos connection strings distintos e pode esgotar o connection pool do servidor. Mitigue usando um número limitado de bancos, reuso de pools ou um gateway de pooling. Evite construir connection strings dinamicamente com pequenas diferenças (ex: tempos de timeout), isso cria pools separados.

Codigos exemplo: aplicar migrations multi-tenant

O exemplo abaixo mostra um utilitário que itera tenants conhecidos e aplica migrations para database-per-tenant. Útil em CI/CD para garantir que cada banco esteja na versão correta.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Threading.Tasks;

public class TenantMigrationRunner
{
    private readonly IServiceProvider _sp;
    public TenantMigrationRunner(IServiceProvider sp) => _sp = sp;

    public async Task RunAllAsync(IEnumerable<TenantInfo> tenants)
    {
        foreach (var t in tenants)
        {
            // Cria um scope para construir DbContext com connection string do tenant
            using var scope = _sp.CreateScope();
            var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
            optionsBuilder.UseSqlServer(t.ConnectionString);

            using var ctx = new AppDbContext(optionsBuilder.Options, new TenantProvider());
            await ctx.Database.MigrateAsync();
        }
    }
}

Executar migrations dessa forma requer cuidado com credenciais e número de conexões simultâneas.

Performance: índices e cardinalidade

Em modelos discriminator, crie índices compostos com TenantId + columns frequentes em WHERE. Em schema/database, você pode otimizar por tenant individualmente. Avalie cardinalidade e monitore planos de execução — um grande erro é assumir que o mesmo índice funciona bem para todos os tenants.

Conclusao pratica

Resumo objetivo

Discriminator é simples e operacionalmente leve; schema oferece isolamento lógico sem criar muitos bancos; database dá isolamento máximo com custo operacional. A escolha depende de requisitos de isolamento, custo e escalabilidade. Independentemente da escolha, padrões como TenantProvider scoped, filtros globais, convenções automáticas e pipelines de migrations são essenciais.

Checklist prático para implantação

  1. Defina requisitos de isolamento e SLA por tenant.
  2. Implemente TenantProvider e middleware para resolução clara de tenant.
  3. Use DbContext por request e aplique filtros globais via conventions.
  4. Planeje migrations: uma vs many vs scripts idempotentes.
  5. Monitore pooling, índices e custos de I/O por tenant.

Com as práticas acima você terá uma base sólida para evoluir a plataforma: começar por discriminator e migrar para schema/database conforme necessidade é um caminho comum. A chave é projetar para mudanças: evitar otimizações precoces que amarram a escolha de multitenancy no código.

Este artigo forneceu implementações práticas e padrões que podem ser adaptados ao seu ambiente. Executar testes de carga, revisar segurança e automatizar migrations são passos necessários antes de produção.

Assuntos: C#, aspnet-core, ef-core, multitenancy

Leia também