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 = @idEsto significa:
blog.Postsnunca contendrá posts con soft delete.blog.Posts.Countrefleja 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 elTenantIdequivocado nunca se filtra a través deInclude(i => i.LineItems). Remove()+SaveChangesAsync()ejecuta exactamente un UPDATE — la fila sobrevive conIsDeleted = 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(), unInclude()filtrado en el mismo contexto vuelve a mostrar el post eliminado vía fixup de entidades rastreadas. ExecuteDeleteelimina 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.HasQueryFilteren un tipo owned lanzaInvalidOperationException, y el índice parcial se lee de vuelta desdesys.indexescomo([IsDeleted]=(0)).

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
| Escenario | Enfoque |
|---|---|
| Soft delete para todas las entidades | Bucle con árbol de expresiones sobre subtipos de SoftDeletableEntity en OnModelCreating |
| Multi-tenancy | ITenantProvider con scope, referenciado a través del contexto en el filtro; combinar con filtro de soft delete |
| Ver registros eliminados | IgnoreQueryFilters() en la consulta |
| Restaurar un registro | Obtener con IgnoreQueryFilters(), establecer IsDeleted = false, guardar |
| Filtrado de propiedades de navegación | Automático — EF Core aplica filtros en entidades incluidas |
| Índice parcial | HasIndex(...).HasFilter("[IsDeleted] = 0") |
| Pruebas | Sembrar 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 propias | No 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.