<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abongile Boja</title>
    <description>The latest articles on DEV Community by Abongile Boja (@abongileboja).</description>
    <link>https://dev.to/abongileboja</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3906138%2F220d675b-4153-41b4-acef-42d73be030c3.png</url>
      <title>DEV Community: Abongile Boja</title>
      <link>https://dev.to/abongileboja</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abongileboja"/>
    <language>en</language>
    <item>
      <title>I Got Tired of Hand-Rolling Expression Trees. So I Built QuerySpec.</title>
      <dc:creator>Abongile Boja</dc:creator>
      <pubDate>Thu, 30 Apr 2026 13:11:18 +0000</pubDate>
      <link>https://dev.to/abongileboja/i-got-tired-of-hand-rolling-expression-trees-so-i-built-queryspec-5b34</link>
      <guid>https://dev.to/abongileboja/i-got-tired-of-hand-rolling-expression-trees-so-i-built-queryspec-5b34</guid>
      <description>&lt;h2&gt;
  
  
  I Got Tired of Hand-Rolling Expression Trees. So I Built QuerySpec.
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The first version interpolated SQL. We caught that in code review. The second used &lt;code&gt;System.Linq.Dynamic.Core&lt;/code&gt; 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 &lt;code&gt;Expression.Lambda&lt;/code&gt; calls with a switch statement that grew every sprint.&lt;/p&gt;

&lt;p&gt;That endpoint is the reason QuerySpec exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways devs solve this, three of them bad
&lt;/h2&gt;

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

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

&lt;p&gt;&lt;strong&gt;3. In-memory filter.&lt;/strong&gt; &lt;code&gt;db.Users.ToList().Where(...)&lt;/code&gt;. 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Specifications as data
&lt;/h2&gt;

&lt;p&gt;The Specification pattern (Eric Evans, 2003; Steve Smith's &lt;a href="https://github.com/ardalis/Specification" rel="noopener noreferrer"&gt;Ardalis.Specification&lt;/a&gt; for .NET) makes one move: a spec describes a query, it is &lt;em&gt;not&lt;/em&gt; the query. You build it as data. Something else turns it into SQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AdvancedFilterExpression&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Department"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Operator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FilterOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Engineering"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;QuerySpecExpressionTranslator&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ApplyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddQuerySpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qs&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;qs&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithAuditing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogAllQueries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSecurity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableDataMasking&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithCaching&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseMemoryCache&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithResilience&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableCircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromBody&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;AdvancedFilterExpression&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FromServices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;IFilterApplicator&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsNoTracking&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The boring stuff&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;Try it&lt;br&gt;
QuerySpec just hit 5.0.0.&lt;/p&gt;

&lt;p&gt;dotnet add package QuerySpec.Core&lt;br&gt;
dotnet add package QuerySpec.EFCore&lt;br&gt;
dotnet add package QuerySpec.DependencyInjection&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/AbongileBoja/QuerySpec" rel="noopener noreferrer"&gt;https://github.com/AbongileBoja/QuerySpec&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If it saves you a 1,200-line file, ⭐ the repo.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>efcore</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
