//JorgenHoc
← Todos os artigos
EF CorePor Jorge CalderónAtualizado 21 min read

EF Core Global Query Filters — Soft Delete e Multi-Tenancy

Implemente soft delete, multi-tenancy e segurança em nível de linha no EF Core usando global query filters. Cobre configuração, bypass, índices e testes.

#entity-framework#dotnet#database

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 = @id

Isso significa:

  • blog.Posts nunca conterá posts com soft delete.
  • blog.Posts.Count reflete 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 o TenantId errado nunca vaza através de Include(i => i.LineItems).
  • Remove() + SaveChangesAsync() executa exatamente um UPDATE — a linha sobrevive com IsDeleted = 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(), um Include() filtrado no mesmo contexto volta a mostrar o post excluído via fixup de entidades rastreadas.
  • ExecuteDelete exclui 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.
  • HasQueryFilter em um tipo owned lança InvalidOperationException, e o índice parcial é lido de volta do sys.indexes como ([IsDeleted]=(0)).
Saída de console do sample de global query filters: 22 verificações que passam. Soft delete — consultas comuns devolvem 2 de 3 posts, IgnoreQueryFilters mostra os 3, a armadilha do fixup faz um Include filtrado voltar a mostrar o post excluído depois de uma consulta sem filtros no mesmo contexto, Remove mais SaveChanges executa exatamente um UPDATE e o padrão de restauração recupera a linha. Multi-tenancy — o tenant A vê só sua fatura ativa, o line item marcado com o tenant B nunca vaza através de Include, IgnoreQueryFilters com o nome SoftDelete mostra a fatura excluída sem deixar de esconder o tenant B, trocar o provider no mesmo contexto muda de tenant por consulta, e ExecuteDelete exclui de verdade atravessando a interceptação. A armadilha do Expression.Constant(provider) — um segundo contexto defeituoso construído com um provider do tenant B ainda vê o tenant A porque o primeiro provider ficou gravado no modelo cacheado. Entidades owned — HasQueryFilter em Address lança InvalidOperationException, e o índice parcial é lido do sys.indexes como IsDeleted igual a zero.
As 22 verificações, direto do console — incluindo as duas linhas que mais importam: o contexto defeituoso devolvendo as linhas do tenant A a um provider do tenant B, e IgnoreQueryFilters(["SoftDelete"]) mantendo intacto o isolamento de tenant.
💡

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árioAbordagem
Soft delete para todas as entidadesLoop com árvore de expressões sobre subtipos de SoftDeletableEntity em OnModelCreating
Multi-tenancyITenantProvider com escopo, referenciado através do contexto no filtro; combinar com filtro de soft delete
Ver registros excluídosIgnoreQueryFilters() na consulta
Restaurar um registroBuscar com IgnoreQueryFilters(), definir IsDeleted = false, salvar
Filtragem de propriedades de navegaçãoAutomática — EF Core aplica filtros em entidades incluídas
Índice parcialHasIndex(...).HasFilter("[IsDeleted] = 0")
TestesSemear 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ópriasNã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.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados