//JorgenHoc
← Todos los artículos
EF CorePor Jorge CalderónActualizado 21 min read

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

Implementa soft delete, multi-tenancy y seguridad a nivel de fila en EF Core usando global query filters. Cubre configuración, omisión, índices y pruebas.

#entity-framework#dotnet#database

Los filtros de consulta globales te permiten adjuntar una cláusula WHERE a cada consulta LINQ para un tipo de entidad dado — una vez, en OnModelCreating, y nunca más en el código de la aplicación. Son la manera más limpia de implementar soft delete, multi-tenancy y seguridad a nivel de fila en EF Core sin dispersar llamadas .Where(x => !x.IsDeleted) por todo el código.

Cada afirmación de este artículo está verificada con aserciones por samples/ef-core-global-query-filters — un proyecto de consola ejecutable donde cada línea de salida es una comprobación que pasa, incluidas dos trampas que escribir el sample destapó (ver Verifícalo tú mismo).

Qué son los Global Query Filters

EF Core aplica un filtro de consulta como un predicado que se combina automáticamente con AND en cada consulta contra ese tipo de entidad, incluyendo consultas a través de propiedades de navegación. Si Post tiene un filtro p => !p.IsDeleted, cargar un Blog e incluir sus Posts solo devolverá los posts no eliminados — incluso si olvidaste filtrarlos explícitamente.

Los filtros se registran por tipo de entidad en OnModelCreating:

modelBuilder.Entity<Post>()
    .HasQueryFilter(p => !p.IsDeleted);

Puedes omitir el filtro para una consulta específica con IgnoreQueryFilters():

// Endpoint de administrador: ver todo incluyendo registros eliminados
var allPosts = await db.Posts
    .IgnoreQueryFilters()
    .ToListAsync();
💡

Los filtros de consulta globales se aplican a nivel SQL, no en memoria. EF Core traduce el predicado del filtro a una cláusula SQL WHERE, por lo que nunca cargas filas que no deberías ver.

Implementando Soft Delete

La Clase Base de Entidad

Define una clase base compartida para todas las entidades con soft delete. Colocar los campos comunes aquí hace que la configuración del DbContext sea 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 el Filtro en OnModelCreating

Usa un bucle sobre todos los tipos de entidad que heredan de SoftDeletableEntity para que nunca tengas que agregar el filtro manualmente para nuevas entidades:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
 
    // Aplica el filtro de soft delete a cada entidad que hereda SoftDeletableEntity
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (!typeof(SoftDeletableEntity).IsAssignableFrom(entityType.ClrType))
            continue;
 
        // Construye: 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);
    }
}
💡

El enfoque con árbol de expresiones evita escribir HasQueryFilter para cada entidad individualmente. Cada nueva entidad que herede de SoftDeletableEntity obtiene el filtro automáticamente.

Sobreescribiendo SaveChanges para Soft Delete

Llamar db.Posts.Remove(post) está bien — pero sobreescribe SaveChanges para interceptar la eliminación y convertirla en una actualización. Sobreescribe la sobrecarga (bool, CancellationToken), no SaveChangesAsync(CancellationToken): los cuatro puntos de entrada públicos (SaveChanges(), SaveChanges(bool), SaveChangesAsync(ct), SaveChangesAsync(bool, ct)) desembocan en las sobrecargas con bool, así que interceptar ahí cubre también a los llamadores síncronos. Sobreescribe solo la sobrecarga con ct y un simple db.SaveChanges() en cualquier parte del código emitirá silenciosamente un 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:
                // Convierte la eliminación permanente en soft delete
                entry.State = EntityState.Modified;
                entry.Entity.IsDeleted = true;
                entry.Entity.DeletedAt = now;
                break;
 
            case EntityState.Added:
                // Asegura que las nuevas entidades no estén marcadas como eliminadas accidentalmente
                entry.Entity.IsDeleted = false;
                break;
        }
    }
 
    // Rellena campos de auditoría en 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 no pasan por SaveChanges en absoluto — se traducen directamente a DELETE de SQL y eliminan filas de verdad atravesando esta intercepción. En un código con soft delete, trata la familia ExecuteDelete como solo-superadmin, o expresa los soft-deletes masivos como ExecuteUpdate(s => s.SetProperty(e => e.IsDeleted, true)).

Restaurando Registros con Soft Delete

Los endpoints de administración necesitan recuperar registros eliminados. Como el filtro de consulta oculta las filas eliminadas, debes usar IgnoreQueryFilters() para encontrarlas primero:

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;
 
    // Establece Modified para que SaveChanges envíe un UPDATE (no interceptado como eliminación)
    _db.Entry(post).State = EntityState.Modified;
    await _db.SaveChangesAsync();
 
    return post;
}

Multi-Tenancy con Global Query Filters

Para aplicaciones SaaS, cada tabla que contiene datos de tenant necesita una columna TenantId y un filtro que restrinja las filas al tenant actual. El predicado del filtro debe leer el tenant actual en el momento de la consulta, no al inicio — por lo que debe cerrarse sobre un servicio con scope, no un valor estático.

Servicio de Resolución 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
        {
            // Lee desde un claim JWT, header o subdominio — ajusta según tu estrategia de auth
            var claim = _httpContextAccessor.HttpContext?
                .User.FindFirst("tenant_id")?.Value;
 
            return Guid.TryParse(claim, out var id)
                ? id
                : throw new InvalidOperationException("Claim TenantId no encontrado.");
        }
    }
}

Entidad 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 con 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 rendimiento de los filtros
        modelBuilder.Entity<Post>()
            .HasIndex(p => p.IsDeleted)
            .HasFilter("IsDeleted = 0"); // Índice parcial: solo filas no eliminadas
 
        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
            //
            // El provider DEBE alcanzarse a través de la instancia del contexto
            // (Expression.Constant(this) -> campo -> propiedad). EF Core cachea el
            // modelo por tipo de contexto y reescribe las referencias al contexto que
            // construyó el modelo hacia el que está ejecutando; un
            // Expression.Constant(_tenantProvider) directo hornea el provider de la
            // PRIMERA instancia en el modelo cacheado, y cada contexto posterior filtra
            // silenciosamente por él.
            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 == actual
            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)
                // Asigna automáticamente el tenant actual a nuevas 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);
    }
}
⚠️

Cómo alcanza el filtro al provider decide si el multi-tenancy funciona de verdad. EF Core construye el modelo una vez por tipo de contexto y lo cachea — la expresión del filtro forma parte de ese modelo cacheado. Lo que hace funcionar los tenants por petición es un paso de reescritura: las constantes que referencian la instancia del contexto que construyó el modelo se sustituyen por el contexto en ejecución en cada consulta, y el acceso a miembros que cuelga de esa constante (this._tenantProvider.TenantId) se evalúa entonces fresco por consulta. Referencia el provider directamente — Expression.Constant(_tenantProvider), o un local capturado como var tid = _tenantProvider.TenantId — y no hay nada que reescribir: el provider (o valor) de la primera petición queda horneado en el modelo cacheado, y cada instancia posterior del contexto filtra silenciosamente por el primer tenant. El sample lo demuestra con un contexto roto a propósito: su segunda instancia, construida con un provider del tenant B, sigue devolviendo las filas del tenant A.

Cómo se Aplican los Filtros en JOINs y Propiedades de Navegación

Los filtros de consulta se aplican a cada consulta SQL que EF Core genera para ese tipo de entidad, incluidas las generadas por Include() y los joins implícitos a través de propiedades de navegación.

// Esta consulta:
var blog = await db.Blogs
    .Include(b => b.Posts)
    .FirstOrDefaultAsync(b => b.Id == id);
 
// Genera SQL aproximadamente así:
// SELECT b.*, p.*
// FROM Blogs b
// LEFT JOIN Posts p ON p.BlogId = b.Id
//   AND p.IsDeleted = 0          -- aplicado automáticamente desde el filtro de Post
// WHERE b.IsDeleted = 0          -- aplicado automáticamente desde el filtro de Blog
//   AND b.Id = @id

Esto significa:

  • blog.Posts nunca contendrá posts con soft delete.
  • blog.Posts.Count refleja solo los posts activos.
  • No necesitas filtrar propiedades de navegación manualmente.

Lo mismo aplica para entidades multi-tenant: si Invoice y InvoiceLineItem ambos tienen el filtro de tenant, cargar una factura con sus líneas de detalle solo devuelve las líneas que pertenecen al mismo tenant.

// La fuga de datos entre tenants se previene incluso a través de navegaciones
var invoice = await db.Invoices
    .Include(i => i.LineItems)  // LineItems filtrados por TenantId automáticamente
    .FirstOrDefaultAsync(i => i.Id == invoiceId);

Rendimiento: Índices

Sin un índice en IsDeleted (y TenantId), cada consulta hace un escaneo completo de la tabla. Agrega índices específicos en OnModelCreating.

Índice Parcial para Soft Delete (SQL Server / PostgreSQL)

Un índice parcial en IsDeleted = 0 es mucho más pequeño que un índice completo y acelera la gran mayoría de consultas (que solo necesitan filas activas):

// Sintaxis SQL Server
modelBuilder.Entity<Post>()
    .HasIndex(p => p.IsDeleted)
    .HasFilter("[IsDeleted] = 0")
    .HasDatabaseName("IX_Posts_Active");
 
// Sintaxis PostgreSQL (via HasFilter en minúsculas)
modelBuilder.Entity<Post>()
    .HasIndex(p => p.IsDeleted)
    .HasFilter("\"IsDeleted\" = false")
    .HasDatabaseName("IX_Posts_Active");

Índice Compuesto para Multi-Tenancy

Para consultas con scope de tenant, un índice compuesto (TenantId, IsDeleted) permite a la base de datos acceder directamente a las filas activas del tenant:

modelBuilder.Entity<Invoice>()
    .HasIndex(i => new { i.TenantId, i.IsDeleted })
    .HasDatabaseName("IX_Invoices_Tenant_Active");
 
// Para consultas que también filtran por una columna de negocio (p.ej., IssuedAt):
modelBuilder.Entity<Invoice>()
    .HasIndex(i => new { i.TenantId, i.IsDeleted, i.IssuedAt })
    .HasDatabaseName("IX_Invoices_Tenant_Active_Date");

La migración generada para estos índices:

migrationBuilder.CreateIndex(
    name: "IX_Invoices_Tenant_Active",
    table: "Invoices",
    columns: new[] { "TenantId", "IsDeleted" });

Omitiendo Filtros

Usa IgnoreQueryFilters() cuando legítimamente necesitas ver todos los datos: paneles de administración, registros de auditoría, trabajos en background que operan entre tenants, o migraciones de datos.

// Ver todas las facturas de todos los tenants (solo superadmin)
var allInvoices = await db.Invoices
    .IgnoreQueryFilters()
    .ToListAsync();
 
// Contar posts con soft delete para un trabajo de limpieza
var deletedCount = await db.Posts
    .IgnoreQueryFilters()
    .CountAsync(p => p.IsDeleted);
 
// Restaurar todas las etiquetas con soft delete
var deletedTags = await db.Tags
    .IgnoreQueryFilters()
    .Where(t => t.IsDeleted)
    .ToListAsync();
⚠️

IgnoreQueryFilters() elimina TODOS los filtros en ese tipo de entidad, incluyendo tanto soft delete como filtros de tenant. En una aplicación multi-tenant, las llamadas a IgnoreQueryFilters() deben estar restringidas a roles de superadmin y auditadas cuidadosamente para evitar fugas de datos entre tenants.

⚠️

La trampa del fixup: los filtros son a nivel de SQL, pero al change tracker no le importan. Ejecuta una consulta con IgnoreQueryFilters() y las filas eliminadas (o de otro tenant) quedan rastreadas — un Include() filtrado posterior en el mismo contexto las excluye del JOIN de SQL, y aun así el fixup de navegación adjunta las entidades ya rastreadas a los resultados. Después de omitir filtros, haz el trabajo filtrado en un contexto nuevo, o consulta con AsNoTracking().

Omitir Solo un Filtro (Filtros con Nombre, EF Core 10)

EF Core 10 añadió los filtros de consulta con nombre: dale un nombre a cada filtro, registra varios en la misma entidad (EF Core los combina con AND) y omite solo los que nombres. Esto resuelve el clásico problema de la pantalla de restauración de admin — mostrar las filas eliminadas del propio tenant sin perder el aislamiento de tenant:

// Registra soft delete y aislamiento de tenant como dos filtros con nombre
modelBuilder.Entity<Invoice>()
    .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
    .HasQueryFilter("Tenant", i => i.TenantId == _tenantProvider.TenantId);
 
// Pantalla de restauración: filas eliminadas, todavía limitadas al tenant
var withDeleted = await db.Invoices
    .IgnoreQueryFilters(["SoftDelete"])   // omite SOLO el filtro de soft delete
    .ToListAsync();

El sample afirma exactamente esto: IgnoreQueryFilters(["SoftDelete"]) devuelve la factura eliminada del tenant A mientras las filas del tenant B siguen ocultas.

En EF Core 8/9, donde los filtros no tienen nombre e IgnoreQueryFilters() es todo-o-nada, las alternativas son configuraciones de DbContext separadas o una columna discriminadora y tipos de entidad separados.

Un patrón alternativo es un flag con scope:

public class AppDbContext : DbContext
{
    // Scope por petición; establecer en true en endpoints de administración
    public bool IgnoreSoftDeleteFilter { get; set; }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // La lambda lee el flag en tiempo de consulta
        modelBuilder.Entity<Post>()
            .HasQueryFilter(p => IgnoreSoftDeleteFilter || !p.IsDeleted);
    }
}

Usa esto con moderación — el enfoque con flag hace las pruebas más difíciles y puede causar bugs sutiles si un DbContext se reutiliza entre peticiones.

Pruebas con Filtros de Consulta

Estrategia 1: Probar a Través del Filtro (Por Defecto)

La mayoría de las pruebas deben ejercitar el filtro como lo hace el código de producción. Sembrar datos eliminados y activos; afirmar que solo regresan datos activos:

[Fact]
public async Task GetPosts_ExcludesSoftDeletedPosts()
{
    // Arrange — usa proveedor in-memory o 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 o valor conocido
    db.Posts.AddRange(
        new Post { Title = "Activo", TenantId = tenantId },
        new Post { Title = "Eliminado", IsDeleted = true, TenantId = tenantId }
    );
    await db.SaveChangesAsync();
 
    // Act
    var posts = await db.Posts.ToListAsync();
 
    // Assert
    Assert.Single(posts);
    Assert.Equal("Activo", posts[0].Title);
}

Estrategia 2: Omitir el Filtro en las Aserciones

Usa IgnoreQueryFilters() en la fase de aserción para verificar que un soft delete realmente escribió en la base de datos sin aparecer a través del 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 Eliminar", TenantId = tenantId };
    db.Posts.Add(post);
    await db.SaveChangesAsync();
 
    // Act — activa soft delete via Remove + SaveChanges
    db.Posts.Remove(post);
    await db.SaveChangesAsync();
 
    // Assert — omite el filtro para inspeccionar la fila subyacente
    var deletedPost = await db.Posts
        .IgnoreQueryFilters()
        .FirstAsync(p => p.Id == post.Id);
 
    Assert.True(deletedPost.IsDeleted);
    Assert.NotNull(deletedPost.DeletedAt);
}

Proveedor de Tenant Falso para Pruebas

public class FakeTenantProvider : ITenantProvider
{
    public FakeTenantProvider(Guid tenantId) => TenantId = tenantId;
    public Guid TenantId { get; }
}

Filtros en Entidades Propias (Owned Entities)

EF Core no soporta HasQueryFilter en tipos de entidad propios (entidades configuradas con OwnsOne u OwnsMany). Las entidades propias siempre se cargan como parte de su propietario y no tienen consultas independientes, por lo que el filtro en la entidad propietaria las cubre.

public class Customer : AuditableEntity
{
    public string Name { get; set; } = string.Empty;
    // Propia — cargada con Customer, el filtro en Customer cubre el acceso
    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;
}
 
// En OnModelCreating:
modelBuilder.Entity<Customer>()
    .OwnsOne(c => c.BillingAddress);
 
// El filtro de soft delete en Customer ya cubre Address —
// no se necesita ni es posible un filtro separado para Address.

Si aplicas HasQueryFilter a un tipo propio, EF Core lanza una InvalidOperationException al inicio.

Ejemplo Completo de Configuración del 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 propios
        modelBuilder.Entity<Customer>()
            .OwnsOne(c => c.BillingAddress);
 
        // Configurar filtros
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            // Omitir entidades propias — no pueden tener filtros independientes
            if (entityType.IsOwned()) continue;
 
            var clrType = entityType.ClrType;
 
            if (typeof(TenantEntity).IsAssignableFrom(clrType))
            {
                // Combinado: !IsDeleted && TenantId == actual
                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));
                // A través de la constante del contexto — ver el aviso de arriba: una
                // constante del propio provider queda horneada en el 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))
            {
                // Solo 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 rendimiento
        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");
    }
 
    // La sobrecarga con bool — los cuatro puntos de entrada públicos de SaveChanges
    // desembocan en ella, así que los llamadores síncronos también quedan interceptados.
    public override Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess, CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;
 
        foreach (var entry in ChangeTracker.Entries())
        {
            // Asigna tenant automáticamente en nuevas entidades de tenant
            if (entry.Entity is TenantEntity tenantEntity
                && entry.State == EntityState.Added)
            {
                tenantEntity.TenantId = _tenantProvider.TenantId;
            }
 
            // Intercepta eliminaciones permanentes, convierte a soft deletes
            if (entry.Entity is SoftDeletableEntity softEntity
                && entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                softEntity.IsDeleted = true;
                softEntity.DeletedAt = now;
            }
 
            // Marcas de tiempo de auditoría
            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 en DI

// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

Verifícalo tú mismo

Cada afirmación de arriba está verificada por samples/ef-core-global-query-filters — 22 comprobaciones que lanzan excepción si fallan (EF Core 10, SQL Server LocalDB). Lo más destacado:

  • Una consulta normal, un JOIN de Include() y un count excluyen las filas con soft delete; un line item sembrado a propósito con el TenantId equivocado nunca se filtra a través de Include(i => i.LineItems).
  • Remove() + SaveChangesAsync() ejecuta exactamente un UPDATE — la fila sobrevive con IsDeleted = true, y el patrón de restauración la trae de vuelta.
  • Cambiar un tenant provider mutable en el mismo contexto cambia los resultados por consulta; un contexto nuevo con otro provider ve su propio tenant — porque el filtro referencia el provider a través del contexto.
  • La variante rota a propósito (Expression.Constant(provider)) pasa su primer uso y después devuelve las filas del tenant A a un contexto del tenant B, exactamente como predice el aviso sobre el cacheo del modelo.
  • La trampa del fixup se reproduce: tras IgnoreQueryFilters(), un Include() filtrado en el mismo contexto vuelve a mostrar el post eliminado vía fixup de entidades rastreadas.
  • ExecuteDelete elimina de verdad atravesando la intercepción de SaveChanges.
  • IgnoreQueryFilters(["SoftDelete"]) muestra la factura eliminada del tenant actual mientras el tenant B sigue oculto — los filtros con nombre componen de verdad.
  • HasQueryFilter en un tipo owned lanza InvalidOperationException, y el índice parcial se lee de vuelta desde sys.indexes como ([IsDeleted]=(0)).
Salida de consola del sample de global query filters: 22 comprobaciones que pasan. Soft delete — las consultas normales devuelven 2 de 3 posts, IgnoreQueryFilters muestra los 3, la trampa del fixup hace que un Include filtrado vuelva a mostrar el post eliminado tras una consulta sin filtros en el mismo contexto, Remove más SaveChanges ejecuta exactamente un UPDATE y el patrón de restauración recupera la fila. Multi-tenancy — el tenant A ve solo su factura activa, el line item marcado con el tenant B nunca se fuga a través de Include, IgnoreQueryFilters con el nombre SoftDelete muestra la factura eliminada sin dejar de ocultar al tenant B, cambiar el provider en el mismo contexto cambia de tenant por consulta, y ExecuteDelete elimina de verdad atravesando la intercepción. La trampa de Expression.Constant(provider) — un segundo contexto defectuoso construido con un provider del tenant B sigue viendo al tenant A porque el primer provider quedó horneado en el modelo cacheado. Entidades owned — HasQueryFilter sobre Address lanza InvalidOperationException, y el índice parcial se lee desde sys.indexes como IsDeleted igual a cero.
Las 22 comprobaciones, directo de la consola — incluidas las dos filas que más importan: el contexto defectuoso devolviendo las filas del tenant A a un provider del tenant B, y IgnoreQueryFilters(["SoftDelete"]) manteniendo intacto el aislamiento de tenant.
💡

Ejecuta primero su seed.sql y luego dotnet run. Una nota práctica de escribirlo: crea los índices filtrados con QUOTED_IDENTIFIER ON — sqlcmd lo trae OFF por defecto y el CREATE INDEX ... WHERE falla.

Resumen

EscenarioEnfoque
Soft delete para todas las entidadesBucle con árbol de expresiones sobre subtipos de SoftDeletableEntity en OnModelCreating
Multi-tenancyITenantProvider con scope, referenciado a través del contexto en el filtro; combinar con filtro de soft delete
Ver registros eliminadosIgnoreQueryFilters() en la consulta
Restaurar un registroObtener con IgnoreQueryFilters(), establecer IsDeleted = false, guardar
Filtrado de propiedades de navegaciónAutomático — EF Core aplica filtros en entidades incluidas
Índice parcialHasIndex(...).HasFilter("[IsDeleted] = 0")
PruebasSembrar datos eliminados y activos; afirmar que solo se devuelven filas activas; usar IgnoreQueryFilters() en la fase de aserción para verificar escrituras de soft delete
Entidades propiasNo es posible aplicar filtro; cubierto por el filtro de la entidad propietaria

Lectura Relacionada

Los filtros globales son configuración a nivel de modelo, así que se aplican en OnModelCreating junto al resto del mapeo — consulta el recorrido por las migraciones de EF Core para ver qué cambios de modelo generan migraciones y cuáles no.

Ten en cuenta también que un filtro global se aplica a cada consulta que toca la entidad, incluidas las de dentro de un Include, lo que puede sorprenderte al depurar el problema de consultas N+1.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados