← All articles

The SearchObject pattern: composable filtering for EF Core APIs

How to turn ?brand=volvo&hasIntervention=true into safe, index-friendly SQL — a deep dive into typed search objects, query builders, global filters, normalized free-text search and union queries.
The SearchObject pattern: composable filtering for EF Core APIs

Filtering is the feature every API grows, and almost none design. It starts with one ?name= parameter handled inline in a controller. Then somebody needs a date range, then a status list, then free text; the controller method sprouts twelve optional parameters and a pyramid of ifs; a second endpoint needs the same filters and copies them. A year in, "search" is the most duplicated, least tested code in the service.

The cure is to treat filtering as a first-class contract with three separated responsibilities: a typed object that names the filters, query builders that translate them to IQueryable, and a pipeline that composes builders with sorting, paging and includes in a fixed order. This is the architecture of Regira Entities' read path, and this article walks it end to end — including the parts that only show up at scale: global filters, key-type mechanics, normalized free-text search and union queries.

The contract: a SearchObject

One record per entity names everything you can filter on:

public record VehicleSearchObject : SearchObject
{
    public string? Code { get; set; }
    public string? Model { get; set; }
    public ICollection<int>? BrandId { get; set; }
    public ICollection<int>? VehicleTypeId { get; set; }
    public string? Brand { get; set; }          // filter by related entity's text
    public bool? HasIntervention { get; set; }  // existence of related rows
}

(That's the shape of a real one from the open-source Regira-Fleet sample, with a Brand text filter added for this article.) The base SearchObject contributes the universal fields — Id/Ids, the free-text Q, timestamp ranges, the archived-rows selector — and three conventions do a lot of quiet work:

  • null means "not filtered". Every property is nullable; absence is meaningful. This is what lets one record serve every combination of filters.
  • Id filters are collections. ICollection<int>? BrandId binds ?brandId=1&brandId=3 naturally and makes the multi-select case the default rather than a later retrofit.
  • The record binds straight from the query string. GET /vehicles?brand=volvo&hasIntervention=true is the API. No DSL, no JSON query language — the filter vocabulary is discoverable in OpenAPI because it's just a parameter object.

The same record is used at every layer — controller binding, service signatures (List(so), Count(so)), and query builders — so "what can you filter on" has exactly one definition. It's also mirrored client-side: the TypeScript entities client sends the same shape from its filter panels, which is why front end and back end can't drift apart.

Where filtering sits: the read pipeline

A request travels a fixed pipeline: the entity set, then query builders — filters, sorting, paging, includes, in that order — then post-fetch processors, then DTO mapping. The part we care about assembles one IQueryable and therefore one SQL statement; nothing here materializes early.

Filters come in two granularities.

Per-entity filters: inline or classed

For simple cases, an inline lambda at registration keeps everything in one place:

services.For<Product, int, ProductSearchObject>(item =>
{
    item.Filter((query, so) =>
    {
        if (so?.MinPrice != null) query = query.Where(x => x.Price >= so.MinPrice);
        if (so?.MaxPrice != null) query = query.Where(x => x.Price <= so.MaxPrice);
        return query;
    });
});

(services.For<…> is the per-entity registration API from Regira.Entities.DependencyInjection; it runs inside the one-time services.UseEntities<AppDbContext>(o => { o.UseDefaults(); /* entities */ }) setup, which also wires the default global filters, the ?q= search and the save-time interceptors. That wiring lives in the docs' quickstart — this article stays inside the filter seam.)

When the logic grows — or needs its own dependencies — it graduates to a class implementing one method:

public class VehicleFilteredQueryBuilder(IQKeywordHelper qHelper)
    : FilteredQueryBuilderBase<Vehicle, int, VehicleSearchObject>
{
    public override IQueryable<Vehicle> Build(IQueryable<Vehicle> query, VehicleSearchObject? so)
    {
        if (so?.BrandId?.Any() == true)
            query = query.Where(x => x.BrandId != null && so.BrandId.Contains(x.BrandId.Value));
        if (!string.IsNullOrWhiteSpace(so?.Brand))
        {
            // normalize the input the same way the column was normalized (see the Q section below):
            // raw input against a normalized column silently matches nothing: the normalizer
            // strips punctuation (and Fleet's config upper-cases) - "volvo-fh" would search in vain
            var brand = qHelper.ParseKeyword(so.Brand);
            query = query.Where(x => EF.Functions.Like(x.Brand!.NormalizedTitle!, brand.QW));
        }
        if (so?.HasIntervention == true)
            query = query.Where(x => x.Interventions!.Any());
        return query;
    }
}
// registration: e.AddFilter<VehicleFilteredQueryBuilder>();

Multiple registered filters compose by chaining — each receives the previous one's output, so the semantics are AND. That's the right default; OR gets a dedicated mechanism later.

The pattern's testability is easy to overlook: a query builder is a pure function from (IQueryable, SearchObject) to IQueryable. You can unit test the predicate logic against an in-memory list without a database, controller or DI container in sight.

Global filters: cross-cutting rules, registered once

Some predicates should apply to every entity of a certain shape, without any entity opting in: honor the archived/soft-delete opt-ins, scope everything to the current tenant, honor the universal Id/Ids filters. These register once, against an interface:

options.UseDefaults(); // registers the built-in set: archived, id/ids, Q search, timestamps …
options.AddGlobalFilterQueryBuilder<FilterHasTenantQueryBuilder>(); // yours: every IHasTenantId

Two provenance notes on that snippet. The hiding of archived rows is wired by UseDefaults() as an EF query filter on the DbContext — the registered FilterArchivablesQueryBuilder handles the search object's archived opt-ins on top of it, so don't expect registering a builder alone to hide anything. And FilterHasTenantQueryBuilder is not a shipped class — it's the ~5-line blueprint filter you write yourself, dissected in its own article.

Global filters run before entity filters, and because they run in the query pipeline — not in controllers — they also govern counts and direct service calls. One boundary to know: a pipeline filter is a plain Where on the root query, so it does not reach inside Include(...)'d child collections — that propagation is exactly what distinguishes the archived EF query filter above from a pipeline builder. Cross-tenant children therefore need their own guard (tenant-stamp the children too, or filter in the include lambda).

One piece of machinery here rewards understanding, because it's a security property. Global filter builders are generic over the key type, and the pipeline runs one variant per filter family — preferring the one matching the entity's search-object key. When the types don't line up (say a string-keyed entity meets the int-keyed variant), the search object coerces to null — and the builder then applies its key-agnostic default rather than stepping aside. Concretely: the archived filter can't read typed fields it doesn't know, but it still hides archived rows. A soft-delete or tenant default silently dropping on a key mismatch is the failure mode this design refuses; if you add entities with non-int keys, register the matching variants (AddDefaultGlobalQueryFilters<TKey>()) so their typed fields work too.

Free-text search: solve it at write time

Q — the search box — is where naive filtering dies. WHERE Title LIKE '%josé%' misses Jose, mixed case, doubled spaces, and anything living in a sibling column. String-mangling every column at query time can't be indexed and still misses diacritics.

The workable answer normalizes at save time. Entities that want to be searchable declare normalized columns, computed from named sources by a save interceptor — diacritics stripped, special characters and whitespace collapsed, case preserved by default and case-folded if you configure it (Fleet upper-cases via Transform = TextTransform.ToUpperCase):

[MaxLength(256), Normalized(SourceProperties = [nameof(Model), nameof(Code)])]
public string? NormalizedTitle { get; set; }

At query time, the built-in Q filter parses the search string with the same normalizer (IQKeywordHelper), producing per-keyword wildcard patterns, and applies each as a LIKE on the stored column:

var keywords = qHelper.Parse(so.Q);
foreach (var q in keywords)
    query = query.Where(x => EF.Functions.Like(x.NormalizedContent, q.QW));

Same normalizer on both sides, so "José-K 12" and Jose K meet in the middle (searching jose too, on the case-insensitive collations most databases default to — configure Transform to a fixed casing if you can't rely on that); multiple keywords AND together; and the column is a plain string that indexes like one. A parent entity can even fold children's normalized text into its own NormalizedContent — normalize phone numbers consistently and a party becomes findable by any spelling of its number.

Provider dialects leak exactly here, and the pipeline gives them a clean seam — swap the builder, not the call sites. Fleet does this at registration:

_ = dbType == DataBaseTypes.PostgreSQL
    ? e.AddFilter<VehiclePostgresLikeQueryFilter>()   // EF.Functions.ILike(...)
    : e.AddFilter<VehicleLikeQueryFilter>();          // EF.Functions.Like(...)

Sorting, includes, paging: the rest of the pipeline

Sorting is a typed enum (?sortBy=CreatedDesc&sortBy=Title — repeatable), translated by a sort builder. The one sharp edge: because the builder is invoked once per requested sort value, each arm must start or continue the ordering. The framework's OrderOrThenBy / OrderOrThenByDescending extensions do exactly that; the hand-rolled is IOrderedQueryable<T> check people write instead compiles fine and throws on the first sorted request.

e.SortBy((query, sortBy) => sortBy switch
{
    OrderSortBy.OrderNumber => query.OrderOrThenBy(x => x.OrderNumber),
    OrderSortBy.TotalAmount => query.OrderOrThenBy(x => x.TotalAmount),
    _ => query.OrderOrThenByDescending(x => x.OrderDate)
});

Includes are a flags enum the client sends (?includes=Category), so list payloads stay lean by default and fatten on request; Details(id) always applies all registered includes, on the theory that a detail view wants the whole aggregate. Two rules save real debugging time: an entity gets one Includes(...) registration — a second one replaces the first, so compose everything in one lambda — and an unexpectedly empty nested collection is a missing or wrongly-gated include, not a mapping bug, and never something to "fix" in a filter.

Paging defaults live in options — DefaultPageSize fills in when the request omits one, MaxPageSize clamps what the request asks for — and the clamp is enforced once at the HTTP boundary, so no client escapes it, while direct service-layer calls retain full control for jobs and exports.

The endpoints, and what OR looks like

Every entity controller exposes the read pipeline over a consistent surface:

Endpoint Returns Purpose
GET /{entities} { items } plain list
GET /{entities}/search { items, count } list + total for pagers
POST /{entities}/list / POST /{entities}/search same, from a body multiple SearchObjects (complex registrations)

/search exists because counting belongs with paging: the count is the total matching rows, computed server-side alongside the page. Point pagers there; deriving totals from items.length on the plain list is the classic quiet bug. (One footnote on the table: the two POST body variants exist on complex registrations — the ones declaring TSortBy/TIncludes, like the Vehicle example — while a minimal registration exposes just the two GETs.)

The POST variants answer the OR question. Within one SearchObject, filters AND — that's what predicate chaining gives you. To express "vehicles of brand 3 or with an open intervention", you post an array of SearchObjects, and the results union, deduplicated:

POST /vehicles/search
[
  { "brandId": [3] },
  { "hasIntervention": true }
]

AND within an object, OR across objects — disjunctive normal form as an API shape. It composes with paging, sorting and includes like any other search, and it falls out of the pipeline naturally: each SearchObject runs the same builders, and the queries union before paging applies.

What this buys you

Come back to the twelve-parameter controller from the introduction and tally the differences. The filter vocabulary is one record, shared by server, OpenAPI and the SPA. Translation to SQL is a set of small, pure, unit-testable builders. Cross-cutting rules — soft delete, tenancy, id filters — are registered once and provably everywhere, with key-type coercion designed so security defaults cannot silently drop. Free text is solved at write time, on a column you can index. Sorting and includes are typed enums, paging is clamped at the boundary, everything composes into one SQL statement, and OR has a principled home instead of a filterMode=any flag bolted on.

None of these pieces is exotic on its own. The compounding value is the fixed seam between them: naming filters is data modeling, translating them is query building, and composing them is the pipeline's job — so each new filter is one property and a few lines in a builder, forever.

And none of it has to stay on the page: grab the sample project — a minimal API with the Vehicle pipeline above, a seeded SQLite database, and a README of copy-paste curls for every filter in this article, the union POST included.

The pipeline has a write-side mirror — preppers, primers, related-collection syncing — and the filters above compose with recursive subtree functions for hierarchy queries, both covered in earlier deep dives: hierarchies at any depth and row-level multi-tenancy.

Docs: Regira Entities — services, web endpoints, normalizing, built-in features. The Fleet sample with the full Vehicle pipeline is on GitHub.