Skip to content

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

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.

There are three pieces:

  1. Generated filters Produced from persistence attributes such as [SoftDelete].
  2. Runtime filter services IQueryFilterProvider, IQueryFilterToggle, and related infrastructure.
  3. 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.

Four interfaces, and the choice is not a matter of taste: each changes how the filter composes with the others, or when it is skipped. Picking the wrong one is silent — the filter runs, and hides or reveals rows you did not mean.

You wantImplementWhat that buys
A rule that always narrows the setIQueryFilter<T>ANDed with every other restrictive filter
A rule a permission can liftIPermissionBasedFilter<T>skipped for a holder of BypassPermission, skipped in the trusted modes, and its presence is what makes an anonymous read fail closed
A rule that widens what is visibleIScopeVisibilityFilterORed with the other visibility contributions, then ANDed with the restrictive ones
To mark a filter as the tenant oneITenantFilterthe provider recognises it by type rather than by name

IQueryFilter<T> — the restrictive default

Section titled “IQueryFilter<T> — the restrictive default”

Everything that removes rows and has no exception: soft delete, an archived flag, a status the application never shows.

public sealed class NotArchivedFilter : IQueryFilter<Order>
{
public int Priority => 100;
public Expression<Func<Order, bool>> GetFilter() => o => !o.IsArchived;
}

Composed with AND. Two restrictive filters both apply, and adding one can only ever return fewer rows.

IPermissionBasedFilter<T> — restrictive, with a way out

Section titled “IPermissionBasedFilter<T> — restrictive, with a way out”

The same thing plus a BypassPermission: the rule holds for everyone except the holders of that permission. Reach for it whenever the sentence contains “unless”.

public sealed class OwnTeamOnlyFilter(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 values) ? values[0] : "";
return o => o.TeamId == teamId;
}
}

Three things follow from the interface, and only the first is obvious:

  • a caller holding orders.view_all reads unfiltered;
  • the trusted modes — Admin, Background, Raw — skip it, which is what lets a job read without a user;
  • ⚠️ its presence is what makes an anonymous read fail closed. FailClosedWhenAnonymous looks for a permission-based filter on the entity; an entity whose only guard is a plain IQueryFilter<T> that reads ICurrentUser does not fail closed — it evaluates the expression against an empty user and returns whatever that happens to match. If the rule depends on who is asking, say so with this interface rather than by reading the user inside a restrictive one.

The one that exists because AND is the wrong operator. Ownership, materialised access scopes and computed scope rules each say a row is visible; a row reachable through any of them should come back, and ANDing them means a row must satisfy all three — which hides everything reachable through only one.

It is a marker, not a second GetFilter, so it goes beside one of the two above. That is what the generated filters do:

// generated for [HasAccessScopes], priority 250
public sealed class ScopedDataFilter(IUserScopeResolver scopes, ICurrentUser user)
: IPermissionBasedFilter<Invoice>, IScopeVisibilityFilter

The provider ORs every visibility filter together first, then ANDs the result with the restrictive group.

ITenantFilter — a marker the provider looks for

Section titled “ITenantFilter — a marker the provider looks for”

You rarely write one: an entity that is ITenantEntity gets a generated TenantFilter carrying it. It exists so the provider can recognise the tenant filter by type rather than by name — a class of your own called TenantFilter is not one, and this interface is what says so.

Two dials that are not about which interface

Section titled “Two dials that are not about which interface”

Priority (lower first) decides the order of the AND terms. It does not change the result — a conjunction is commutative — but it is what you read in the log, so keep the numbers meaningful. What the generator emits, measured:

FilterPriorityInterfaces
SoftDeleteFilter100IQueryFilter<T>
TenantFilter200IQueryFilter<T>, ITenantFilter
DataAccessFilter, ParentVisibilityFilter200IPermissionBasedFilter<T> (+ IScopeVisibilityFilter for the first)
ScopedDataFilter250IPermissionBasedFilter<T>, IScopeVisibilityFilter

Scope decides where the filter reaches: Root, Collections, OptionalReferences, RequiredReferences, Subqueries, Projections, Joins, combined with |. The default covers the ordinary read.

⚠️ On a required reference, BehaviorOnRequired decides what happens when the related row does not match: FilterNavigation filters the navigation and can leave a null where the model says there cannot be one, FilterParent drops the whole parent row, Skip leaves the relation alone. FilterParent is the safe reading; FilterNavigation is the default. Worth knowing before a required navigation starts coming back null.

In a Pragmatic host you register nothing. The generated host calls the filter registration of every module it discovers, from the persistence metadata, before your IStartupStep runs.

The extension it calls is Add{Prefix}QueryFilters(), in _Infra.Persistence.QueryFilters.g.cs, where {Prefix} is the identifier derived from the common namespace of the entities — AddGeneratedQueryFilters() when they share no prefix. Call it yourself only outside a host, next to the rest of the wiring:

builder.Services.AddSalesDbContext(o => o.UseNpgsql(cs));
builder.Services.AddPragmaticPersistenceRepositories<SalesDbContext>();
builder.Services.AddMyAppQueryFilters();

⚠️ Repositories resolve perfectly well without that last line, and read unfiltered: no soft-delete, no tenant, no ownership. Nothing fails, the rows simply are not restricted — which is why the host stopped leaving the call to the application.

SourceGenerated effectTypical purpose
[SoftDelete]Adds a soft-delete filterHide deleted rows from normal reads
Tenant-aware entity shapeAdds a tenant filterRestrict rows to the current tenant
Temporal relation metadataAdds a temporal filterHide inactive historical rows by default

You do not call these filters directly. The repository and query pipeline apply them for you.

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 IQueryFilterProvider for 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> orders,
IQueryFilterToggle filterToggle)
{
public Task<List<Order>> GetIncludingDeleted(CancellationToken ct)
{
using (filterToggle.Disable<Order.SoftDeleteFilter>())
{
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.

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:

The modes are ordered, and each one skips everything the one before it skips:

ModeValueSkips
Normal0nothing — every filter applies
Admin1visibility and permission-based filters
Background3the above, plus the tenant filter
Raw4everything, soft-delete included

⚠️ Soft-delete survives every mode except Raw. Admin does not reveal deleted rows — a common assumption, and a wrong one. To read them, disable that one filter or go to Raw.

For novice teams, use Raw sparingly. It is the sharpest tool in the box.

IQueryFilterToggle is a little wider than the three calls above:

MemberFor
Disable<TFilter>() / Disable(Type)Turn off one filter for the scope
DisableAll()Turn off all of them
UseMode(FilterMode)Switch mode for the scope
IsDisabled<TFilter>() / IsDisabled(Type)Ask, without changing anything
AllDisabled · CurrentMode · GetDisabledFilterTypes()What the current scope looks like

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.

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

When a filter depends on the current user’s permissions, implement IPermissionBasedFilter<T>.

Beyond GetFilter() and Priority, IQueryFilter<T> carries three members that decide where a filter applies — all with defaults, so you override only what you mean to change:

MemberDefaultWhat it decides
ScopeRoot | Collections | OptionalReferencesWhich parts of a query the filter reaches
ShouldApplyTo(NavigationContext)truePer-navigation say, for what Scope cannot express
BehaviorOnRequiredFilterNavigationWhat to do at a required navigation: filter it, filter the parent, or skip

FilterScope is a flags enum: None, Root, Collections, OptionalReferences, RequiredReferences, Subqueries, Projections, Joins, plus the combinations AllNavigations, Default and All.

⚠️ Read the default again: it covers the root, collections and optional references — not required references, subqueries, projections or joins. A filter that must reach those has to say so with Scope = FilterScope.All.

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()
{
// Claims maps a name to a LIST of values — a claim can legitimately appear more than once
var teamId = user.Claims.TryGetValue("team_id", out var values) ? values[0] : "";
return order => order.TeamId == teamId;
}
}

⚠️ The query returns nothing. This is the opposite of what it used to do, and the opposite of what most people assume.

DefaultQueryFilterProvider checks, before it looks at any individual filter: is the caller unauthenticated, does this entity have at least one permission-based filter, and is the mode not an elevated one? If all three hold it returns _ => false — a filter that matches no row — rather than dropping the permission filter and answering with everything. The option is QueryFilterOptions.FailClosedWhenAnonymous, and it defaults to true.

// Restore the historical fail-open behaviour — an [AllowAnonymous] endpoint that intentionally
// exposes a permission-filtered entity is the case it exists for
services.AddSingleton(new QueryFilterOptions { FailClosedWhenAnonymous = false });

The trusted modes are unaffected: Admin, Background and Raw skip permission filters by design, so a background job reads without a user and without failing closed.

The two remaining rules are per filter, and they run after that check:

  • the user holds BypassPermission → that filter is skipped for them;
  • there is no user at all and fail-closed is off → permission filters are skipped.

Register custom filters in DI as IQueryFilter — the provider takes them as an IEnumerable<IQueryFilter>, so the non-generic interface is the registration:

services.AddScoped<IQueryFilter, TeamOrdersFilter>();

Scoped, not singleton: the filter reads ICurrentUser, which is request-scoped.


Everything above in one worked case. The entity is soft-deletable, tenant-aware, and carries a team:

[Entity<Guid>]
[SoftDelete]
[BelongsTo<SalesBoundary>]
public partial class Order : IEntity, ITenantEntity
{
// public setter, not private: ITenantEntity requires one, because TenantInterceptor stamps it
// on insert from outside any object initializer. Treat it as infrastructure and do not assign it.
public string TenantId { get; set; } = "";
public string TeamId { get; private set; } = "";
public decimal Total { get; private set; }
}

Two filters are generated from that declaration — Order.SoftDeleteFilter (priority 100) and Order.TenantFilter (200). The third is yours:

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 values) ? values[0] : "";
return order => order.TeamId == teamId;
}
}
services.AddScoped<IQueryFilter, TeamOrdersFilter>();

A read through the generated repository — orders.FindAsync(spec, ct) — goes through these steps before your specification is even applied:

  1. Build the FilterContext from the tenant context, the current user, the clock and the toggle: Mode, DisabledFilters, TenantId, UserId, Now.
  2. Raw? Mode == FilterMode.Raw returns null immediately — no filter, not even soft-delete.
  3. Fail closed? Anonymous, a permission-based filter exists for Order, mode below Admin_ => false. The query runs and returns nothing.
  4. Select the filters for Order, ordered by Priority, dropping each one that is disabled by the toggle, named in DisabledFilters (by entity type or by filter type), skipped by the mode, or bypassed by the user’s permission.
  5. Compose them. Restrictive filters are ANDed. Scope-visibility filters — the ones implementing IScopeVisibilityFilter: ownership, scoped data, computed scope rules — are ORed with each other first, because they are additive: a row is visible if it matches any of them. The two groups are then ANDed together.
  6. Apply: query.Where(combined).

For an authenticated user in team t-7, tenant acme, with none of it disabled, the result is one expression:

e => !e.IsDeleted && e.TenantId == "acme" && e.TeamId == "t-7"
SELECT ... FROM "Orders" AS o
WHERE NOT o."IsDeleted" AND o."TenantId" = @__tenant AND o."TeamId" = @__team

The AND terms follow the priorities: 100, 200, 300. Order does not change the result, but it is what you will see in the log.

What you writeWhat happens to TeamOrdersFilterAnd to the others
nothing — an ordinary readappliesapply
the user has orders.view_allskippedapply
toggle.Disable<TeamOrdersFilter>()skippedapply
toggle.UseMode(FilterMode.Admin)skipped — it is permission-basedsoft-delete and tenant still apply
toggle.UseMode(FilterMode.Raw)skippednone apply, deleted rows included
no authenticated userthe query returns nothingirrelevant — nothing is composed

Assert the two halves separately: that the filter restricts, and that the escape hatch opens.

var user = FakeCurrentUser.Authenticated("u-1"); // in team t-7 via claims
services.AddScoped<ICurrentUser>(_ => user);
services.AddScoped<IQueryFilter, TeamOrdersFilter>();
// restricts
var mine = await orders.FindAsync(Spec<Order>.True, ct);
mine.Should().OnlyContain(o => o.TeamId == "t-7");
// and opens, for exactly one filter
using (toggle.Disable<TeamOrdersFilter>())
{
var all = await orders.FindAsync(Spec<Order>.True, ct);
all.Should().Contain(o => o.TeamId != "t-7");
}

⚠️ A test that only asserts the first half passes against a provider that never registered the filter at all. The second half is what tells the two apart.

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.

A common novice question is:

“If I include child collections, do filters still apply there?”

Mostly, and the exact answer is in the filter’s Scope. The default reaches collections and optional references, so Include(o => o.Items) is filtered; it does not reach required references, subqueries, projections or joins unless the filter widens its scope.

Example:

var order = await orderRepository.GetByIdAsync(
orderId,
query => query.Include(o => o.Items),
ct);

The soft-delete filter reaches that Include because collections are in the default scope: the children come back filtered, not wholesale.

This is handled by the generated filter map plus FilterMapComposer in the EF Core layer.

Forgetting to register generated query filters

Section titled “Forgetting to register generated query filters”

Only possible outside a Pragmatic host, since the host makes the call itself. When it does happen the repository resolves and reads unfiltered — deleted rows come back, tenants see each other. Nothing throws, which is what makes it worth checking for.

Prefer the narrowest possible scope:

  • disable one filter if that is enough
  • use Admin or Background mode when the behavior matches the use case
  • reserve Raw for 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>.

When testing filter behavior:

  • register generated query filters in the test DI container
  • resolve IQueryFilterToggle from DI
  • assert both the default filtered behavior and the explicitly disabled behavior

For a full test harness, see Testing Generated Persistence.