Os filtros de consulta globais permitem que você anexe uma cláusula WHERE a cada consulta LINQ para um determinado tipo de entidade — uma vez, em OnModelCreating, e nunca mais no código da aplicação. Eles são a maneira mais limpa de implementar soft delete, multi-tenancy e segurança em nível de linha no EF Core sem espalhar chamadas .Where(x => !x.IsDeleted) por toda a base de código.
Cada afirmação deste artigo é verificada com asserções por
samples/ef-core-global-query-filters
— um projeto de console executável em que cada linha de saída é uma verificação que passa, incluindo duas armadilhas que escrever o sample revelou (veja Verifique você mesmo).
O que são os Global Query Filters
O EF Core aplica um filtro de consulta como um predicado que é automaticamente combinado com AND em cada consulta contra aquele tipo de entidade, incluindo consultas através de propriedades de navegação. Se Post tem um filtro p => !p.IsDeleted, carregar um Blog e incluir seus Posts retornará apenas os posts não excluídos — mesmo que você tenha esquecido de filtrá-los explicitamente.
Os filtros são registrados por tipo de entidade em OnModelCreating:
modelBuilder.Entity<Post>()
.HasQueryFilter(p => !p.IsDeleted);Você pode ignorar o filtro para uma consulta específica com IgnoreQueryFilters():
// Endpoint de administrador: ver tudo incluindo registros excluídos
var allPosts = await db.Posts
.IgnoreQueryFilters()
.ToListAsync();Os filtros de consulta globais são aplicados no nível SQL, não na memória. O EF Core traduz o predicado do filtro para uma cláusula SQL WHERE, portanto você nunca carrega linhas que não deveria ver.
Implementando Soft Delete
A Classe Base de Entidade
Defina uma classe base compartilhada para todas as entidades com soft delete. Colocar os campos comuns aqui mantém a configuração do DbContext DRY.
public abstract class SoftDeletableEntity
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
public abstract class AuditableEntity : SoftDeletableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public string UpdatedBy { get; set; } = string.Empty;
}Entidades Concretas
public class Blog : AuditableEntity
{
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public ICollection<Post> Posts { get; set; } = new List<Post>();
}
public class Post : AuditableEntity
{
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag : AuditableEntity
{
public string Name { get; set; } = string.Empty;
public ICollection<Post> Posts { get; set; } = new List<Post>();
}Registrando o Filtro no OnModelCreating
Use um loop sobre todos os tipos de entidade que herdam de SoftDeletableEntity para que você nunca precise adicionar o filtro manualmente para novas entidades:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Aplica o filtro de soft delete a cada entidade que herda SoftDeletableEntity
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
continue;
// Constrói: e => !((SoftDeletableEntity)e).IsDeleted
var param = Expression.Parameter(entityType.ClrType, "e");
var prop = Expression.Property(
Expression.Convert(param, typeof(SoftDeletableEntity)),
nameof(SoftDeletableEntity.IsDeleted));
var notDeleted = Expression.Not(prop);
var lambda = Expression.Lambda(notDeleted, param);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}A abordagem com árvore de expressões evita escrever HasQueryFilter para cada entidade individualmente. Cada nova entidade que herdar de SoftDeletableEntity obtém o filtro automaticamente.
Sobrescrevendo SaveChanges para Soft Delete
Chamar db.Posts.Remove(post) é aceitável — mas sobrescreva SaveChanges para interceptar a exclusão e convertê-la em uma atualização. Sobrescreva a sobrecarga (bool, CancellationToken), não SaveChangesAsync(CancellationToken): os quatro pontos de entrada públicos (SaveChanges(), SaveChanges(bool), SaveChangesAsync(ct), SaveChangesAsync(bool, ct)) desembocam nas sobrecargas com bool, então interceptar ali cobre também os chamadores síncronos. Sobrescreva só a sobrecarga com ct e um simples db.SaveChanges() em qualquer lugar do código emitirá silenciosamente um DELETE real.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ApplySoftDeleteRules();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
ApplySoftDeleteRules();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
private void ApplySoftDeleteRules()
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<SoftDeletableEntity>())
{
switch (entry.State)
{
case EntityState.Deleted:
// Converte a exclusão permanente em soft delete
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
break;
case EntityState.Added:
// Garante que novas entidades não sejam marcadas como excluídas acidentalmente
entry.Entity.IsDeleted = false;
break;
}
}
// Preenche campos de auditoria em AuditableEntity
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Added)
entry.Entity.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.UpdatedAt = now;
}
}ExecuteDelete/ExecuteDeleteAsync não passam pelo SaveChanges — traduzem-se diretamente em DELETE de SQL e excluem linhas de verdade, atravessando essa interceptação. Em um código com soft delete, trate a família ExecuteDelete como exclusiva de superadmin, ou expresse soft-deletes em massa como ExecuteUpdate(s => s.SetProperty(e => e.IsDeleted, true)).
Restaurando Registros com Soft Delete
Os endpoints de administração precisam recuperar registros excluídos. Como o filtro de consulta oculta as linhas excluídas, você deve usar IgnoreQueryFilters() para encontrá-las primeiro:
public async Task<Post?> RestorePostAsync(int postId)
{
var post = await _db.Posts
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.Id == postId && p.IsDeleted);
if (post is null)
return null;
post.IsDeleted = false;
post.DeletedAt = null;
post.DeletedBy = null;
// Define Modified para que SaveChanges envie um UPDATE (não interceptado como exclusão)
_db.Entry(post).State = EntityState.Modified;
await _db.SaveChangesAsync();
return post;
}Multi-Tenancy com Global Query Filters
Para aplicações SaaS, cada tabela que contém dados de tenant precisa de uma coluna TenantId e um filtro que restrinja as linhas ao tenant atual. O predicado do filtro deve ler o tenant atual no momento da consulta, não na inicialização — então deve fechar sobre um serviço com escopo, não um valor estático.
Serviço de Resolução de Tenant
public interface ITenantProvider
{
Guid TenantId { get; }
}
public class HttpContextTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextTenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid TenantId
{
get
{
// Lê de um claim JWT, header ou subdomínio — ajuste à sua estratégia de auth
var claim = _httpContextAccessor.HttpContext?
.User.FindFirst("tenant_id")?.Value;
return Guid.TryParse(claim, out var id)
? id
: throw new InvalidOperationException("Claim TenantId ausente.");
}
}
}Entidade Base Multi-Tenant
public abstract class TenantEntity : AuditableEntity
{
public Guid TenantId { get; set; }
}
public class Invoice : TenantEntity
{
public decimal Amount { get; set; }
public string Currency { get; set; } = "USD";
public DateTime IssuedAt { get; set; }
public ICollection<InvoiceLineItem> LineItems { get; set; } = new List<InvoiceLineItem>();
}
public class InvoiceLineItem : TenantEntity
{
public string Description { get; set; } = string.Empty;
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public int InvoiceId { get; set; }
public Invoice Invoice { get; set; } = null!;
}DbContext com Filtros Combinados
public class AppDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ApplySoftDeleteFilters(modelBuilder);
ApplyTenantFilters(modelBuilder);
// Índices para desempenho dos filtros
modelBuilder.Entity<Post>()
.HasIndex(p => p.IsDeleted)
.HasFilter("IsDeleted = 0"); // Índice parcial: apenas linhas não excluídas
modelBuilder.Entity<Invoice>()
.HasIndex(i => new { i.TenantId, i.IsDeleted });
modelBuilder.Entity<InvoiceLineItem>()
.HasIndex(li => new { li.TenantId, li.IsDeleted });
}
private static void ApplySoftDeleteFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
continue;
var param = Expression.Parameter(entityType.ClrType, "e");
var prop = Expression.Property(
Expression.Convert(param, typeof(SoftDeletableEntity)),
nameof(SoftDeletableEntity.IsDeleted));
var filter = Expression.Lambda(Expression.Not(prop), param);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
}
private void ApplyTenantFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(TenantEntity).IsAssignableFrom(entityType.ClrType))
continue;
var param = Expression.Parameter(entityType.ClrType, "e");
// Soft delete: !e.IsDeleted
var isDeletedProp = Expression.Property(
Expression.Convert(param, typeof(SoftDeletableEntity)),
nameof(SoftDeletableEntity.IsDeleted));
var notDeleted = Expression.Not(isDeletedProp);
// Tenant: e.TenantId == _tenantProvider.TenantId
//
// O provider DEVE ser alcançado através da instância do contexto
// (Expression.Constant(this) -> campo -> propriedade). O EF Core faz cache
// do modelo por tipo de contexto e reescreve referências ao contexto que
// construiu o modelo para o que está executando; um
// Expression.Constant(_tenantProvider) direto grava o provider da PRIMEIRA
// instância no modelo cacheado, e todo contexto posterior filtra
// silenciosamente por ele.
var tenantIdProp = Expression.Property(
Expression.Convert(param, typeof(TenantEntity)),
nameof(TenantEntity.TenantId));
var provider = Expression.Field(
Expression.Constant(this), nameof(_tenantProvider));
var currentTenant = Expression.Property(
provider, nameof(ITenantProvider.TenantId));
var tenantMatch = Expression.Equal(tenantIdProp, currentTenant);
// Combinado: !IsDeleted && TenantId == atual
var combined = Expression.AndAlso(notDeleted, tenantMatch);
var lambda = Expression.Lambda(combined, param);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<TenantEntity>())
{
if (entry.State == EntityState.Added)
// Atribui automaticamente o tenant atual a novas entidades
entry.Entity.TenantId = _tenantProvider.TenantId;
}
foreach (var entry in ChangeTracker.Entries<SoftDeletableEntity>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
}
}
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Added)
entry.Entity.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.UpdatedAt = now;
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
}Como o filtro alcança o provider decide se o multi-tenancy funciona de verdade. O EF Core constrói o modelo uma vez por tipo de contexto e o armazena em cache — a expressão do filtro faz parte desse modelo cacheado. O que faz tenants por requisição funcionarem é uma etapa de reescrita: constantes que referenciam a instância do contexto que construiu o modelo são trocadas pelo contexto em execução a cada consulta, e o acesso a membros pendurado nessa constante (this._tenantProvider.TenantId) é então avaliado de forma atualizada por consulta. Referencie o provider diretamente — Expression.Constant(_tenantProvider), ou uma variável local capturada como var tid = _tenantProvider.TenantId — e não há nada para reescrever: o provider (ou valor) da primeira requisição fica gravado no modelo cacheado, e toda instância posterior do contexto filtra silenciosamente pelo primeiro tenant. O sample prova isso com um contexto quebrado de propósito: sua segunda instância, construída com um provider do tenant B, ainda devolve as linhas do tenant A.
Como os Filtros se Aplicam em JOINs e Propriedades de Navegação
Os filtros de consulta são aplicados a cada consulta SQL que o EF Core gera para aquele tipo de entidade, incluindo as geradas por Include() e joins implícitos através de propriedades de navegação.
// Esta consulta:
var blog = await db.Blogs
.Include(b => b.Posts)
.FirstOrDefaultAsync(b => b.Id == id);
// Gera SQL aproximadamente assim:
// SELECT b.*, p.*
// FROM Blogs b
// LEFT JOIN Posts p ON p.BlogId = b.Id
// AND p.IsDeleted = 0 -- aplicado automaticamente do filtro de Post
// WHERE b.IsDeleted = 0 -- aplicado automaticamente do filtro de Blog
// AND b.Id = @idIsso significa:
blog.Postsnunca conterá posts com soft delete.blog.Posts.Countreflete apenas os posts ativos.- Você não precisa filtrar propriedades de navegação manualmente.
O mesmo se aplica a entidades multi-tenant: se Invoice e InvoiceLineItem têm o filtro de tenant, carregar uma nota fiscal com seus itens de linha retorna apenas os itens pertencentes ao mesmo tenant.
// Vazamentos de dados entre tenants são prevenidos mesmo através de navegações
var invoice = await db.Invoices
.Include(i => i.LineItems) // LineItems filtrados por TenantId automaticamente
.FirstOrDefaultAsync(i => i.Id == invoiceId);Desempenho: Índices
Sem um índice em IsDeleted (e TenantId), cada consulta faz uma varredura completa da tabela. Adicione índices específicos em OnModelCreating.
Índice Parcial para Soft Delete (SQL Server / PostgreSQL)
Um índice parcial em IsDeleted = 0 é muito menor do que um índice completo e acelera a grande maioria das consultas (que só precisam de linhas ativas):
// Sintaxe SQL Server
modelBuilder.Entity<Post>()
.HasIndex(p => p.IsDeleted)
.HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_Posts_Active");
// Sintaxe PostgreSQL (via HasFilter em minúsculas)
modelBuilder.Entity<Post>()
.HasIndex(p => p.IsDeleted)
.HasFilter("\"IsDeleted\" = false")
.HasDatabaseName("IX_Posts_Active");Índice Composto para Multi-Tenancy
Para consultas com escopo de tenant, um índice composto (TenantId, IsDeleted) permite ao banco de dados acessar diretamente as linhas ativas do tenant:
modelBuilder.Entity<Invoice>()
.HasIndex(i => new { i.TenantId, i.IsDeleted })
.HasDatabaseName("IX_Invoices_Tenant_Active");
// Para consultas que também filtram por uma coluna de negócio (ex: IssuedAt):
modelBuilder.Entity<Invoice>()
.HasIndex(i => new { i.TenantId, i.IsDeleted, i.IssuedAt })
.HasDatabaseName("IX_Invoices_Tenant_Active_Date");A migration gerada para esses índices:
migrationBuilder.CreateIndex(
name: "IX_Invoices_Tenant_Active",
table: "Invoices",
columns: new[] { "TenantId", "IsDeleted" });Ignorando Filtros
Use IgnoreQueryFilters() quando você legitimamente precisa ver todos os dados: painéis de administração, logs de auditoria, jobs em background que operam entre tenants ou migrações de dados.
// Ver todas as notas fiscais de todos os tenants (apenas superadmin)
var allInvoices = await db.Invoices
.IgnoreQueryFilters()
.ToListAsync();
// Contar posts com soft delete para um job de limpeza
var deletedCount = await db.Posts
.IgnoreQueryFilters()
.CountAsync(p => p.IsDeleted);
// Restaurar todas as tags com soft delete
var deletedTags = await db.Tags
.IgnoreQueryFilters()
.Where(t => t.IsDeleted)
.ToListAsync();IgnoreQueryFilters() remove TODOS os filtros naquele tipo de entidade, incluindo tanto soft delete quanto filtros de tenant. Em uma aplicação multi-tenant, as chamadas a IgnoreQueryFilters() devem ser restritas a papéis de superadmin e auditadas cuidadosamente para evitar vazamentos de dados entre tenants.
A armadilha do fixup: os filtros são em nível de SQL, mas o change tracker não liga para eles. Execute uma consulta com IgnoreQueryFilters() e as linhas excluídas (ou de outro tenant) ficam rastreadas — um Include() filtrado posterior no mesmo contexto as exclui do JOIN de SQL, e mesmo assim o fixup de navegação anexa as entidades já rastreadas aos resultados. Depois de ignorar filtros, faça o trabalho filtrado em um contexto novo, ou consulte com AsNoTracking().
Ignorar Apenas Um Filtro (Filtros Nomeados, EF Core 10)
O EF Core 10 adicionou filtros de consulta nomeados: dê um nome a cada filtro, registre vários na mesma entidade (o EF Core os combina com AND) e ignore só os que você nomear. Isso resolve o clássico problema da tela de restauração de admin — mostrar as linhas excluídas do próprio tenant sem perder o isolamento de tenant:
// Registra soft delete e isolamento de tenant como dois filtros nomeados
modelBuilder.Entity<Invoice>()
.HasQueryFilter("SoftDelete", i => !i.IsDeleted)
.HasQueryFilter("Tenant", i => i.TenantId == _tenantProvider.TenantId);
// Tela de restauração: linhas excluídas, ainda limitadas ao tenant
var withDeleted = await db.Invoices
.IgnoreQueryFilters(["SoftDelete"]) // ignora SÓ o filtro de soft delete
.ToListAsync();O sample afirma exatamente isso: IgnoreQueryFilters(["SoftDelete"]) devolve a fatura excluída do tenant A enquanto as linhas do tenant B continuam ocultas.
No EF Core 8/9, onde os filtros não têm nome e IgnoreQueryFilters() é tudo-ou-nada, as alternativas são configurações de DbContext separadas ou uma coluna discriminadora e tipos de entidade separados.
Um padrão alternativo é um flag com escopo:
public class AppDbContext : DbContext
{
// Escopo por requisição; definir como true em endpoints de administração
public bool IgnoreSoftDeleteFilter { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// A lambda lê o flag no momento da consulta
modelBuilder.Entity<Post>()
.HasQueryFilter(p => IgnoreSoftDeleteFilter || !p.IsDeleted);
}
}Use isso com moderação — a abordagem com flag dificulta os testes e pode causar bugs sutis se um DbContext for reutilizado entre requisições.
Testes com Filtros de Consulta
Estratégia 1: Testar Através do Filtro (Padrão)
A maioria dos testes deve exercitar o filtro como o código de produção faz. Semeie dados excluídos e ativos; afirme que apenas os dados ativos são retornados:
[Fact]
public async Task GetPosts_ExcludesSoftDeletedPosts()
{
// Arrange — use provedor in-memory ou SQLite
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
await using var db = new AppDbContext(options, new FakeTenantProvider(Guid.NewGuid()));
await db.Database.EnsureCreatedAsync();
var tenantId = db.CurrentTenantId; // helper ou valor conhecido
db.Posts.AddRange(
new Post { Title = "Ativo", TenantId = tenantId },
new Post { Title = "Excluído", IsDeleted = true, TenantId = tenantId }
);
await db.SaveChangesAsync();
// Act
var posts = await db.Posts.ToListAsync();
// Assert
Assert.Single(posts);
Assert.Equal("Ativo", posts[0].Title);
}Estratégia 2: Ignorar o Filtro nas Asserções
Use IgnoreQueryFilters() na fase de asserção para verificar que um soft delete realmente escreveu no banco de dados sem aparecer através do filtro:
[Fact]
public async Task DeletePost_SetsSoftDeleteFields()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
var tenantId = Guid.NewGuid();
await using var db = new AppDbContext(options, new FakeTenantProvider(tenantId));
await db.Database.EnsureCreatedAsync();
var post = new Post { Title = "A Excluir", TenantId = tenantId };
db.Posts.Add(post);
await db.SaveChangesAsync();
// Act — aciona soft delete via Remove + SaveChanges
db.Posts.Remove(post);
await db.SaveChangesAsync();
// Assert — ignora o filtro para inspecionar a linha subjacente
var deletedPost = await db.Posts
.IgnoreQueryFilters()
.FirstAsync(p => p.Id == post.Id);
Assert.True(deletedPost.IsDeleted);
Assert.NotNull(deletedPost.DeletedAt);
}Provedor de Tenant Falso para Testes
public class FakeTenantProvider : ITenantProvider
{
public FakeTenantProvider(Guid tenantId) => TenantId = tenantId;
public Guid TenantId { get; }
}Filtros em Entidades Próprias (Owned Entities)
O EF Core não suporta HasQueryFilter em tipos de entidade próprios (entidades configuradas com OwnsOne ou OwnsMany). As entidades próprias são sempre carregadas como parte de seu proprietário e não têm consultas independentes, portanto o filtro na entidade proprietária as cobre.
public class Customer : AuditableEntity
{
public string Name { get; set; } = string.Empty;
// Própria — carregada com Customer, o filtro em Customer cobre o acesso
public Address BillingAddress { get; set; } = null!;
}
[Owned]
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
}
// Em OnModelCreating:
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.BillingAddress);
// O filtro de soft delete em Customer já cobre Address —
// nenhum filtro separado é necessário ou possível para Address.Se você aplicar HasQueryFilter a um tipo próprio, o EF Core lança uma InvalidOperationException na inicialização.
Exemplo Completo de Configuração do DbContext
public class AppDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Invoice> Invoices => Set<Invoice>();
public DbSet<InvoiceLineItem> InvoiceLineItems => Set<InvoiceLineItem>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Tipos próprios
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.BillingAddress);
// Configurar filtros
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// Ignorar entidades próprias — não podem ter filtros independentes
if (entityType.IsOwned()) continue;
var clrType = entityType.ClrType;
if (typeof(TenantEntity).IsAssignableFrom(clrType))
{
// Combinado: !IsDeleted && TenantId == atual
var param = Expression.Parameter(clrType, "e");
var isDeleted = Expression.Property(
Expression.Convert(param, typeof(SoftDeletableEntity)),
nameof(SoftDeletableEntity.IsDeleted));
var tenantId = Expression.Property(
Expression.Convert(param, typeof(TenantEntity)),
nameof(TenantEntity.TenantId));
// Através da constante do contexto — veja o aviso acima: uma constante
// do próprio provider fica gravada no modelo cacheado.
var currentTenant = Expression.Property(
Expression.Field(Expression.Constant(this), nameof(_tenantProvider)),
nameof(ITenantProvider.TenantId));
var filter = Expression.AndAlso(
Expression.Not(isDeleted),
Expression.Equal(tenantId, currentTenant));
modelBuilder.Entity(clrType).HasQueryFilter(Expression.Lambda(filter, param));
}
else if (typeof(SoftDeletableEntity).IsAssignableFrom(clrType))
{
// Apenas soft delete
var param = Expression.Parameter(clrType, "e");
var isDeleted = Expression.Property(
Expression.Convert(param, typeof(SoftDeletableEntity)),
nameof(SoftDeletableEntity.IsDeleted));
modelBuilder.Entity(clrType).HasQueryFilter(
Expression.Lambda(Expression.Not(isDeleted), param));
}
}
// Índices de desempenho
modelBuilder.Entity<Post>()
.HasIndex(p => p.IsDeleted)
.HasFilter("[IsDeleted] = 0");
modelBuilder.Entity<Blog>()
.HasIndex(b => b.IsDeleted)
.HasFilter("[IsDeleted] = 0");
modelBuilder.Entity<Invoice>()
.HasIndex(i => new { i.TenantId, i.IsDeleted });
modelBuilder.Entity<InvoiceLineItem>()
.HasIndex(li => new { li.TenantId, li.IsDeleted });
modelBuilder.Entity<Customer>()
.HasIndex(c => c.IsDeleted)
.HasFilter("[IsDeleted] = 0");
}
// A sobrecarga com bool — os quatro pontos de entrada públicos do SaveChanges
// desembocam nela, então os chamadores síncronos também são interceptados.
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries())
{
// Atribui tenant automaticamente em novas entidades de tenant
if (entry.Entity is TenantEntity tenantEntity
&& entry.State == EntityState.Added)
{
tenantEntity.TenantId = _tenantProvider.TenantId;
}
// Intercepta exclusões permanentes, converte em soft deletes
if (entry.Entity is SoftDeletableEntity softEntity
&& entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
softEntity.IsDeleted = true;
softEntity.DeletedAt = now;
}
// Timestamps de auditoria
if (entry.Entity is AuditableEntity auditEntity)
{
if (entry.State == EntityState.Added)
auditEntity.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
auditEntity.UpdatedAt = now;
}
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
}Registro no DI
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));Verifique você mesmo
Cada afirmação acima é verificada por
samples/ef-core-global-query-filters
— 22 verificações que lançam exceção se falharem (EF Core 10, SQL Server LocalDB). Os destaques:
- Uma consulta comum, um JOIN de
Include()e um count excluem as linhas com soft delete; um line item semeado de propósito com oTenantIderrado nunca vaza através deInclude(i => i.LineItems). Remove()+SaveChangesAsync()executa exatamente um UPDATE — a linha sobrevive comIsDeleted = true, e o padrão de restauração a traz de volta.- Trocar um tenant provider mutável no mesmo contexto muda os resultados por consulta; um contexto novo com outro provider vê o próprio tenant — porque o filtro referencia o provider através do contexto.
- A variante quebrada de propósito (
Expression.Constant(provider)) passa no primeiro uso e depois devolve as linhas do tenant A para um contexto do tenant B, exatamente como o aviso sobre o cache do modelo prevê. - A armadilha do fixup se reproduz: depois de
IgnoreQueryFilters(), umInclude()filtrado no mesmo contexto volta a mostrar o post excluído via fixup de entidades rastreadas. ExecuteDeleteexclui de verdade, atravessando a interceptação do SaveChanges.IgnoreQueryFilters(["SoftDelete"])mostra a fatura excluída do tenant atual enquanto o tenant B continua oculto — filtros nomeados realmente compõem.HasQueryFilterem um tipo owned lançaInvalidOperationException, e o índice parcial é lido de volta dosys.indexescomo([IsDeleted]=(0)).

Execute primeiro o seed.sql
e depois dotnet run. Uma nota prática de escrevê-lo: crie índices filtrados com QUOTED_IDENTIFIER ON — o sqlcmd o traz OFF por padrão e o CREATE INDEX ... WHERE falha.
Resumo
| Cenário | Abordagem |
|---|---|
| Soft delete para todas as entidades | Loop com árvore de expressões sobre subtipos de SoftDeletableEntity em OnModelCreating |
| Multi-tenancy | ITenantProvider com escopo, referenciado através do contexto no filtro; combinar com filtro de soft delete |
| Ver registros excluídos | IgnoreQueryFilters() na consulta |
| Restaurar um registro | Buscar com IgnoreQueryFilters(), definir IsDeleted = false, salvar |
| Filtragem de propriedades de navegação | Automática — EF Core aplica filtros em entidades incluídas |
| Índice parcial | HasIndex(...).HasFilter("[IsDeleted] = 0") |
| Testes | Semear dados excluídos e ativos; afirmar que apenas linhas ativas são retornadas; usar IgnoreQueryFilters() na fase de asserção para verificar gravações de soft delete |
| Entidades próprias | Não é possível aplicar filtro; coberto pelo filtro da entidade proprietária |
Leitura Relacionada
Filtros globais são configuração em nível de modelo, então são aplicados em
OnModelCreating junto com o restante do mapeamento — veja
o passo a passo das migrações do EF Core para
entender quais mudanças de modelo geram migrações e quais não.
Note também que um filtro global se aplica a toda consulta que toca a entidade, inclusive
as de dentro de um Include, o que pode surpreender ao depurar
o problema de consultas N+1.