<?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>Stop Using HasData for All Your Data Seeding in .NET</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Tue, 10 Nov 2026 16:30:09 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/stop-using-hasdata-for-all-your-data-seeding-in-net-21hn</link>
      <guid>https://dev.to/remigiuszzalewski/stop-using-hasdata-for-all-your-data-seeding-in-net-21hn</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A fresh database is useless until something fills it. EF Core gives you three different ways to do that, and most projects pick one and force everything through it. That is how you end up with fake data baked into a migration, or a production table that never got its rows.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating seed data as one thing. In this post I build a small .NET 10 expense tracker on EF Core 10 and SQL Server that uses all three approaches side by side: &lt;code&gt;HasData&lt;/code&gt; for fixed reference data, &lt;code&gt;UseSeeding&lt;/code&gt; / &lt;code&gt;UseAsyncSeeding&lt;/code&gt; for default data the app needs, and a custom seeder class with Bogus for local fake data. One fresh database fills itself with all three, and running the app twice doesn't duplicate a single row.&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/NkNEjLCETwc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  Three kinds of seed data, three tools
&lt;/h2&gt;

&lt;p&gt;Before any code, sort your data into one of these buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed reference data&lt;/strong&gt; - currency codes, country lists. The same in every environment, and it only changes when you decide to change it. Use &lt;code&gt;HasData&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default data the app needs to work, which can grow&lt;/strong&gt; - the starting categories in an expense tracker. Users add their own next to them, but the defaults must always be there. Use &lt;code&gt;UseSeeding&lt;/code&gt; and &lt;code&gt;UseAsyncSeeding&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake data for local development&lt;/strong&gt; - so you aren't staring at empty screens. Use your own seeder class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing worth knowing: the Microsoft docs now call &lt;code&gt;HasData&lt;/code&gt; "model managed data" and recommend &lt;code&gt;UseSeeding&lt;/code&gt; as the default way to seed. &lt;code&gt;HasData&lt;/code&gt; isn't deprecated, but it's no longer the general answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo app
&lt;/h2&gt;

&lt;p&gt;The app is a .NET 10 Minimal API with three entities - &lt;code&gt;Currency&lt;/code&gt;, &lt;code&gt;Category&lt;/code&gt; and &lt;code&gt;Expense&lt;/code&gt; - and an &lt;code&gt;AppDbContext&lt;/code&gt; registered with the SQL Server provider (&lt;code&gt;Microsoft.EntityFrameworkCore.SqlServer&lt;/code&gt;, plus &lt;code&gt;Microsoft.EntityFrameworkCore.Design&lt;/code&gt; for the tooling):&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;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DbContextOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDbContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DbContext&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;DbSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Currencies&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;DbSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Category&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Categories&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Category&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;DbSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Expenses&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="c1"&gt;// ...&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;Currency&lt;/code&gt; uses its three-letter &lt;code&gt;Code&lt;/code&gt; as the primary key, &lt;code&gt;Category&lt;/code&gt; has an &lt;code&gt;Id&lt;/code&gt; and a unique &lt;code&gt;Name&lt;/code&gt;, and every &lt;code&gt;Expense&lt;/code&gt; points to one of each. In development, &lt;code&gt;Program.cs&lt;/code&gt; creates the database and applies pending migrations on startup:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IsDevelopment&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;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CreateAsyncScope&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;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetRequiredService&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDbContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MigrateAsync&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;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine locally. For applying migrations in a real deployment, see &lt;a href="https://remigiuszzalewski.com/blog/ef-core-migration-bundles-cicd-azure" rel="noopener noreferrer"&gt;EF Core migration bundles in CI/CD&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: HasData for fixed reference data
&lt;/h2&gt;

&lt;p&gt;Currencies are the textbook case: purely static, and they don't depend on anything you'd have to read from the database first. They go straight into &lt;code&gt;OnModelCreating&lt;/code&gt;:&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="nc"&gt;Entity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&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="nc"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;

    &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"US Dollar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"$"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Euro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"€"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"British Pound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"£"&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;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;Notice that every row sets the primary key explicitly. Even when the database would generate keys for you, &lt;code&gt;HasData&lt;/code&gt; needs them: the model snapshot has to identify each row to work out later whether it was added, changed or removed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HasData&lt;/code&gt; only does anything through migrations, so the next step is to create one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool install --global dotnet-ef
dotnet ef migrations add SeedCurrencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated migration turns the &lt;code&gt;HasData&lt;/code&gt; call into an &lt;code&gt;InsertData&lt;/code&gt; call, and &lt;code&gt;Down&lt;/code&gt; deletes the same rows again by key:&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;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nc"&gt;Up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MigrationBuilder&lt;/span&gt; &lt;span class="n"&gt;migrationBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;migrationBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InsertData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Currencies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&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;span class="s2"&gt;"Code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Symbol"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="k"&gt;object&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;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Euro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"€"&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;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"British Pound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"£"&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;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"US Dollar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"$"&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;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;Now say you want to rename "British Pound" to just "Pound". You change the value in &lt;code&gt;OnModelCreating&lt;/code&gt;:&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;new&lt;/span&gt; &lt;span class="nc"&gt;Currency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Pound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"£"&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;And you need another migration, because everything about &lt;code&gt;HasData&lt;/code&gt; goes through migrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet ef migrations add UpdatedCurrencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF Core compares the model with the snapshot and generates an update for exactly that one value:&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;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nc"&gt;Up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MigrationBuilder&lt;/span&gt; &lt;span class="n"&gt;migrationBuilder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;migrationBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UpdateData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Currencies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;keyColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;keyValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Name"&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="s2"&gt;"Pound"&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;&lt;code&gt;Down&lt;/code&gt; does the reverse and sets the name back to "British Pound". This is where the explicit primary key pays off: &lt;code&gt;keyValue: "GBP"&lt;/code&gt; is how EF Core knows which row to update. After &lt;code&gt;MigrateAsync&lt;/code&gt; runs, the &lt;code&gt;__EFMigrationsHistory&lt;/code&gt; table lists the applied migrations and the &lt;code&gt;Currencies&lt;/code&gt; table has the latest values.&lt;/p&gt;

&lt;p&gt;That is the trade-off: data changes are versioned and reviewable, but every edit costs a migration. Fine for currencies, painful for anything that changes often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: UseSeeding for default data that can grow
&lt;/h2&gt;

&lt;p&gt;Categories are different. The app should always start with Food, Transport, Housing and Entertainment, but users can add their own through the API. That's exactly what &lt;code&gt;UseSeeding&lt;/code&gt; was added for.&lt;/p&gt;

&lt;p&gt;The logic lives in a static &lt;code&gt;CategorySeeder&lt;/code&gt; with a synchronous and an asynchronous method:&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;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CategorySeeder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;Defaults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Food"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Transport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Housing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Entertainment"&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="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nc"&gt;Seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DbContext&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="c1"&gt;// same logic, synchronous calls&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;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="nc"&gt;SeedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DbContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Category&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&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="nc"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToHashSetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Defaults&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Contains&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="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Category&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&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;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="nc"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parameter is the base &lt;code&gt;DbContext&lt;/code&gt;, not &lt;code&gt;AppDbContext&lt;/code&gt;. That's what the seeding delegates hand you, so the seeder gets the set with &lt;code&gt;context.Set&amp;lt;Category&amp;gt;()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It loads only the existing names into a &lt;code&gt;HashSet&lt;/code&gt; and inserts the defaults that are missing. That makes it safe to run on every startup and every deployment.&lt;/li&gt;
&lt;li&gt;Categories added by users are never touched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then wire both methods into the &lt;code&gt;DbContext&lt;/code&gt; options in &lt;code&gt;Program.cs&lt;/code&gt;:&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="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddDbContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDbContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UseSqlServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Default"&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;span class="nc"&gt;UseSeeding&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;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CategorySeeder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Seed&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="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UseAsyncSeeding&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CategorySeeder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SeedAsync&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;cancellationToken&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;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why you need both
&lt;/h3&gt;

&lt;p&gt;Which delegate runs depends on who triggers the seeding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UseSeeding&lt;/code&gt;&lt;/strong&gt; runs for &lt;code&gt;dotnet ef database update&lt;/code&gt;, &lt;code&gt;Migrate()&lt;/code&gt;, &lt;code&gt;EnsureCreated()&lt;/code&gt; and migration bundles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UseAsyncSeeding&lt;/code&gt;&lt;/strong&gt; runs for &lt;code&gt;MigrateAsync()&lt;/code&gt; and &lt;code&gt;EnsureCreatedAsync()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same job, two entry points, so implement both.&lt;/p&gt;

&lt;p&gt;In the demo, the first run with &lt;code&gt;MigrateAsync&lt;/code&gt; hits &lt;code&gt;SeedAsync&lt;/code&gt; with zero existing categories and inserts all four - with no migration created for them. Switching to &lt;code&gt;Migrate()&lt;/code&gt; hits the synchronous &lt;code&gt;Seed&lt;/code&gt; instead, which finds the four categories already there and adds nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: A custom seeder class for fake data
&lt;/h2&gt;

&lt;p&gt;For fake development data, &lt;code&gt;UseSeeding&lt;/code&gt; is the wrong tool. Generating realistic data needs more than a &lt;code&gt;DbContext&lt;/code&gt; - configuration, logging, maybe other services - and it should only ever run in development. So this one gets its own class, using the &lt;a href="https://github.com/bchavez/Bogus" rel="noopener noreferrer"&gt;Bogus&lt;/a&gt; library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Bogus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number of expenses comes from configuration through a small options class in the &lt;code&gt;Options&lt;/code&gt; folder:&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;SeedOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nc"&gt;SectionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Seed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nc"&gt;ExpenseCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&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;With a matching section in &lt;code&gt;appsettings.Development.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"Seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"ExpenseCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&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;And the registration in &lt;code&gt;Program.cs&lt;/code&gt;:&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="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Configure&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SeedOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SeedOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SectionName&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;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddScoped&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DevDataSeeder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;The seeder itself uses constructor injection, so it can take whatever it needs:&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;DevDataSeeder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;AppDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;IOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SeedOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ILogger&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DevDataSeeder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&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="k"&gt;async&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="nc"&gt;SeedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="o"&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;span class="k"&gt;if&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Expenses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AnyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&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;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Expenses already exist, skipping dev seed"&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;currencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Currencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;faker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Faker&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="nc"&gt;UseSeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Commerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ProductName&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;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Finance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&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;span class="nc"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndexFaker&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Count&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;span class="nc"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;currencies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndexFaker&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;currencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Count&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;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Expenses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Generate&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="nc"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExpenseCount&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&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;What matters here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The guard at the top.&lt;/strong&gt; If any expenses exist, it does nothing, so a second run adds no rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UseSeed(42)&lt;/code&gt;&lt;/strong&gt; makes Bogus generate the same data every time, so everyone on the team gets identical rows on their machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real foreign keys.&lt;/strong&gt; Categories and currencies are read from the database - the ones the first two approaches just put there - and &lt;code&gt;IndexFaker&lt;/code&gt; spreads expenses across them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally, call it right after migrating, inside the same development-only block:&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IsDevelopment&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;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CreateAsyncScope&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;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetRequiredService&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDbContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MigrateAsync&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;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;seeder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetRequiredService&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DevDataSeeder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seeder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SeedAsync&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first run the database gets 25 realistic-looking expenses. On the next run the guard kicks in and nothing is added.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data seeding pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic values in &lt;code&gt;HasData&lt;/code&gt;&lt;/strong&gt; - put &lt;code&gt;DateTime.UtcNow&lt;/code&gt; or &lt;code&gt;Guid.NewGuid()&lt;/code&gt; in there and the model is different every time it's built. &lt;code&gt;dotnet ef database update&lt;/code&gt; then fails with a pending model changes error. Use fixed values, or move that data to &lt;code&gt;UseSeeding&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wiring only &lt;code&gt;UseAsyncSeeding&lt;/code&gt;&lt;/strong&gt; - your app is async, so it runs fine. Then &lt;code&gt;dotnet ef database update&lt;/code&gt; applies the migrations and fails because no synchronous seed delegate was provided, leaving the tables created but empty. Always wire both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploying with SQL scripts&lt;/strong&gt; - this one hurts in production. In the output of &lt;code&gt;dotnet ef migrations script&lt;/code&gt;, the currency inserts are there because they live in migrations. The categories aren't, because &lt;code&gt;UseSeeding&lt;/code&gt; is code, not SQL, and nothing runs it. If you need seeding in your pipeline, use a migration bundle (which does call &lt;code&gt;UseSeeding&lt;/code&gt;) or keep that data in migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seeding after a downgrade&lt;/strong&gt; - seeding also runs after &lt;code&gt;dotnet ef database update 0&lt;/code&gt;, and the seeder fails with "Invalid object name" because the &lt;code&gt;Categories&lt;/code&gt; table is already gone. If you downgrade like this, check that the table exists first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in seed code&lt;/strong&gt; - seed code goes into source control and &lt;code&gt;HasData&lt;/code&gt; values end up in your migrations too. If you need an admin account, read the password from configuration, never from a string in code.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Seed data isn't one thing: fixed reference data, default data that can grow and fake development data each need a different tool.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HasData&lt;/code&gt; fits fixed reference data that's identical everywhere - set primary keys explicitly and accept one migration per change.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UseSeeding&lt;/code&gt; and &lt;code&gt;UseAsyncSeeding&lt;/code&gt; fit default data the app needs; wire both and make the seeder idempotent so it's safe on every deployment.&lt;/li&gt;
&lt;li&gt;Fake data or anything that needs real services belongs in your own seeder class, kept behind a development check.&lt;/li&gt;
&lt;li&gt;If you're not sure, start with &lt;code&gt;UseSeeding&lt;/code&gt; - it's what Microsoft recommends and the right default.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The complete solution - the .NET 10 expense tracker API with the &lt;code&gt;AppDbContext&lt;/code&gt;, the &lt;code&gt;HasData&lt;/code&gt; currency migrations, the &lt;code&gt;CategorySeeder&lt;/code&gt; wired through &lt;code&gt;UseSeeding&lt;/code&gt; and &lt;code&gt;UseAsyncSeeding&lt;/code&gt;, and the Bogus-based &lt;code&gt;DevDataSeeder&lt;/code&gt; with its options - is available to Patreon supporters 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/modeling/data-seeding" rel="noopener noreferrer"&gt;Data seeding (EF Core docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying" rel="noopener noreferrer"&gt;Applying migrations (EF Core docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/ef/core/cli/dotnet" rel="noopener noreferrer"&gt;EF Core tools reference (.NET CLI)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options" rel="noopener noreferrer"&gt;Options pattern in ASP.NET Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bchavez/Bogus" rel="noopener noreferrer"&gt;Bogus on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>entityframework</category>
      <category>efcore</category>
    </item>
    <item>
      <title>Claude Code Free Setup: OmniRoute + Free LLM APIs (2026)</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:03:49 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/claude-code-free-setup-omniroute-free-llm-apis-2026-40dp</link>
      <guid>https://dev.to/remigiuszzalewski/claude-code-free-setup-omniroute-free-llm-apis-2026-40dp</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Lots of AI providers give you a free tier, but each one alone is too small for a coding agent. A few requests, you hit the rate limit, and Claude Code stops. In this post you set up OmniRoute as a central hub for free LLMs on your own machine, connect OpenRouter, Nvidia, Mistral and Groq to it, and point Claude Code at the result so that a session costs nothing.&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/1GWGCjrgppI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  Why OmniRoute
&lt;/h2&gt;

&lt;p&gt;OmniRoute is an open-source AI gateway under the MIT license, and it runs on your own PC. You connect your providers once, and it gives every tool one endpoint and one API key.&lt;/p&gt;

&lt;p&gt;Two features make it work for Claude Code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Combos&lt;/strong&gt; - a combo is a set of models you design yourself. If one model is out of quota or failing, OmniRoute moves on to the next one in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API format translation&lt;/strong&gt; - Claude Code talks to OmniRoute in the Anthropic format, even when the model behind it is something completely different.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You need two things before setting up OmniRoute. The first is Node.js, which you can check from the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that doesn't print a version, download the installer for your operating system from &lt;a href="https://nodejs.org" rel="noopener noreferrer"&gt;nodejs.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second is Claude Code itself. If you don't have it yet, use the install command from the &lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;Claude Code setup page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and run OmniRoute
&lt;/h2&gt;

&lt;p&gt;OmniRoute is an npm package, so installing and starting it takes two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; omniroute
omniroute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirms that OmniRoute is running and shows the API base URL and the dashboard address. Open the dashboard and sign in. The default password is &lt;code&gt;CHANGEME&lt;/code&gt;, which I left in place for this local demo. To set your own, run this and confirm the new password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;omniroute reset-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The homepage has a quick start guide, a view of your connected providers with their status, and your recent requests with the model, the input and output tokens, and when each one happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing free providers
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Providers&lt;/strong&gt; in the left-hand menu. There are about 370 AI providers listed, so turn on the free tier filter to avoid models you would pay for.&lt;/p&gt;

&lt;p&gt;Based on my experience, these are the ones I can recommend: Antigravity CLI, Cerebras, Groq, Kiro AI, LLM7.io, Mistral, Nvidia and OpenRouter. In this post I connect four of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect OpenRouter
&lt;/h2&gt;

&lt;p&gt;OpenRouter goes first because it's an aggregator: one API key gives you access to more than one AI provider at once.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register at &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;openrouter.ai&lt;/a&gt;, open the API keys tab and click &lt;strong&gt;New key&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Name the key &lt;code&gt;omniroute&lt;/code&gt;, then choose an expiration and a credit limit. I used no expiration for the demo and a $50 limit, which never gets touched because only free models are used.&lt;/li&gt;
&lt;li&gt;In the OpenRouter section of OmniRoute, click &lt;strong&gt;Add connection&lt;/strong&gt;, paste the key and check that it's valid.&lt;/li&gt;
&lt;li&gt;Name the connection, use the option to import only free models, save and enable the connection.&lt;/li&gt;
&lt;li&gt;Import the models. I got 17.&lt;/li&gt;
&lt;li&gt;Run &lt;strong&gt;Test all models&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last step is the one that matters. An imported model isn't necessarily a working one, and only models with an OK response are usable from Claude Code. In my case, 8 of the 17 worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect Nvidia, Mistral and Groq
&lt;/h2&gt;

&lt;p&gt;The pattern is the same for every other provider: create an API key on the provider's site, add a connection in OmniRoute, check the key, import only free models, save, enable and test.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nvidia&lt;/strong&gt; - register on the Nvidia website and generate a key in its API keys section. I kept the default one-year expiration. 5 of 7 models worked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistral&lt;/strong&gt; - create the key at &lt;code&gt;admin.mistral.ai&lt;/code&gt;, not &lt;code&gt;studio.mistral.ai&lt;/code&gt;. Only one model was available for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq&lt;/strong&gt; - create the key in the API keys tab of your Groq account. 5 of 5 models worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OmniRoute homepage now shows four connected providers. Experiment with others too: Kiro AI, Antigravity CLI and Cerebras are all very good options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create the combo
&lt;/h2&gt;

&lt;p&gt;A combo works like a fallback policy. It uses all the models you configure, and once one of them is out of use, OmniRoute switches to another automatically in the background.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Create combo&lt;/strong&gt;, name it &lt;code&gt;omniroute-combo&lt;/code&gt; and choose the free stack quick template. To add a model, select a provider, select one of its models and click &lt;strong&gt;Add step&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model list shows everything that was imported, not what currently works, so run the provider's model test again and pick from those results. I added four OpenRouter models, all five Groq models, the one Mistral model and the working Nvidia models.&lt;/p&gt;

&lt;p&gt;The last step is the routing strategy. I use round robin, a circular distribution where each request goes to the next model in rotation, which spreads the load equally across all of them. Priority and the other strategies are there too, each with a description, so pick what suits your use case. Then click through to save.&lt;/p&gt;

&lt;p&gt;Now use &lt;strong&gt;Test combo&lt;/strong&gt;. Most of my models came back green, but one OpenRouter model returned a 429 error from the provider. That doesn't break anything: OmniRoute skips that model and doesn't use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Point Claude Code at OmniRoute
&lt;/h2&gt;

&lt;p&gt;Claude Code needs its own key, so create one in the &lt;strong&gt;API Keys&lt;/strong&gt; section of OmniRoute. I named mine after Claude Code. Copy the value, then open your project. I used the MCP server project from &lt;a href="https://remigiuszzalewski.com/blog/mcp-server-dotnet-ef-core" rel="noopener noreferrer"&gt;Build Your First MCP Server in .NET&lt;/a&gt;, the same one that I used in &lt;a href="https://remigiuszzalewski.com/blog/run-claude-code-free-ollama" rel="noopener noreferrer"&gt;Run Claude Code for FREE with Ollama&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the root of the project, create a &lt;code&gt;.claude&lt;/code&gt; folder with a &lt;code&gt;settings.local.json&lt;/code&gt; file inside it. This makes Claude Code use OmniRoute in this project instead of your Claude subscription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_BASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:20128"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_AUTH_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-omniroute-api-key&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"omniroute-combo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_OPUS_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"omniroute-combo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_SONNET_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"omniroute-combo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_HAIKU_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"omniroute-combo"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base URL is your local OmniRoute, and the auth token is the key you just created. All four model values are your combo name, so whichever model Claude Code asks for, the request goes through the combo.&lt;/p&gt;

&lt;p&gt;Save the file, open a terminal in the project and start Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The startup screen shows &lt;code&gt;omniroute-combo&lt;/code&gt; as the model, which confirms that the setup works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it on real tasks
&lt;/h2&gt;

&lt;p&gt;The first test was on the existing codebase: generate a Markdown file with documentation based on the code. The result had an architecture diagram, the project structure and the data models, and it looks very decent.&lt;/p&gt;

&lt;p&gt;The second test was to build something new: a tic-tac-toe game in pure HTML. One prompt and a few seconds later there was a &lt;code&gt;.html&lt;/code&gt; file that runs in any modern browser. I opened it in the browser built into VS Code and played a round. It detects when someone wins, and undo and restart work as well. It's impressive what these free models can do through Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check what it cost
&lt;/h2&gt;

&lt;p&gt;OmniRoute has an analytics section with the total tokens used, input tokens, output tokens and the estimated cost, along with highlights, performance and infrastructure data. For this whole experiment the estimated cost was 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  OmniRoute pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Imported doesn't mean working&lt;/strong&gt; - about half of my OpenRouter models failed the test. Always run &lt;strong&gt;Test all models&lt;/strong&gt; before relying on a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The combo's model list isn't filtered&lt;/strong&gt; - it shows every imported model, so retest the provider right before you add steps instead of picking by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking for the Mistral key in the wrong place&lt;/strong&gt; - API keys are created at &lt;code&gt;admin.mistral.ai&lt;/code&gt;, not &lt;code&gt;studio.mistral.ai&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a different name in the settings file&lt;/strong&gt; - the model values must match your combo name exactly, so check it in OmniRoute before pasting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worrying about a 429 in the combo test&lt;/strong&gt; - one failing model doesn't break the combo, because OmniRoute skips it and uses the others.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A single free tier is too small for a coding agent, but OmniRoute puts many free providers behind one local endpoint and one API key.&lt;/li&gt;
&lt;li&gt;A combo combines models from different providers, and when one is out of quota or failing, OmniRoute moves to another automatically.&lt;/li&gt;
&lt;li&gt;Test models after importing them and before adding them to a combo, because many imported free models don't respond.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;.claude/settings.local.json&lt;/code&gt; file with the base URL, the API key and your combo name is all it takes to point a project's Claude Code at OmniRoute.&lt;/li&gt;
&lt;li&gt;The analytics section lets you confirm the result: the estimated cost of my session was 0.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/diegosouzapw/OmniRoute" rel="noopener noreferrer"&gt;OmniRoute on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/setup" rel="noopener noreferrer"&gt;Claude Code setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Claude Code settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://build.nvidia.com" rel="noopener noreferrer"&gt;Nvidia Build&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.groq.com" rel="noopener noreferrer"&gt;Groq Console&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://admin.mistral.ai" rel="noopener noreferrer"&gt;Mistral Admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nodejs.org" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>omniroute</category>
      <category>llm</category>
    </item>
    <item>
      <title>Run Claude Code for FREE with Ollama</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Mon, 02 Nov 2026 07:42:51 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/run-claude-code-for-free-with-ollama-551e</link>
      <guid>https://dev.to/remigiuszzalewski/run-claude-code-for-free-with-ollama-551e</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Claude Code can run against a local AI model on your own computer - free, private, and it even works offline. Your code never leaves your machine and there are no tokens to pay for.&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/Mw5wpQkCmWI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;Claude Code talks to its model through the Anthropic API, and Ollama speaks that same API. So you point Claude Code at Ollama on your machine instead of the cloud, and that's it: no tokens to pay for, and your code never leaves your computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install Ollama
&lt;/h2&gt;

&lt;p&gt;On the &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;ollama.com&lt;/a&gt; downloads page there are install commands for the three operating systems. On Windows, copy the command, paste it into PowerShell and run it. Once installed, open the Ollama app - it has three tabs: applications, chat and settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pick the right model for your hardware
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Know your hardware first.&lt;/strong&gt; What matters is the VRAM of your GPU, or the unified memory on a Mac. If you have no GPU (or an old Mac), the model falls back to CPU and RAM, which is much slower and will quickly become frustrating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match parameters to memory.&lt;/strong&gt; As a rough guide from the video: with 8 GB of VRAM or unified memory you can usually handle 3-9 billion parameter chat models at Q4; with 16 GB, around 20-27 billion parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check benchmarks for your exact setup.&lt;/strong&gt; You can search ollama.com blindly, or use a "can I run AI" style website where you enter your GPU, VRAM and RAM and get benchmarks for local models by use case (coding, image and so on). The useful part is how much of your GPU or unified memory a model uses. If it shows something like 102% of VRAM, the remaining work is offloaded to CPU and RAM, and you'll feel the slowdown. (This isn't a sponsored recommendation - it was just handy for comparing models against your hardware.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave room for context.&lt;/strong&gt; The VRAM figure in those benchmarks does not include the context, which is how much the model can process and remember during a session. That's a real trade-off: one model may reason better but leave little memory for context, while a smaller one with, say, 69% of VRAM free can run a much larger context. In the video the choice was &lt;strong&gt;GPT-OSS with 20 billion parameters&lt;/strong&gt; with roughly a 32K token context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Download the model
&lt;/h2&gt;

&lt;p&gt;The model's page on ollama.com shows the command. For GPT-OSS 20B:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run gpt-oss:20b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Set the context length
&lt;/h2&gt;

&lt;p&gt;Before using it, change the context size. You can set the environment variable yourself, but the easiest way is the UI: open Ollama's &lt;strong&gt;Settings&lt;/strong&gt;, find &lt;strong&gt;Context length&lt;/strong&gt; and set it to &lt;strong&gt;32K&lt;/strong&gt;, based on what your hardware allows.&lt;/p&gt;

&lt;p&gt;Then try the model in Ollama's chat window. Asking it to explain RAG returns an answer almost immediately - a good sign before connecting it to Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Connect it to Claude Code
&lt;/h2&gt;

&lt;p&gt;First &lt;a href="https://code.claude.com/docs" rel="noopener noreferrer"&gt;install Claude Code&lt;/a&gt; by copying the install command from its documentation. Then launch it through Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama launch claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ollama lists a few recommended models, plus the one you downloaded earlier - select &lt;strong&gt;GPT-OSS 20B&lt;/strong&gt; and press Enter. You'll see a notice that the model isn't recognized. That doesn't mean it's broken: it simply isn't an official Claude model, so Claude Code flags the custom one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the context size matters
&lt;/h2&gt;

&lt;p&gt;The video demonstrates the failure mode. When the context is too small, the model can forget what you asked during its thinking phase - ask "what is C#?" and you might get the weather in some city instead. With the context set properly, the same question returns a correct answer about the programming language, with no hallucinated detour.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real task: generate documentation from a codebase
&lt;/h2&gt;

&lt;p&gt;To test it on real work, open a terminal in the folder of the MCP server project from &lt;a href="https://remigiuszzalewski.com/blog/mcp-server-dotnet-ef-core" rel="noopener noreferrer"&gt;Build Your First MCP Server in .NET&lt;/a&gt;, run &lt;code&gt;ollama launch claude&lt;/code&gt; with the same model, and ask it to generate a Markdown documentation file based on the code. The result is a &lt;code&gt;README.md&lt;/code&gt; built from the codebase: the database and connection strings, example requests and responses, and the available tools. For a free model running entirely on your own machine, the output is very good.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Claude Code can run against a local model through Ollama (&lt;code&gt;ollama launch claude&lt;/code&gt;) - free, private and offline, because Ollama speaks the Anthropic API.&lt;/li&gt;
&lt;li&gt;Pick a model by VRAM or unified memory, and keep headroom for context; set Ollama's context length (32K in the demo) or the model can lose track of your request.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>ollama</category>
      <category>localai</category>
    </item>
    <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>
