Query Filters
Query filters are one of the main reasons this persistence stack exists.
They let you define data visibility rules once and apply them consistently, instead of relying on every query author to remember the same Where(...) clauses.
For novice users, the most important mental model is:
- filters are safety rails
- they run automatically on generated repository reads
- you disable them only on purpose, in a clearly scoped block
What problem query filters solve
Section titled “What problem query filters solve”In a real application, many rows should not be visible all the time:
- soft-deleted records should usually stay hidden
- tenant data should not leak across tenants
- temporal data may need “currently valid only” behavior
- row-level authorization may depend on the current user
Without query filters, each repository or handler must remember those conditions manually.
That is not a good reliability model.
How the system works
Section titled “How the system works”There are three pieces:
- Generated filters
Produced from persistence attributes such as
[SoftDelete]. - Runtime filter services
IQueryFilterProvider,IQueryFilterToggle, and related infrastructure. - Generated repositories They call the filter provider automatically on read paths.
In EF Core, navigation filtering is also applied through the filter-map pipeline so Include(...) paths do not accidentally bypass the same rules.
Registering generated filters
Section titled “Registering generated filters”Generated query filters are registered by a generated extension method.
Typical startup:
builder.Services.AddSalesDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("Sales")));
builder.Services.AddSalesRepositories();builder.Services.AddMyAppQueryFilters();Important:
- the query-filter registration method name is derived from the common namespace prefix
- if no common prefix can be derived, the fallback method is
AddGeneratedQueryFilters() - repositories can still resolve without this registration, but filter behavior will not match production expectations
Auto-generated filters
Section titled “Auto-generated filters”| Source | Generated effect | Typical purpose |
|---|---|---|
[SoftDelete] | Adds a soft-delete filter | Hide deleted rows from normal reads |
| Tenant-aware entity shape | Adds a tenant filter | Restrict rows to the current tenant |
| Temporal relation metadata | Adds a temporal filter | Hide inactive historical rows by default |
You do not call these filters directly. The repository and query pipeline apply them for you.
What a normal read looks like
Section titled “What a normal read looks like”Your code:
var orders = await ordersRepository.FindAsync( Spec<Order>.Where(o => o.Total > 1000), ct);What the repository effectively does:
- starts from the
DbSet<Order> - asks
IQueryFilterProviderfor the combined filter - applies the filter before executing the query
That is why soft-delete and tenant rules stay consistent across the application.
IQueryFilterToggle: temporary, scoped overrides
Section titled “IQueryFilterToggle: temporary, scoped overrides”Sometimes you really do need to bypass a filter:
- admin recovery screens
- support tooling
- data repair scripts
- migration workflows
IQueryFilterToggle is the safe mechanism for that.
Example:
public sealed class AdminOrdersService( IRepository<Order, Guid> orders, IQueryFilterToggle filterToggle){ public Task<List<Order>> GetIncludingDeleted(CancellationToken ct) { using (filterToggle.Disable<SoftDeleteFilter_Order>()) { return orders.Query().ToListAsync(ct); } }}Why the using block matters:
- the override is temporary
- the previous state is restored automatically
- the change stays local to the current async flow
Under the hood, QueryFilterToggle uses AsyncLocal, so the scope works correctly with await.
Filter modes
Section titled “Filter modes”If you need a broader behavioral switch, use FilterMode.
using (filterToggle.UseMode(FilterMode.Admin)){ // Skip visibility and permission-based filters.}
using (filterToggle.UseMode(FilterMode.Background)){ // Skip tenant, visibility, and permission-based filters.}
using (filterToggle.UseMode(FilterMode.Raw)){ // Bypass all automatic filters.}Current modes:
| Mode | Meaning |
|---|---|
Normal | Default behavior. All normal filters can apply. |
Admin | Skip visibility and permission-based filters. |
Background | Skip tenant, visibility, and permission-based filters; keep soft-delete. |
Raw | Bypass all automatic filters. |
For novice teams, use Raw sparingly. It is the sharpest tool in the box.
DisableAll() vs UseMode(FilterMode.Raw)
Section titled “DisableAll() vs UseMode(FilterMode.Raw)”Both can result in “show me everything”, but they communicate slightly different intent:
DisableAll()means “turn off all filters in this scope”UseMode(FilterMode.Raw)means “run this query path in raw mode”
If you are writing application code, UseMode(...) is often easier to reason about because it expresses a mode change rather than a collection of disabled filters.
FilterContext
Section titled “FilterContext”At runtime, filters evaluate against a FilterContext.
It carries information such as:
- current tenant
- current user ID
- current timestamp
- disabled filter types
- current mode
That is how the same filter infrastructure can support:
- tenant filtering
- time-aware filtering
- permission-aware behavior
- admin or migration bypasses
Permission-based filters
Section titled “Permission-based filters”When a filter depends on the current user’s permissions, implement IPermissionBasedFilter<T>.
Example:
using System.Linq.Expressions;using Pragmatic.Identity;using Pragmatic.Persistence.Query.Filters;
public sealed class TeamOrdersFilter(ICurrentUser user) : IPermissionBasedFilter<Order>{ public string BypassPermission => "orders.view_all"; public int Priority => 300;
public Expression<Func<Order, bool>> GetFilter() { var teamId = user.Claims.TryGetValue("team_id", out var value) ? value : ""; return order => order.TeamId == teamId; }}Important behavior built into DefaultQueryFilterProvider:
- if no
ICurrentUseris available, permission-based filters are skipped - if the user is not authenticated, permission-based filters are skipped
- if the user has the bypass permission, the filter is skipped
That design prevents background jobs and maintenance paths from crashing just because there is no authenticated request user.
Register custom filters in DI as IQueryFilter implementations:
services.AddScoped<IQueryFilter, TeamOrdersFilter>();Dynamic visibility filters
Section titled “Dynamic visibility filters”Some filters cannot be generated at compile time because they depend on runtime data from another service.
That is what IVisibilityFilterProvider is for.
Example:
using System.Linq.Expressions;using Pragmatic.Persistence.Query.Filters;
public sealed class TeamVisibilityProvider(ITeamService teams) : IVisibilityFilterProvider{ public IReadOnlyDictionary<Type, LambdaExpression> GetFilters(FilterContext context) { if (context.SkipVisibility) return new Dictionary<Type, LambdaExpression>();
var teamIds = teams.GetVisibleTeamIds(context.UserId);
return new Dictionary<Type, LambdaExpression> { [typeof(Project)] = (Expression<Func<Project, bool>>)(project => teamIds.Contains(project.TeamId)) }; }}Register it like any other scoped runtime service:
services.AddScoped<IVisibilityFilterProvider, TeamVisibilityProvider>();FilterMapComposer merges these runtime filters with the source-generated filter map used for navigation filtering.
Navigation filtering
Section titled “Navigation filtering”A common novice question is:
“If I include child collections, do filters still apply there?”
In this stack, yes. That is one of the main benefits of the EF Core integration.
Example:
var order = await orderRepository.GetByIdAsync( orderId, query => query.Include(o => o.Items), ct);With the filter pipeline active, child collections can still respect filters such as soft-delete instead of loading everything blindly.
This is handled by the generated filter map plus FilterMapComposer in the EF Core layer.
Common mistakes
Section titled “Common mistakes”Forgetting to register generated query filters
Section titled “Forgetting to register generated query filters”If you skip AddMyAppQueryFilters() or AddGeneratedQueryFilters(), the repository may still run but the expected filtering contract will not be fully active.
Disabling filters too broadly
Section titled “Disabling filters too broadly”Prefer the narrowest possible scope:
- disable one filter if that is enough
- use
AdminorBackgroundmode when the behavior matches the use case - reserve
Rawfor migrations, support, or very explicit low-level paths
Treating filter bypass as a normal business path
Section titled “Treating filter bypass as a normal business path”If a screen or endpoint always needs raw data, that is often a modeling smell. Revisit the business rule before normalizing widespread filter bypass.
Assuming interface methods expose everything
Section titled “Assuming interface methods expose everything”Filters affect repository reads, but helper methods like GetBySkuAsync(...) still live on the concrete generated repository, not on IRepository<TEntity, TId>.
Testing guidance
Section titled “Testing guidance”When testing filter behavior:
- register generated query filters in the test DI container
- resolve
IQueryFilterTogglefrom DI - assert both the default filtered behavior and the explicitly disabled behavior
For a full test harness, see Testing Generated Persistence.