DEV Community

Abongile Boja
Abongile Boja

Posted on

I Got Tired of Hand-Rolling Expression Trees. So I Built QuerySpec.

I Got Tired of Hand-Rolling Expression Trees. So I Built QuerySpec.

Two years ago I shipped an endpoint that took a JSON filter from the client and ran it against EF Core. Every CRUD app eventually grows one. The product team called it "advanced search". I called it the part of the codebase I was scared to touch.

The first version interpolated SQL. We caught that in code review. The second used System.Linq.Dynamic.Core and parsed the input as a C# expression — beautiful, until I realised I'd handed the world the keys to the database. The third was 600 lines of hand-written Expression.Lambda calls with a switch statement that grew every sprint.

That endpoint is the reason QuerySpec exists.

The four ways devs solve this, three of them bad

1. String interpolation into raw SQL. I have personally code-reviewed $"WHERE {col} = '{val}'" in three different codebases. It's always there because someone said "we'll fix it later".

2. System.Linq.Dynamic.Core. Cute. Also accepts method calls, reflection, typeof(...). The library has a sandbox now — but the people who picked it up in 2017 didn't know they needed one.

3. In-memory filter. db.Users.ToList().Where(...). Works fine until the table has 50k rows, at which point it becomes the reason your dashboard is slow. I have shipped this. I am not proud.

4. Build the expression tree by hand. Right answer. Becomes 1,200 lines of Expression.Parameter / Expression.Property calls across 50 operators × every entity, with subtle null-handling bugs that fire on SQL Server but not SQLite.

Specifications as data

The Specification pattern (Eric Evans, 2003; Steve Smith's Ardalis.Specification for .NET) makes one move: a spec describes a query, it is not the query. You build it as data. Something else turns it into SQL.

var filter = new AdvancedFilterExpression
{
    Field = "Department",
    Operator = FilterOperator.Equal,
    Value = "Engineering"
};

var users = await QuerySpecExpressionTranslator
    .ApplyFilter(dbContext.Users, filter)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Same parameterised SQL you'd have written by hand. Except filter came from System.Text.Json, and you got there without a parser, without a sandbox, and without a 1,200-line file.

Why not just Ardalis.Specification?
Honest answer: I love what Ardalis got right. If your filters are written in C# at compile time, use it. I do.

QuerySpec is for the case Ardalis isn't built for: the filter is data, not code. It came in over the wire. Three things QuerySpec adds:

Plain-data filter input — AdvancedFilterExpression is a POCO. Deserialise from a request body, compose from query strings, load from a saved view. You can't do that with a typed Specification.
Cross-cutting concerns in the box — auditing, column masking, row-level security, in-memory + Redis caching, retry / circuit breaker / rate limiting, OpenTelemetry counters. Opt-in, register only what you wire up:

builder.Services.AddQuerySpec(qs => qs
    .WithAuditing(a => a.LogAllQueries())
    .WithSecurity(s => s.EnableDataMasking())
    .WithCaching(c => c.UseMemoryCache())
    .WithResilience(r => r.EnableCircuitBreaker()));
Enter fullscreen mode Exit fullscreen mode

Compiled-expression cache — keyed on spec shape, not values. Same predicate hit twice? Compiled once. Hot endpoints feel it.
What it looks like in production

[HttpPost("search")]
public async Task<IActionResult> Search(
    [FromBody] AdvancedFilterExpression filter,
    [FromServices] IFilterApplicator<User> filters)
{
    var query = filters.Apply(_db.Users.AsNoTracking(), filter);
    return Ok(await query.OrderBy(u => u.Id).Take(100).ToListAsync());
}
Enter fullscreen mode Exit fullscreen mode

Eight lines. JSON in, parameterised SQL out, no eval, no manual expression trees. The auditing / masking / RLS / metrics interceptors hang off IFilterApplicator — none of that is in the controller, none of it has to be. 50+ operators ship out of the box; tested against EF Core In-Memory, SQLite, SQL Server, and PostgreSQL via Testcontainers in CI.

The boring stuff
Strong-named. Multi-targets net8.0 / net9.0 / net10.0. AOT and trim friendly. SourceLink + .snupkg symbols. SBOM in every package, SLSA provenance, deterministic builds, OIDC publish to NuGet. If "supply chain" is a phrase your security team says, the answers are in the package.

Try it
QuerySpec just hit 5.0.0.

dotnet add package QuerySpec.Core
dotnet add package QuerySpec.EFCore
dotnet add package QuerySpec.DependencyInjection

Repo: https://github.com/AbongileBoja/QuerySpec

If you've got a filter endpoint you're scared to touch — show me. There are versions of this problem I haven't seen, and the next operator probably comes from someone else's domain.

If it saves you a 1,200-line file, ⭐ the repo.

Top comments (0)