Global filters: the rules every query obeys
The basics covered per-entity filters: one record names them, one builder translates them. But some predicates shouldn't belong to any single entity. Honor the universal Id/Ids filters. Hide soft-deleted rows. Scope everything to the current tenant. Rules like these must apply to every entity of a certain shape — without any entity opting in, and without any developer remembering to.
That's what global filters are: query builders registered once, against an interface, that run for every matching entity in the pipeline.
What you already have
UseDefaults() — the one-liner from the basics article — registers the built-in set: the Id/Ids/Exclude filters, the archived-row handling (next article), the free-text Q filter, and timestamp ranges. You can see "registered once, works everywhere" in the sample without writing a line — the base SearchObject fields work on both entities:
curl "$URL/vehicles?ids=1&ids=3" # TRK-001, TRK-003
curl "$URL/vehicles?exclude=2&exclude=4" # TRK-001, TRK-003, VAN-002
curl "$URL/products?ids=2" # Dash cam
Nobody wrote an id filter for Vehicle or Product. The global builder saw a search object with ids in it and applied the predicate — for any entity, forever.
Adding your own: the tenant filter
The classic custom global filter is row-level tenancy. Mark tenant-owned entities with an interface (IHasTenantId), and register one builder against it:
options.UseDefaults();
options.AddGlobalFilterQueryBuilder<FilterHasTenantQueryBuilder>(); // yours: every IHasTenantId
The builder itself is about five lines — resolve the current tenant id, apply Where(x => x.TenantId == tenantId). That's the whole trick: tenant isolation becomes a property of the data layer, not a discipline every endpoint must remember. The blueprint, the claims plumbing and the honest list of gotchas (raw SQL, background jobs) fill a deep dive of their own.
Two placement facts matter more than the code:
- Global filters run before entity filters, in the query pipeline — not in controllers. So they also govern
Count,/searchtotals, and direct service-layer calls. There is no path around them. - A pipeline filter is a plain
Whereon the root query. It does not reach insideInclude(...)'d child collections. Cross-tenant children need their own guard — tenant-stamp the children too, or filter in the include lambda. (The archived filter handles this differently, as the next article shows.)
The string-id story: why key types matter
Here's the part that looks like plumbing but is actually a security property.
Global filter builders are generic over the entity's key type. The pipeline runs one variant per filter family, preferring the one that matches the entity's search object — for the int-keyed entities you've seen so far, the int variant, which can read Ids, Exclude, and friends.
Now add an entity with a string key (natural codes, external identifiers). When that entity meets the int-keyed variant of a global filter, the types don't line up — and the search object coerces to null. The design question is what happens next, and the answer is the property worth remembering:
The builder does not step aside. It applies its key-agnostic default instead.
Concretely: the archived filter can't read typed fields it doesn't know, but it still hides archived rows. A soft-delete or tenancy default that silently dropped whenever a key type didn't match would be the worst kind of bug — invisible, and wide open. This design refuses that failure mode.
What you give up on a key mismatch is only the typed part — the string-keyed entity's ?ids= filter won't bind through the int variant. So when you introduce non-int keys, register the matching variants too:
options.AddDefaultGlobalQueryFilters<string>(); // typed fields for string-keyed entities
One line, and the string-keyed entities get the same universal filters the int ones had — while the safety defaults were never at risk in the first place.
What to take away
Cross-cutting rules live in the pipeline, keyed by interface, registered once: that's what makes them provable rather than hoped for. The built-ins arrive with UseDefaults(); your own (tenancy is the archetype) are one class and one registration line; and the key-type machinery is designed so that the important defaults apply even when types don't match.
The most useful built-in global filter deserves its own article, because it changes what DELETE means: Archiving — delete without losing data.
Docs: Regira Entities — global filter query builders and defaults. Runnable curls: the series' sample project.