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
- Defina requisitos de isolamento e SLA por tenant.
- Implemente TenantProvider e middleware para resolução clara de tenant.
- Use DbContext por request e aplique filtros globais via conventions.
- Planeje migrations: uma vs many vs scripts idempotentes.
- 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
Implementando paginação cursor-based com EF Core e Minimal APIs (passo a passo)
Guia prático para implementar paginação cursor-based com EF Core e Minimal APIs: modelagem do cursor, consultas keyset em LINQ, geração segura de cursor…
SignalR com Blazor: criar chat em tempo real com reconexão e fallback
Guia prático em C# para criar um chat com SignalR e Blazor WASM: passo a passo, código real, reconexão automática e fallback para LongPolling quando…
Soft delete no EF Core: filtros globais, auditoria e restauração
Soft delete com EF Core evita perda de dados, permite auditoria e restauração. Aprenda o jeito errado (filtros manuais) e o jeito certo (global query filters…
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…
Tratar concorrência otimista e retries com EF Core e Polly em ASP.NET Core
Combina rowversion, tratamento de DbUpdateConcurrencyException e políticas Polly para retries seguros. Estratégia prática: recriar DbContext, mesclar ou…
Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo
Guia passo a passo para usar Minimal APIs + SSE e enviar progresso de jobs para clientes Blazor. Do setup ao código funcional: servidor com endpoint SSE…