From Query Parameters to a Composable Filter Pipeline
Almost every business application has a screen where people look for something: the vehicles due for a service, last month's orders, the customers in one region. Behind each of those screens sits a search, and searches grow the same way everywhere. It starts with one field. Then someone asks for a date range, then a status, then a free-text box, then the same filters on a second screen. Each request looks small, but each is built by hand and copied to wherever it is needed, and little of it is tested. A year in, search is the part of the application that is slowest to change and most likely to show the wrong total.
The way out is to treat "what can people search on" as a design decision rather than something that accumulates, in three moves:
- Write down, once, what can be searched. One list per kind of record — for a vehicle: its code, its model, its brand, whether it has had maintenance. What is not on the list cannot be asked for, which also makes it a security boundary.
- Say once how each item on that list is looked up. One small piece per kind of record, testable on its own, instead of the same logic repeated on every screen.
- Let the framework combine them. Any mix of filters becomes one database query, and the page of results and the total come from the same place, so the count on the screen matches the list below it.
For a project, that turns a new filter into one small, contained change, and every screen searches the same way.
The rest of this post shows the three moves in code, one at a time, and each later part of the series adds one layer on top. The worked example is Regira Entities, and everything runs against one tiny API — grab the sample project (a dotnet run, a seeded SQLite database, no other setup) and curl along.
Universal or Regira? The first two moves are plain EF Core and need no package: a typed object that names the filters, a small builder that translates them. The pipeline that combines them, the generated endpoints and the built-in filters later in the series are Regira Entities.
Run the sample
Unzip the sample and start it from its folder — the .NET 10 SDK, nothing else; the first start creates and seeds vehicles.db:
dotnet run
The port is printed at startup (Now listening on: http://localhost:…). Every curl line in this series reads it from $URL:
URL=http://localhost:5000 # the port from your startup line
The curl lines are bash; on Windows, run them in Git Bash.
To build the same in a project of your own, add the packages the sample pins:
dotnet add package Regira.Entities.Web --version 6.4.0
dotnet add package Regira.Entities.Mapping.Mapster --version 6.4.0
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12
The registration snippet below (Program.cs) needs these usings; the search object record needs Regira.Entities.Models, the builder class Regira.Entities.QueryBuilders.Abstractions and Regira.Entities.Keywords.Abstractions:
using Microsoft.EntityFrameworkCore;
using Regira.Entities.DependencyInjection.Extensions;
using Regira.Entities.Mapping.Mapster;
using Regira.Entities.Web.DependencyInjection;
Step 1 — one object that names the filters
For every entity you define one record that says what you can filter on. Here is the sample's, for a Vehicle:
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
}
Three small conventions do a lot of quiet work:
nullmeans "not filtered". Every property is nullable, and absence is meaningful. That's what lets one record serve every combination of filters — no flags, no variants.- Id filters are collections.
ICollection<int>? BrandIdbinds?brandId=1&brandId=3naturally, so multi-select is the default instead of a later retrofit. - The record binds straight from the query string.
GET /vehicles?brand=volvo&hasIntervention=trueis the API. No query DSL, no JSON language — and because it's just a parameter object, the whole filter vocabulary shows up in OpenAPI by itself.
The same record is used everywhere — controller binding, service calls (List(so), Count(so)), query builders — so "what can you filter on" has exactly one definition.
Step 2 — translate them: an inline lambda
Naming a filter is data modeling; translating it to a query is a separate job. For simple cases, a lambda at registration time is all it takes. The sample registers a Product this way:
services
.UseEntities<AppDbContext>(options =>
{
options.UseDefaults(); // built-in filters, normalizers - the next articles
options.UseMapsterMapping(); // entity <-> DTO by convention
options.DefaultPageSize = 20;
})
.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;
});
});
Try it:
curl "$URL/products?minPrice=50"
# -> Dash cam (89.90), Roof rack (129.00)
Note the shape of the lambda: it takes an IQueryable and the search object, applies a Where per present filter, and returns the query. Nothing executes here — the pipeline composes everything into one SQL statement and the database does the work.
When it grows: a query builder class
The inline lambda is fine for two conditions. When the logic grows — or needs its own dependencies — it graduates to a class with one method. The sample's vehicle filter (slightly shortened here; one branch uses text normalization, which gets its own article):
public class VehicleFilteredQueryBuilder(IQKeywordHelper qHelper)
: FilteredQueryBuilderBase<Vehicle, int, VehicleSearchObject>
{
public override IQueryable<Vehicle> Build(IQueryable<Vehicle> query, VehicleSearchObject? so)
{
if (so?.Code != null)
query = query.Where(x => x.Code == so.Code);
if (!string.IsNullOrWhiteSpace(so?.Model))
query = query.Where(x => x.Model!.Contains(so.Model));
if (so?.BrandId?.Any() == true)
query = query.Where(x => x.BrandId != null && so.BrandId.Contains(x.BrandId.Value));
if (so?.HasIntervention == true)
query = query.Where(x => x.Interventions!.Any());
return query;
}
}
// registration: e.AddFilter<VehicleFilteredQueryBuilder>();
And the payoff, straight from the query string:
curl "$URL/vehicles/search?brandId=1" # count 2: TRK-001, TRK-002 (the Volvos)
curl "$URL/vehicles?hasIntervention=true" # TRK-001, TRK-003, VAN-001
curl "$URL/vehicles?model=Vito" # VAN-002
One property of this design is easy to overlook: a query builder is a pure function from (IQueryable, SearchObject) to IQueryable. You can unit test your predicate logic against an in-memory list — no database, no controller, no DI container in sight.
Step 3 — the pipeline puts it together
Filters registered for one entity are chained: each receives the previous one's output, so the semantics are AND. That's the right default; OR gets a principled home at the end of the series. The pipeline adds paging, sends the result to the database as one query, and serves it on a consistent set of endpoints; which ones depends on the registration's shape, and the sample contains both kinds:
| Method | Route | Returns | Availability |
|---|---|---|---|
GET |
/{entities}/{id} |
one item, all includes | all registrations |
GET |
/{entities} |
{ items } |
all registrations |
GET |
/{entities}/search |
{ items, count } |
all registrations |
POST |
/{entities}/list |
{ items }, from a body |
complex only |
POST |
/{entities}/search |
{ items, count }, from a body |
complex only |
"Simple" is the Product registration above; "complex" adds a typed sort enum and a typed includes enum — the sample's Vehicle, shown in the next part. The POST variants are where OR lives, in the last part. The same registration brings the writes too (POST /{entities}, and PUT, PATCH and DELETE on /{entities}/{id}); the Writing in Regira Entities series covers them.
/search exists because counting belongs with paging: count is the total matching rows, computed server-side next to the page. Point your pagers there — deriving totals from items.length is the classic quiet bug.
Where this is going
You now have the seam the rest of the series builds on: filters are named in a record, translated in a builder, composed by the pipeline. Each next part adds one layer on the same sample: sorting, includes and paging, the rules every query obeys, archiving, free-text search and combining searches with OR.
Why not Gridify, Sieve or OData? A typed SearchObject is an allowlist by construction — a client can only ask what you named — and the full comparison with a query language is in a post of its own.
Docs: Regira Entities — services, web endpoints, built-in features; the sample runs on the free tier, without a license key.