<?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: Remigiusz Zalewski</title>
    <description>The latest articles on DEV Community by Remigiusz Zalewski (@remigiuszzalewski).</description>
    <link>https://dev.to/remigiuszzalewski</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4122305%2Fa1ef1bb1-982f-4603-b600-0c4a894fc367.jpg</url>
      <title>DEV Community: Remigiusz Zalewski</title>
      <link>https://dev.to/remigiuszzalewski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/remigiuszzalewski"/>
    <language>en</language>
    <item>
      <title>This Pattern Cleaned Up My EF Core Queries</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Fri, 16 Oct 2026 07:03:00 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/this-pattern-cleaned-up-my-ef-core-queries-4b4i</link>
      <guid>https://dev.to/remigiuszzalewski/this-pattern-cleaned-up-my-ef-core-queries-4b4i</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/D-NWdjfG_cg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Take a look at a repository interface that has been in production for a few months:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IOrderRepository&lt;/span&gt;
&lt;span class="p"&gt;{&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStalePendingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;placedBefore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CountStalePendingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;placedBefore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetShippingToAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetLargeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;minimumTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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;Four methods, four copies of the same &lt;code&gt;Where&lt;/code&gt;, &lt;code&gt;Include&lt;/code&gt;, &lt;code&gt;OrderBy&lt;/code&gt; dance - and the rule for what makes an order "stale" is already written twice, once for the list and once for the count. The day someone decides cancelled orders shouldn't count, one of them gets fixed and the other doesn't. And every new screen adds a fifth method, then a sixth.&lt;/p&gt;

&lt;p&gt;The Specification pattern fixes this. By the end, that interface has two methods - &lt;code&gt;ListAsync(spec)&lt;/code&gt; and &lt;code&gt;CountAsync(spec)&lt;/code&gt; - that never need to change again. Same endpoints, same JSON, and the same SQL going to the database. The refactoring changes &lt;em&gt;where&lt;/em&gt; the query lives, not &lt;em&gt;what&lt;/em&gt; it sends. In this video we build it from scratch on .NET 10 and EF Core 10, so you understand every line before you decide whether to pull in a library.&lt;/p&gt;

&lt;p&gt;Subscribe to my .NET Newsletter:&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://remigiuszzalewski.com/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fremigiuszzalewski.com%2Fstatic%2Fimages%2Ftwitter-card.png" height="418" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://remigiuszzalewski.com/" rel="noopener noreferrer" class="c-link"&gt;
            Remigiusz Zalewski | Become A Better .NET Software Engineer
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Blog about .NET/C#/Angular/React/Azure
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fremigiuszzalewski.com%2Fstatic%2Ffavicons%2Ffavicon-96x96.png" width="96" height="96"&gt;
          remigiuszzalewski.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;






&lt;h2&gt;
  
  
  What a specification actually is
&lt;/h2&gt;

&lt;p&gt;A specification is a small class that describes one query in business terms: which rows you want, and how to load them - the related data and the sort order. The repository gets one method that can run &lt;em&gt;any&lt;/em&gt; specification. So a new query means a new, named class, not another repository method and another change to the interface.&lt;/p&gt;

&lt;p&gt;The idea goes back to Eric Evans and Martin Fowler in the Domain-Driven Design days. The flavour most .NET teams use with EF Core - and the one the popular Ardalis.Specification library is built around - carries includes and sorting along with the filter. That's the version built in the video.&lt;/p&gt;

&lt;p&gt;The demo is an ASP.NET Core Minimal API with an &lt;code&gt;Order&lt;/code&gt; entity, its &lt;code&gt;OrderItem&lt;/code&gt;s, a status, a shipping city and a created date, seeded with 40 orders on SQL Server LocalDB. Every SQL command is logged to the console - that's the proof, at every step, that nothing changes on the database side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The base class: one filter, optional includes and sorting
&lt;/h2&gt;

&lt;p&gt;The whole pattern rests on a small abstract &lt;code&gt;Specification&amp;lt;TEntity&amp;gt;&lt;/code&gt; base class with four things on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Criteria&lt;/code&gt;&lt;/strong&gt; - the filter. It comes in through the primary constructor, so every specification &lt;em&gt;must&lt;/em&gt; have one. You can't forget it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Includes&lt;/code&gt;&lt;/strong&gt; - a read-only list of related data to load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OrderBy&lt;/code&gt; / &lt;code&gt;OrderByDescending&lt;/code&gt;&lt;/strong&gt; - an optional sort key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Includes and sorting are set through protected helpers (&lt;code&gt;AddInclude&lt;/code&gt;, &lt;code&gt;ApplyOrderBy&lt;/code&gt;, &lt;code&gt;ApplyOrderByDescending&lt;/code&gt;) that a specification calls from its own constructor, only when it needs them. Nobody outside can change them afterwards.&lt;/p&gt;

&lt;p&gt;The one detail that makes or breaks the pattern with EF Core is the type of the criteria:&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="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Criteria&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&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;Not &lt;code&gt;Func&amp;lt;TEntity, bool&amp;gt;&lt;/code&gt; - an &lt;code&gt;Expression&lt;/code&gt;. A &lt;code&gt;Func&lt;/code&gt; is compiled code, a black box. An &lt;code&gt;Expression&lt;/code&gt; is &lt;em&gt;data&lt;/em&gt;: a tree that describes the lambda ("property &lt;code&gt;Status&lt;/code&gt;, equals, &lt;code&gt;Pending&lt;/code&gt;, and property &lt;code&gt;CreatedAt&lt;/code&gt;, less than, this value"). EF Core can walk that tree and turn it into a SQL &lt;code&gt;WHERE&lt;/code&gt; clause. Getting this wrong doesn't throw - it just quietly gets slow. More on that in the pitfalls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your first specification reads like the business talks
&lt;/h2&gt;

&lt;p&gt;With the base class in place, the first query moves out of the repository and into its own class, &lt;code&gt;StalePendingOrders&lt;/code&gt;. The filter (pending, and created before a cut-off date) goes to the base constructor; the include of &lt;code&gt;Items&lt;/code&gt; and the oldest-first sort are set in the constructor body.&lt;/p&gt;

&lt;p&gt;Read it out loud: &lt;em&gt;stale pending orders, with their items, oldest first.&lt;/em&gt; That's how the business talks about it, and now that's what the code is called. It's the same three pieces that lived in &lt;code&gt;GetStalePendingAsync&lt;/code&gt; - just with a name.&lt;/p&gt;

&lt;p&gt;The endpoint then becomes a one-liner that builds the spec and hands it over:&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;stale&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;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;StalePendingOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;placedBefore&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The evaluator: write it once, reuse it in every repository
&lt;/h2&gt;

&lt;p&gt;Something has to turn a specification into an actual query. You could write that as a private method inside &lt;code&gt;OrderRepository&lt;/code&gt; - but look at what it needs: the &lt;code&gt;Criteria&lt;/code&gt;, the &lt;code&gt;Includes&lt;/code&gt;, the sort. All of that lives on &lt;code&gt;Specification&amp;lt;TEntity&amp;gt;&lt;/code&gt;. None of it is about orders. The only order-specific thing is where the query &lt;em&gt;starts&lt;/em&gt; - &lt;code&gt;dbContext.Orders&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the video puts it in a generic &lt;code&gt;SpecificationEvaluator&lt;/code&gt; instead: take any starting &lt;code&gt;IQueryable&lt;/code&gt;, add the &lt;code&gt;Where&lt;/code&gt;, add each &lt;code&gt;Include&lt;/code&gt;, then apply the sort. The day you add a &lt;code&gt;CustomerRepository&lt;/code&gt;, it uses the exact same class. It carries the same &lt;code&gt;where TEntity : class&lt;/code&gt; constraint EF Core itself puts on &lt;code&gt;DbSet&amp;lt;TEntity&amp;gt;&lt;/code&gt; and &lt;code&gt;Include&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Crucially, it returns an &lt;code&gt;IQueryable&lt;/code&gt; - nothing has hit the database yet. Each call just adds another node to the expression tree. Nothing runs until the repository calls &lt;code&gt;ToListAsync&lt;/code&gt;, which is when EF Core translates the whole tree into one SQL statement.&lt;/p&gt;

&lt;p&gt;That split is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The evaluator builds the query.&lt;/strong&gt; It's generic and shared by every repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The repository decides where the query starts and when it runs.&lt;/strong&gt; It stays specific to its entity, and it's the natural place for read-side options - pass &lt;code&gt;dbContext.Orders.AsNoTracking()&lt;/code&gt; as the starting point and the evaluator doesn't change at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Naming note:&lt;/strong&gt; in eShopOnWeb and Ardalis.Specification this class is also called a &lt;code&gt;SpecificationEvaluator&lt;/code&gt;. Those tutorials also show a &lt;code&gt;SpecificationBuilder&lt;/code&gt; - that's a different thing. It's the fluent API you use &lt;em&gt;inside&lt;/em&gt; a specification (&lt;code&gt;Query.Where(...).Include(...)&lt;/code&gt;) to replace the protected helpers. The builder &lt;em&gt;describes&lt;/em&gt; a query; the evaluator &lt;em&gt;runs&lt;/em&gt; it against EF Core.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run the endpoint and the console shows exactly what the old method sent: a &lt;code&gt;SELECT&lt;/code&gt; from &lt;code&gt;Orders&lt;/code&gt; with a &lt;code&gt;LEFT JOIN&lt;/code&gt; to &lt;code&gt;OrderItems&lt;/code&gt;, a &lt;code&gt;WHERE&lt;/code&gt; on status and date, and an &lt;code&gt;ORDER BY CreatedAt&lt;/code&gt;. EF Core also appends the order's &lt;code&gt;Id&lt;/code&gt; to the &lt;code&gt;ORDER BY&lt;/code&gt; - you never asked for it, but it needs each order's joined rows next to each other to group items back under the right &lt;code&gt;Order&lt;/code&gt; object. The old method got exactly the same thing. And &lt;code&gt;placedBefore&lt;/code&gt; shows up as a SQL parameter, not a hard-coded date, because the lambda captured it - same SQL text for any value, so SQL Server reuses the cached plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: list and count share one rule
&lt;/h2&gt;

&lt;p&gt;Now the duplicated rule. &lt;code&gt;CountAsync&lt;/code&gt; takes the very same &lt;code&gt;StalePendingOrders&lt;/code&gt; the list endpoint uses. The "what counts as stale" rule exists exactly once - change it in the class and both endpoints change together.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CountAsync&lt;/code&gt; deliberately applies &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;Criteria&lt;/code&gt;. Joining order items and sorting rows you're only going to count is wasted work, and the console confirms it: a plain &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; with the same &lt;code&gt;WHERE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Descending sorts and computed totals
&lt;/h2&gt;

&lt;p&gt;Two more specifications round out the API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OrdersShippingTo&lt;/code&gt;&lt;/strong&gt; - orders for one city, newest first. It exercises the &lt;code&gt;OrderByDescending&lt;/code&gt; branch of the evaluator, and since &lt;code&gt;city&lt;/code&gt; is captured, it goes to SQL as a parameter: no string concatenation, no SQL injection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LargeOrders&lt;/code&gt;&lt;/strong&gt; - orders worth at least a minimum total, biggest first. This is the interesting one. Both the filter and the sort are on a &lt;em&gt;computed&lt;/em&gt; total - the sum of quantity × unit price across the items. There's no &lt;code&gt;Total&lt;/code&gt; column.
&lt;/li&gt;
&lt;/ul&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="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnitPrice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimumTotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's all expressions, EF Core translates that &lt;code&gt;Sum&lt;/code&gt; into a correlated subquery and uses it in both the &lt;code&gt;WHERE&lt;/code&gt; and the &lt;code&gt;ORDER BY&lt;/code&gt;. You get a computed filter and sort without loading a single order into memory first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delete the old methods
&lt;/h2&gt;

&lt;p&gt;The satisfying part: delete &lt;code&gt;GetStalePendingAsync&lt;/code&gt;, &lt;code&gt;CountStalePendingAsync&lt;/code&gt;, &lt;code&gt;GetShippingToAsync&lt;/code&gt; and &lt;code&gt;GetLargeAsync&lt;/code&gt;. What's left:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IOrderRepository&lt;/span&gt;
&lt;span class="p"&gt;{&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Specification&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;specification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&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="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CountAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Specification&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;specification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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;The next query your team needs is a new class in the &lt;code&gt;Specifications&lt;/code&gt; folder. The interface doesn't change, the repository doesn't change, and in code review you're looking at one small, named file instead of a diff scattered across an interface and an implementation. Run all four requests again: same responses, and side by side, the same SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A specification is one named class that describes one query in business terms: the filter, the related data to load, and the sort order.&lt;/li&gt;
&lt;li&gt;A minimal &lt;code&gt;Specification&amp;lt;TEntity&amp;gt;&lt;/code&gt; base class takes &lt;code&gt;Criteria&lt;/code&gt; through its constructor and exposes &lt;code&gt;Includes&lt;/code&gt;, &lt;code&gt;OrderBy&lt;/code&gt; and &lt;code&gt;OrderByDescending&lt;/code&gt;, set via protected helpers.&lt;/li&gt;
&lt;li&gt;A generic &lt;code&gt;SpecificationEvaluator&lt;/code&gt; turns any specification into an &lt;code&gt;IQueryable&lt;/code&gt; - written once, shared by every repository. The repository only decides where the query starts and when it runs.&lt;/li&gt;
&lt;li&gt;The criteria must be &lt;code&gt;Expression&amp;lt;Func&amp;lt;TEntity, bool&amp;gt;&amp;gt;&lt;/code&gt;. A &lt;code&gt;Func&lt;/code&gt; silently turns a SQL &lt;code&gt;WHERE&lt;/code&gt; into a full table load filtered in memory.&lt;/li&gt;
&lt;li&gt;Reusing one specification for both the list and the count means a business rule like "stale pending order" lives in exactly one place - and the count applies only the criteria.&lt;/li&gt;
&lt;li&gt;The refactoring changes where the query lives, not what it sends: same JSON, same SQL, same parameterised plans.&lt;/li&gt;
&lt;li&gt;Don't use it everywhere: one-off queries are simpler as repository methods, and combinable search filters are better as chained &lt;code&gt;Where&lt;/code&gt; calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the Full Source Code
&lt;/h2&gt;

&lt;p&gt;The complete runnable solution - both the Before and After versions of the Orders API, the &lt;code&gt;Specification&amp;lt;TEntity&amp;gt;&lt;/code&gt; base class, the evaluator, all three specifications, the seeded LocalDB database and the &lt;code&gt;.http&lt;/code&gt; file to compare the SQL yourself - is available to Patreon supporters. If you'd rather run it and watch the console than rebuild it from the walkthrough above, you can find it on &lt;a href="https://www.patreon.com/remigiuszzalewski" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
    <item>
      <title>EF Core 10 Is INSANE - Overview of Game Changer features</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Sat, 03 Oct 2026 11:59:56 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/ef-core-10-is-insane-overview-of-game-changer-features-4bf9</link>
      <guid>https://dev.to/remigiuszzalewski/ef-core-10-is-insane-overview-of-game-changer-features-4bf9</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Most EF Core releases are a bag of small quality-of-life fixes. EF Core 10 has a few of those too, but it also lands several changes that remove long-standing workarounds: you no longer hand-write a &lt;code&gt;GroupJoin&lt;/code&gt; plus &lt;code&gt;SelectMany&lt;/code&gt; plus &lt;code&gt;DefaultIfEmpty&lt;/code&gt; to get a left join, you no longer have one all-or-nothing global query filter, and you can now do vector similarity search against SQL Server without a bolt-on.&lt;/p&gt;

&lt;p&gt;This video runs through the headline features against a small blog/book/author model on .NET 10 and SQL Server. Here is what each one is and when you would reach for it.&lt;/p&gt;

&lt;p&gt;🎬 Watch the full video here:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Qv1vvJ-_82c" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  LeftJoin and RightJoin as real operators
&lt;/h2&gt;

&lt;p&gt;Before EF Core 10, a left join in LINQ-to-Entities was the incantation everyone had to look up: &lt;code&gt;GroupJoin&lt;/code&gt;, then &lt;code&gt;SelectMany&lt;/code&gt;, then &lt;code&gt;DefaultIfEmpty&lt;/code&gt;. It worked, it translated fine, and nobody remembered the shape.&lt;/p&gt;

&lt;p&gt;EF Core 10 adds &lt;code&gt;LeftJoin&lt;/code&gt; and &lt;code&gt;RightJoin&lt;/code&gt; as first-class query operators:&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authors&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LeftJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Books&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;author&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="n"&gt;book&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&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 generated SQL as the old pattern, a fraction of the code, and it reads like the join you meant. This is the change you will use most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Named query filters
&lt;/h2&gt;

&lt;p&gt;Global query filters (soft delete, multi-tenancy, "only verified rows") have always had one painful limitation: one filter per entity, and &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt; turned off &lt;em&gt;all&lt;/em&gt; of them. If you had soft-delete and tenant filtering on the same entity, you could not disable one without disabling the other.&lt;/p&gt;

&lt;p&gt;EF Core 10 lets you &lt;strong&gt;name&lt;/strong&gt; filters and toggle them individually:&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;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasQueryFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SoftDeletionFilter"&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="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasQueryFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"VerifiedFilter"&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="n"&gt;IsVerified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// disable just one, keep the rest&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authors&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IgnoreQueryFilters&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"SoftDeletionFilter"&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;An admin screen that needs to see soft-deleted rows but must still respect tenant isolation is now a one-liner instead of a manual re-implementation of the other filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complex types mapped to JSON
&lt;/h2&gt;

&lt;p&gt;EF Core 10 lets a complex type be stored as a JSON document in a column:&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;modelBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ComplexProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToJson&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BookAttributes&lt;/code&gt; (genre, page count, language, a &lt;code&gt;string[]&lt;/code&gt; of tags) lives in one &lt;code&gt;nvarchar&lt;/code&gt; column as JSON, and you can still query into it in LINQ:&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Books&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pages&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;300&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;EF Core translates the predicate to SQL JSON path access. This is the pragmatic middle ground between a rigid column-per-field schema and an untyped blob - useful for attribute bags that vary and are not worth their own table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vector search for RAG, built in
&lt;/h2&gt;

&lt;p&gt;This is the flashiest one. SQL Server 2025 has a native &lt;code&gt;vector&lt;/code&gt; type, and EF Core 10 can map to it and translate distance functions:&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;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypeName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"vector(1536)"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;SqlVector&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Vector&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BlogPosts&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VectorDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cosine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queryVector&lt;/span&gt;&lt;span class="p"&gt;))&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;3&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;Combined with &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; to generate embeddings (the demo uses an Azure OpenAI embedding model), you get the retrieval half of a RAG pipeline - "find the three most semantically similar posts to this text" - as an ordinary EF Core query, with no separate vector database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; continues to expand what it can express, including updates driven by other columns, so more bulk updates avoid loading entities.&lt;/li&gt;
&lt;li&gt;Various LINQ translation gaps closed, and better SQL for existing queries - the kind of thing you get for free by upgrading.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector search needs the right SQL Server.&lt;/strong&gt; The native &lt;code&gt;vector&lt;/code&gt; type is SQL Server 2025 / recent Azure SQL. Older targets do not have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named filters are opt-in by name.&lt;/strong&gt; If you migrate existing single filters, give them names and update every &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt; call site, or behavior shifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-mapped complex types query well but index differently.&lt;/strong&gt; Filtering &lt;code&gt;b.Attributes.Pages &amp;gt; 300&lt;/code&gt; works, but performance depends on JSON indexing support in your database - do not assume it is free on a large table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LeftJoin&lt;/code&gt; result selectors can yield nulls.&lt;/strong&gt; The right-side entity is null when there is no match; project defensively.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LeftJoin&lt;/code&gt; / &lt;code&gt;RightJoin&lt;/code&gt; are now first-class operators - retire the &lt;code&gt;GroupJoin&lt;/code&gt; + &lt;code&gt;SelectMany&lt;/code&gt; + &lt;code&gt;DefaultIfEmpty&lt;/code&gt; pattern.&lt;/li&gt;
&lt;li&gt;Query filters can be named and disabled individually with &lt;code&gt;IgnoreQueryFilters(["Name"])&lt;/code&gt;, fixing the all-or-nothing problem for soft delete plus multi-tenancy.&lt;/li&gt;
&lt;li&gt;Complex types can be stored as JSON in a single column and still be queried in LINQ.&lt;/li&gt;
&lt;li&gt;SQL Server's native vector type plus &lt;code&gt;EF.Functions.VectorDistance&lt;/code&gt; gives you similarity search - the retrieval side of RAG - without a dedicated vector store.&lt;/li&gt;
&lt;li&gt;Upgrading also brings a batch of translation improvements you do not have to ask for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the Full Source Code
&lt;/h2&gt;

&lt;p&gt;The complete runnable solution - the blog/book/author model, the named query filters, the JSON complex type, and the vector-search endpoints wired to an embedding generator - is available to Patreon supporters. If you want to run the RAG query against real embeddings instead of rebuilding it from the walkthrough above, you can find it on &lt;a href="https://www.patreon.com/remigiuszzalewski" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew" rel="noopener noreferrer"&gt;What's new in EF Core 10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/ef/core/querying/filters" rel="noopener noreferrer"&gt;Global query filters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/ef/core/providers/sql-server/vector-search" rel="noopener noreferrer"&gt;Vector search in EF Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai" rel="noopener noreferrer"&gt;Microsoft.Extensions.AI&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspdotnet</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Api Key Authentication in ASP .NET Core</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:08:34 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/api-key-authentication-in-asp-net-core-2gng</link>
      <guid>https://dev.to/remigiuszzalewski/api-key-authentication-in-asp-net-core-2gng</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Not every API needs users, tokens, and a login flow. An internal service that another service calls, a webhook receiver, a partner integration - these often just need "prove you are an allowed caller", and an API key in a header does that with almost no ceremony. The client sends a secret string, the server checks it, done.&lt;/p&gt;

&lt;p&gt;This video implements API key authentication in ASP.NET Core on .NET 9 three different ways, because where you put the check matters: middleware for the whole app, an MVC authorization filter for controllers, and an endpoint filter for minimal API routes. All three read the header name and expected key from configuration through the Options pattern.&lt;/p&gt;

&lt;p&gt;🎬 Watch the full video here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtu.be/RPd1md_9oRM" rel="noopener noreferrer"&gt;https://youtu.be/RPd1md_9oRM&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the key and header name live
&lt;/h2&gt;

&lt;p&gt;Both values come from &lt;code&gt;appsettings.json&lt;/code&gt;, bound to a typed options class rather than read as loose strings:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyOptions&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AuthenticationApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Authentication:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;HeaderName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&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;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="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiKeyOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiKeyOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationApiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every enforcement approach below injects &lt;code&gt;IOptions&amp;lt;ApiKeyOptions&amp;gt;&lt;/code&gt;. In production the actual key value belongs in a secret store (user secrets locally, Key Vault or environment variables in the cloud), never committed in &lt;code&gt;appsettings.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: middleware
&lt;/h2&gt;

&lt;p&gt;Middleware runs for every request in the pipeline. It reads the configured header, compares it to the configured key, and short-circuits with &lt;code&gt;401&lt;/code&gt; if it does not match:&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HeaderName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;extractedApiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;extractedApiKey&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status401Unauthorized&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Api Key is invalid"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use middleware when the &lt;strong&gt;entire API&lt;/strong&gt; is key-protected and there are no public endpoints. It is the bluntest instrument - there is no per-route opt-out without adding path checks inside it, which gets ugly fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: MVC authorization filter
&lt;/h2&gt;

&lt;p&gt;For controllers, an &lt;code&gt;IAuthorizationFilter&lt;/code&gt; runs as part of MVC's filter pipeline and can be applied exactly where you want it:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyAuthFilter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAuthorizationFilter&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;void&lt;/span&gt; &lt;span class="nf"&gt;OnAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationFilterContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* header missing or wrong */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UnauthorizedObjectResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Api Key is invalid"&lt;/span&gt;&lt;span class="p"&gt;);&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;Because it has a constructor dependency (&lt;code&gt;IOptions&amp;lt;ApiKeyOptions&amp;gt;&lt;/code&gt;), register it in DI and apply it with &lt;code&gt;[ServiceFilter(typeof(ApiKeyAuthFilter))]&lt;/code&gt; on a controller or action. Now &lt;code&gt;[AllowAnonymous]&lt;/code&gt;-style selectivity is trivial: put the attribute on the controllers that need it and leave the rest open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: minimal API endpoint filter
&lt;/h2&gt;

&lt;p&gt;Minimal APIs have their own filter abstraction, &lt;code&gt;IEndpointFilter&lt;/code&gt;, added per route or per route group:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/weatherforecastminimal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiKeyEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter's &lt;code&gt;InvokeAsync&lt;/code&gt; checks the header and either writes a &lt;code&gt;401&lt;/code&gt; or calls &lt;code&gt;next(context)&lt;/code&gt; to continue. Apply it to a &lt;code&gt;MapGroup&lt;/code&gt; and every route in that group is protected in one line. This is the idiomatic choice for a minimal API codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real gotcha in the demo
&lt;/h2&gt;

&lt;p&gt;The endpoint filter in the video checks the key, writes the &lt;code&gt;401&lt;/code&gt; body on failure, but then still calls &lt;code&gt;await next(context)&lt;/code&gt;. To actually short-circuit, it needs to &lt;code&gt;return&lt;/code&gt; immediately after writing the unauthorized response instead of falling through to the next delegate. It is a one-word fix and a good reminder that with filters, &lt;em&gt;not&lt;/em&gt; calling &lt;code&gt;next&lt;/code&gt; is how you stop the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constant-time comparison
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;extractedApiKey != _options.Key&lt;/code&gt; is a normal string comparison, which can leak timing information to an attacker probing the key byte by byte. For a real deployment, compare with a fixed-time method (&lt;code&gt;CryptographicOperations.FixedTimeEquals&lt;/code&gt; over the UTF-8 bytes) so every comparison takes the same time regardless of how many characters match.&lt;/p&gt;

&lt;h2&gt;
  
  
  When API keys are the wrong tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need to know &lt;em&gt;which user&lt;/em&gt; is acting, not just which application. Use real user auth.&lt;/li&gt;
&lt;li&gt;Keys are going to end up in browser code. A key in client-side JavaScript is public - anyone can read it.&lt;/li&gt;
&lt;li&gt;You need granular, revocable, per-scope permissions. That is OAuth's job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;API keys shine for trusted server-to-server calls where the client can keep a secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Committing the key.&lt;/strong&gt; Treat it like a password: secret store, not source control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No revocation story.&lt;/strong&gt; A single hardcoded key means a leak forces a redeploy. Real systems store hashed keys in a database so individual keys can be rotated and revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to short-circuit.&lt;/strong&gt; Writing a &lt;code&gt;401&lt;/code&gt; body but still invoking the rest of the pipeline runs the endpoint anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;==&lt;/code&gt; comparison.&lt;/strong&gt; Use a fixed-time comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing approaches.&lt;/strong&gt; Pick one enforcement point for a given surface; stacking middleware and a filter doing the same check is just confusing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;API key auth is a good fit for server-to-server APIs where OAuth is overkill and you only need to identify the calling application.&lt;/li&gt;
&lt;li&gt;Bind the header name and key to a typed &lt;code&gt;ApiKeyOptions&lt;/code&gt; via the Options pattern; keep the real value in a secret store.&lt;/li&gt;
&lt;li&gt;Middleware protects the whole app; an MVC &lt;code&gt;IAuthorizationFilter&lt;/code&gt; (via &lt;code&gt;[ServiceFilter]&lt;/code&gt;) protects chosen controllers; an &lt;code&gt;IEndpointFilter&lt;/code&gt; protects chosen minimal API routes or groups.&lt;/li&gt;
&lt;li&gt;To short-circuit a filter, write the response and &lt;code&gt;return&lt;/code&gt; - do not call &lt;code&gt;next&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compare keys in constant time and design for rotation and revocation from the start.&lt;/li&gt;
&lt;li&gt;Never ship an API key in client-side code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the Full Source Code
&lt;/h2&gt;

&lt;p&gt;The complete runnable solution - all three enforcement approaches, the options binding, and both controller and minimal API endpoints to test against - is available to Patreon supporters. If you want to try each approach with a real header instead of rebuilding it from the walkthrough above, you can find it on &lt;a href="https://www.patreon.com/remigiuszzalewski" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://remigiuszzalewski.com/blog/api-key-authentication-aspnet-core" rel="noopener noreferrer"&gt;remigiuszzalewski.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
