
Los Global Query Filters de Entity Framework Core son especialmente útiles cuando existen condiciones que deberían aplicarse automáticamente a prácticamente todas las consultas de una entidad.
Dos casos muy habituales son:
- Soft Delete.
- Multi-Tenancy.
El problema aparece cuando necesitamos ambas cosas al mismo tiempo.
Supongamos una aplicación SaaS donde cada pedido pertenece a un tenant:
public class Order
{
public int Id { get; set; }
public string Description { get; set; } = string.Empty;
public int TenantId { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAt { get; set; }
}Tenemos dos reglas independientes:
- El usuario sólo puede consultar pedidos de su tenant.
- Los pedidos eliminados lógicamente no deberían aparecer normalmente.
Hasta EF Core 9 podíamos resolver ambas condiciones mediante un Global Query Filter:
modelBuilder.Entity<Order>()
.HasQueryFilter(order =>
!order.IsDeleted &&
order.TenantId == CurrentTenantId);Funciona.
Pero ambas reglas quedaron convertidas en un único filtro.
Y ahí aparece el problema.
El problema: necesito desactivar una regla, pero no la otra
Imaginemos que estamos desarrollando una pantalla similar a una papelera de reciclaje.
Un administrador del tenant necesita consultar también sus pedidos eliminados.
Queremos quitar temporalmente:
IsDeleted == falsepero debemos conservar:
TenantId == CurrentTenantIdLa consulta normal de nuestra API es:
var orders = await _context.Orders
.OrderBy(order => order.Id)
.ToListAsync();Con el filtro combinado activo, EF Core genera SQL equivalente a:
SELECT [o].[Id],
[o].[CreatedAt],
[o].[Description],
[o].[IsDeleted],
[o].[TenantId]
FROM [Orders] AS [o]
WHERE [o].[IsDeleted] = CAST(0 AS bit)
AND [o].[TenantId] = @CurrentTenantId
ORDER BY [o].[Id]Para un tenant con CurrentTenantId = 1, esta consulta devuelve únicamente los pedidos activos de ese tenant.
En nuestro ejemplo:
Order 1 - Tenant 1 - ActiveLa API no escribió ningún Where.
Las dos condiciones fueron agregadas automáticamente por EF Core.
¿Qué pasaba si necesitábamos incluir eliminados?
Hasta EF Core 9, la herramienta disponible era:
.IgnoreQueryFilters()Por ejemplo:
var orders = await _context.Orders
.IgnoreQueryFilters()
.OrderBy(order => order.Id)
.ToListAsync();El problema es que IgnoreQueryFilters() no podía desactivar solamente una parte del filtro combinado.
Desactivaba el filtro completo.
El SQL generado quedaba así:
SELECT [o].[Id],
[o].[CreatedAt],
[o].[Description],
[o].[IsDeleted],
[o].[TenantId]
FROM [Orders] AS [o]
ORDER BY [o].[Id]Desaparecieron ambas condiciones:
IsDeleted = 0
TenantId = @CurrentTenantIdEn nuestro laboratorio, eso devuelve:
Order 1 - Tenant 1 - Active
Order 2 - Tenant 1 - Deleted
Order 3 - Tenant 2 - Active
Order 4 - Tenant 2 - DeletedPero nosotros solamente queríamos ver los registros eliminados del tenant actual.
No queríamos acceder a datos de otros tenants.
El workaround antes de EF Core 10
El problema podía resolverse manualmente:
var orders = await _context.Orders
.IgnoreQueryFilters()
.Where(order => order.TenantId == _context.CurrentTenantId)
.OrderBy(order => order.Id)
.ToListAsync();Ahora obtenemos:
Order 1 - Tenant 1 - Active
Order 2 - Tenant 1 - DeletedY EF genera:
SELECT [o].[Id],
[o].[CreatedAt],
[o].[Description],
[o].[IsDeleted],
[o].[TenantId]
FROM [Orders] AS [o]
WHERE [o].[TenantId] = @CurrentTenantId
ORDER BY [o].[Id]Funciona.
Pero hay una diferencia importante:
.Where(order => order.TenantId == _context.CurrentTenantId)no fue agregado automáticamente por EF.
Lo agregamos nosotros.
Primero desactivamos todos los filtros globales y después reconstruimos manualmente la regla que queríamos conservar.
Con sólo dos condiciones puede parecer un problema menor.
Pero imaginemos una aplicación donde tenemos:
Soft Delete
Tenant
Region
Visibility
Security Scopey necesitamos ignorar solamente Soft Delete.
Con el enfoque anterior tenemos que:
- Desactivar todos los filtros.
- Recordar cuáles deben continuar aplicándose.
- Reconstruirlos manualmente.
Además de repetitivo, aumenta el riesgo de olvidar una restricción.
EF Core 10: Named Query Filters
EF Core 10 introduce Named Query Filters.
Ahora podemos definir varios filtros independientes sobre la misma entidad:
modelBuilder.Entity<Order>()
.HasQueryFilter(
QueryFilterNames.SoftDeletion,
order => !order.IsDeleted)
.HasQueryFilter(
QueryFilterNames.Tenant,
order => order.TenantId == CurrentTenantId);En el ejemplo utilizamos constantes para evitar strings distribuidos por la aplicación:
public static class QueryFilterNames
{
public const string SoftDeletion = "SoftDeletionFilter";
public const string Tenant = "TenantFilter";
}Ahora tenemos dos reglas distintas:
SoftDeletionFilter
↓
!order.IsDeleted
TenantFilter
↓
order.TenantId == CurrentTenantIdLa diferencia importante no es simplemente que cada filtro tenga un nombre.
La diferencia es que EF Core puede administrarlos de manera independiente.
Deshabilitar solamente Soft Delete
Ahora podemos escribir:
var orders = await _context.Orders
.IgnoreQueryFilters([QueryFilterNames.SoftDeletion])
.OrderBy(order => order.Id)
.ToListAsync();Estamos diciendo explícitamente:
Ignorar SoftDeletionFilterNo estamos desactivando:
TenantFilterPor eso EF Core mantiene automáticamente la restricción por tenant.
El SQL generado es:
SELECT [o].[Id],
[o].[CreatedAt],
[o].[Description],
[o].[IsDeleted],
[o].[TenantId]
FROM [Orders] AS [o]
WHERE [o].[TenantId] = @CurrentTenantId
ORDER BY [o].[Id]El resultado vuelve a ser:
Order 1 - Tenant 1 - Active
Order 2 - Tenant 1 - DeletedEl resultado es el mismo que obteníamos con el workaround anterior.
Pero la intención del código es completamente diferente.
Antes
.IgnoreQueryFilters()
.Where(order =>
order.TenantId == _context.CurrentTenantId)Primero eliminamos todas las reglas y después reconstruimos manualmente la que queríamos conservar.
EF Core 10
.IgnoreQueryFilters([QueryFilterNames.SoftDeletion])Solamente desactivamos la regla que realmente queremos ignorar.
El filtro de tenant nunca fue desactivado.
Ese es el verdadero valor de Named Query Filters.
También podemos hacer lo contrario
Supongamos ahora que necesitamos una consulta administrativa que muestre los pedidos activos de todos los tenants.
Queremos desactivar:
TenantFilterpero mantener:
SoftDeletionFilterCon EF Core 10:
var orders = await _context.Orders
.IgnoreQueryFilters([QueryFilterNames.Tenant])
.OrderBy(order => order.Id)
.ToListAsync();EF genera:
SELECT [o].[Id],
[o].[CreatedAt],
[o].[Description],
[o].[IsDeleted],
[o].[TenantId]
FROM [Orders] AS [o]
WHERE [o].[IsDeleted] = CAST(0 AS bit)
ORDER BY [o].[Id]El tenant desapareció de la consulta.
Soft Delete continúa activo.
El resultado:
Order 1 - Tenant 1 - Active
Order 3 - Tenant 2 - ActiveEsto deja muy clara la diferencia respecto de los filtros anteriores.
Cada regla puede participar o no de la consulta de manera independiente.
Comparación completa
En el laboratorio utilizamos cuatro pedidos:
Order 1
Tenant: 1
Deleted: false
Order 2
Tenant: 1
Deleted: true
Order 3
Tenant: 2
Deleted: false
Order 4
Tenant: 2
Deleted: trueY CurrentTenantId = 1.
El comportamiento queda así:
| Consulta | Soft Delete | Tenant | Resultado |
|---|---|---|---|
| Consulta normal | Activo | Activo | Order 1 |
IgnoreQueryFilters() | Desactivado | Desactivado | Orders 1, 2, 3, 4 |
| Workaround anterior | Desactivado | Reconstruido manualmente | Orders 1, 2 |
Ignorar SoftDeletionFilter | Desactivado | Activo | Orders 1, 2 |
Ignorar TenantFilter | Activo | Desactivado | Orders 1, 3 |
La diferencia entre las dos consultas que devuelven Order 1 y Order 2 es especialmente importante.
Workaround anterior
var orders = await _context.Orders
.IgnoreQueryFilters()
.Where(order =>
order.TenantId == _context.CurrentTenantId)
.ToListAsync();EF Core 10
var orders = await _context.Orders
.IgnoreQueryFilters([QueryFilterNames.SoftDeletion])
.ToListAsync();Ambas pueden generar SQL equivalente:
WHERE [o].[TenantId] = @CurrentTenantIdPero sólo en la segunda esa condición sigue existiendo porque forma parte del modelo y EF Core la mantuvo automáticamente.
¿Cuándo usar Named Query Filters?
Esta característica resulta especialmente útil cuando una entidad tiene varias reglas transversales independientes.
Por ejemplo:
Soft Delete
+
Multi-TenancyTambién:
Soft Delete
+
Tenant
+
IsActiveo:
Tenant
+
Archived
+
VisibilityOtro caso habitual podría ser una aplicación donde normalmente sólo queremos consultar registros vigentes:
IsArchived = falsepero determinados reportes necesitan incluir registros archivados sin dejar de respetar el tenant u organización actual.
El patrón suele ser siempre el mismo:
Tengo varias reglas globales.
En determinadas consultas necesito desactivar solamente una.Ahí Named Query Filters encaja muy bien.
¿Cuándo no aporta demasiado?
Si una entidad solamente tiene:
.HasQueryFilter(x => !x.IsDeleted)y nunca necesitamos combinar ese filtro con otras reglas, probablemente no exista una necesidad real de convertirlo inmediatamente en un filtro con nombre.
Los Global Query Filters tradicionales siguen siendo perfectamente válidos.
Tampoco utilizaría Query Filters para transformar cualquier condición de negocio en una regla global.
Por ejemplo:
Orders.CreatedAt > DateTime.UtcNow.AddDays(-30)probablemente corresponda a una consulta específica y no a una condición que deba modificar automáticamente todas las consultas sobre Order.
Los Query Filters funcionan mejor cuando representan reglas realmente transversales del modelo.
Named Query Filters no reemplaza la seguridad
Hay una distinción importante.
Que tengamos:
TenantFilterno significa que EF Core se haya convertido en nuestro sistema de autorización.
Un desarrollador todavía puede escribir:
.IgnoreQueryFilters()y desactivar todos los filtros.
Named Query Filters reduce la necesidad de hacerlo cuando solamente queremos ignorar una condición, pero no convierte los filtros del ORM en una frontera de seguridad imposible de evitar.
En sistemas donde el aislamiento entre tenants sea crítico, estos filtros deberían formar parte de una estrategia más amplia:
Autorización
+
Validaciones
+
Tests
+
Aislamiento de datosY, dependiendo del escenario, incluso podrían complementarse con mecanismos de base de datos como Row-Level Security.
Ojo con las navegaciones requeridas
Los Global Query Filters tienen además una consideración importante cuando intervienen relaciones.
Si una entidad relacionada mediante una navegación requerida queda excluida por un filtro, EF Core puede generar un INNER JOIN.
Eso puede provocar que la entidad principal tampoco aparezca en el resultado.
Por eso no alcanza con comprobar que un filtro funciona consultando directamente:
context.OrdersEn modelos más complejos también conviene probar las consultas que incluyen relaciones afectadas por filtros globales.
El SQL importa
Una de las cosas que me interesa mostrar en esta serie es que no alcanza con mirar solamente el LINQ.
También debemos observar qué termina ejecutando la base de datos.
En este ejemplo vimos claramente la evolución.
Consulta normal
WHERE [o].[IsDeleted] = CAST(0 AS bit)
AND [o].[TenantId] = @CurrentTenantIdIgnoreQueryFilters()
-- Sin condiciones globalesWorkaround anterior
WHERE [o].[TenantId] = @CurrentTenantIdEF Core 10 ignorando Soft Delete
WHERE [o].[TenantId] = @CurrentTenantIdEF Core 10 ignorando Tenant
WHERE [o].[IsDeleted] = CAST(0 AS bit)La diferencia entre el workaround anterior y EF Core 10 no está necesariamente en el SQL resultante.
Está en cómo llegamos a ese SQL y qué reglas siguen perteneciendo al modelo.
Ese detalle es fundamental.
El ejemplo completo
El código que acompaña este artículo se encuentra dentro del repositorio de laboratorios de EF Core 10:
EfCore10Labs
└── 01-NamedQueryFilters
├── NamedQueryFilters.slnx
└── NamedQueryFilters.ApiEl laboratorio utiliza:
.NET 10
Entity Framework Core 10
ASP.NET Core Web API
SQL ServerNo requiere ninguna característica específica de SQL Server 2025.
El tenant actual se mantiene deliberadamente fijo en:
public int CurrentTenantId { get; } = 1;En una aplicación real, normalmente provendría del contexto de autenticación, un claim, un header, un subdominio u otro mecanismo.
El valor fijo permite aislar el comportamiento que queremos estudiar: los Query Filters.
Conclusión
Named Query Filters puede parecer inicialmente una mejora pequeña de EF Core 10.
Después de todo, visualmente el cambio parece limitarse a esto:
.HasQueryFilter("SoftDeletionFilter", ...)
.HasQueryFilter("TenantFilter", ...)Pero el cambio importante no está en poder ponerle un nombre a un filtro.
Está en poder administrar cada regla de manera independiente.
En escenarios donde conviven Soft Delete, Multi-Tenancy, archivado, visibilidad u otras restricciones transversales, ya no tenemos que elegir entre mantener todos los filtros activos o ejecutar:
IgnoreQueryFilters()y eliminarlos todos.
Ahora podemos expresar exactamente qué regla queremos ignorar.
Y eso hace que el código sea más explícito, reduce la duplicación y disminuye el riesgo de reconstruir manualmente reglas que deberían seguir formando parte del modelo.