<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Remigiusz Zalewski]]></title><description><![CDATA[Weekly .NET tips sent every Saturday directly to your inbox!]]></description><link>https://newsletter.remigiuszzalewski.com</link><image><url>https://newsletter.remigiuszzalewski.com/img/substack.png</url><title>Remigiusz Zalewski</title><link>https://newsletter.remigiuszzalewski.com</link></image><generator>Substack</generator><lastBuildDate>Fri, 17 Apr 2026 10:05:19 GMT</lastBuildDate><atom:link href="https://newsletter.remigiuszzalewski.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Remigiusz Zalewski]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[remigiuszzalewski@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[remigiuszzalewski@substack.com]]></itunes:email><itunes:name><![CDATA[Remigiusz Zalewski]]></itunes:name></itunes:owner><itunes:author><![CDATA[Remigiusz Zalewski]]></itunes:author><googleplay:owner><![CDATA[remigiuszzalewski@substack.com]]></googleplay:owner><googleplay:email><![CDATA[remigiuszzalewski@substack.com]]></googleplay:email><googleplay:author><![CDATA[Remigiusz Zalewski]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[EF Core Database First: Scaffold a DB Context from an Existing Database]]></title><description><![CDATA[Stop writing models by hand - let EF Core do it for you.]]></description><link>https://newsletter.remigiuszzalewski.com/p/ef-core-database-first-scaffold-a</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/ef-core-database-first-scaffold-a</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 11 Apr 2026 06:01:25 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/1DF0ioRb4N0" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>Most tutorials start with a fresh database that you design yourself. But in the real world, you inherit databases. Legacy systems, shared enterprise databases, third-party schemas - they&#8217;re everywhere, and you need to integrate your .NET Web API with them fast.</p><p>The EF Core Database First approach with scaffolding is exactly the tool for this. One command generates your <code>DbContext</code> and all entity models - with relationships, configurations, and Fluent API mappings - directly from the existing database schema.</p><p>In this post you&#8217;ll see the full workflow: from setting up a new .NET Web API, running the scaffold command against a real SQL Server database, understanding what gets generated and why, cleaning up the output, and wiring everything into your app properly.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-1DF0ioRb4N0" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;1DF0ioRb4N0&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/1DF0ioRb4N0?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>The Starting Point: An Existing Database</h2><p>For this demo, the target is an <code>ECommerceDB</code> SQL Server database with multiple tables, foreign key relationships, and a realistic data model - orders, customers, products, categories, order details, and shippings.</p><p>The database gets created and seeded upfront using two SQL scripts run directly in SSMS. Once the data is in place, the goal is to integrate this schema into a .NET Web API without writing a single entity class by hand.</p><div><hr></div><h2>Required NuGet Packages</h2><p>Before running the scaffold command, three NuGet packages need to be added to your project:</p><pre><code><code>dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SqlServer</code></code></pre><ul><li><p><code>Tools</code> - provides the EF Core CLI commands</p></li><li><p><code>Design</code> - required at design time for scaffolding and migrations</p></li><li><p><code>SqlServer</code> - the database provider for Microsoft SQL Server</p></li></ul><div><hr></div><h2>The Scaffold Command</h2><p>With packages in place, run this single command to generate everything:</p><pre><code><code>dotnet ef dbcontext scaffold \
  "Server=localhost,1433;Database=ECommerceDB;User Id=sa;Password=YourPassword1!;TrustServerCertificate=True" \
  Microsoft.EntityFrameworkCore.SqlServer \
  --output-dir Entities</code></code></pre><ul><li><p>The first argument is the full connection string to your existing database</p></li><li><p><code>Microsoft.EntityFrameworkCore.SqlServer</code> is the provider</p></li><li><p><code>--output-dir Entities</code> places all generated files into an <code>Entities</code> folder</p></li></ul><div><hr></div><h2>Understanding the Generated DbContext</h2><p>This is what EF Core actually generates for the <code>ECommerceDB</code> schema. Read through it carefully - every section tells you something important about your database.</p><pre><code><code>using Microsoft.EntityFrameworkCore;

namespace ECommerceAPI.Entities;

public partial class ECommerceDbContext : DbContext
{
    public ECommerceDbContext()
    {
    }

    public ECommerceDbContext(DbContextOptions&lt;ECommerceDbContext&gt; options)
        : base(options)
    {
    }

    public virtual DbSet&lt;Category&gt; Categories { get; set; }
    public virtual DbSet&lt;Customer&gt; Customers { get; set; }
    public virtual DbSet&lt;Order&gt; Orders { get; set; }
    public virtual DbSet&lt;OrderDetail&gt; OrderDetails { get; set; }
    public virtual DbSet&lt;Product&gt; Products { get; set; }
    public virtual DbSet&lt;Shipping&gt; Shippings { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // WARNING: Move this connection string to appsettings.json
        optionsBuilder.UseSqlServer(
            "Server=localhost,1433;Database=ECommerceDB;User Id=sa;Password=YourPassword1!;TrustServerCertificate=True");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;Category&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__Categori__3214EC07...");

            entity.Property(e =&gt; e.Name)
                .IsRequired()
                .HasMaxLength(100)
                .IsUnicode(false);
        });

        modelBuilder.Entity&lt;Customer&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__Customer__3214EC07...");

            entity.Property(e =&gt; e.FirstName)
                .IsRequired()
                .HasMaxLength(100)
                .IsUnicode(false);
            entity.Property(e =&gt; e.LastName)
                .IsRequired()
                .HasMaxLength(100)
                .IsUnicode(false);
            entity.Property(e =&gt; e.Email)
                .IsRequired()
                .HasMaxLength(200)
                .IsUnicode(false);
        });

        modelBuilder.Entity&lt;Order&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__Orders__3214EC07...");

            entity.Property(e =&gt; e.OrderDate).HasColumnType("datetime");
            entity.Property(e =&gt; e.TotalAmount).HasColumnType("decimal(18,2)");
            entity.Property(e =&gt; e.Status)
                .HasMaxLength(50)
                .IsUnicode(false);

            entity.HasOne(d =&gt; d.Customer)
                .WithMany(p =&gt; p.Orders)
                .HasForeignKey(d =&gt; d.CustomerId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_Orders_Customers");
        });

        modelBuilder.Entity&lt;OrderDetail&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__OrderDet__3214EC07...");

            entity.Property(e =&gt; e.UnitPrice).HasColumnType("decimal(18,2)");

            entity.HasOne(d =&gt; d.Order)
                .WithMany(p =&gt; p.OrderDetails)
                .HasForeignKey(d =&gt; d.OrderId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_OrderDetails_Orders");

            entity.HasOne(d =&gt; d.Product)
                .WithMany(p =&gt; p.OrderDetails)
                .HasForeignKey(d =&gt; d.ProductId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_OrderDetails_Products");
        });

        modelBuilder.Entity&lt;Product&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__Products__3214EC07...");

            entity.Property(e =&gt; e.ProductName)
                .IsRequired()
                .HasMaxLength(200)
                .IsUnicode(false);
            entity.Property(e =&gt; e.Price).HasColumnType("decimal(18,2)");

            entity.HasOne(d =&gt; d.Category)
                .WithMany(p =&gt; p.Products)
                .HasForeignKey(d =&gt; d.CategoryId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_Products_Categories");
        });

        modelBuilder.Entity&lt;Shipping&gt;(entity =&gt;
        {
            entity.HasKey(e =&gt; e.Id).HasName("PK__Shipping__3214EC07...");

            entity.Property(e =&gt; e.ShippedDate).HasColumnType("datetime");
            entity.Property(e =&gt; e.Address)
                .HasMaxLength(300)
                .IsUnicode(false);
            entity.Property(e =&gt; e.Status)
                .HasMaxLength(50)
                .IsUnicode(false);

            entity.HasOne(d =&gt; d.Order)
                .WithMany(p =&gt; p.Shippings)
                .HasForeignKey(d =&gt; d.OrderId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_Shippings_Orders");
        });

        OnModelCreatingPartial(modelBuilder);
    }

    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}</code></code></pre><h3>Example entity created in Entities directory</h3><pre><code><code>public partial class Order 
{
    public int OrderId { get; set; }
    [other properties...]
    
    [example relationship]
    public virtual ICollection&lt;Shipping&gt; Shippings { get; set; } = new List&lt;Shippings&gt;();
}</code> </code></pre><h3>The <code>partial</code> Class and <code>OnModelCreatingPartial</code></h3><p>The generated class is marked <code>partial</code>. This is intentional - it lets you extend the context in a separate file without touching the generated code. If you re-scaffold after a schema change, your customizations survive because they live in a different file.</p><p><code>OnModelCreatingPartial</code> is a partial method hook at the end of <code>OnModelCreating</code>. Add your own configuration in a second partial file by implementing this method there. Never add custom config directly to the generated file.</p><h3>The Two Constructors</h3><p>The parameterless constructor exists for design-time tooling. The second constructor - <code>(DbContextOptions&lt;ECommerceDbContext&gt; options)</code> - is the one used at runtime when the context is registered via DI. You do not need to touch either of these.</p><h3><code>virtual</code> DbSet Properties</h3><p>All <code>DbSet</code> properties are marked <code>virtual</code>. This enables mocking in unit tests - a test double can override these properties and return in-memory data without hitting a real database.</p><h3><code>OnConfiguring</code></h3><p>This is the first thing to fix. The scaffolder drops the connection string directly into <code>OnConfiguring</code> and adds a compiler warning telling you to remove it. Delete the entire method - it is only needed when no options are provided via the constructor, which never happens in a properly configured DI setup.</p><h3><code>OnModelCreating</code> - The Real Value</h3><p>This is where scaffolding earns its keep. Every relationship, constraint, column type, max length, and delete behavior is mapped here using Fluent API. Things to note:</p><ul><li><p><code>HasName("PK__...")</code> captures the actual constraint name from SQL Server - useful if you need to reference it later</p></li><li><p><code>HasColumnType("decimal(18,2)")</code> and <code>HasColumnType("datetime")</code> preserve exact SQL Server types that have no direct CLR equivalent</p></li><li><p><code>IsUnicode(false)</code> maps to <code>VARCHAR</code> columns - if you see this, the database is using non-Unicode string columns</p></li><li><p><code>OnDelete(DeleteBehavior.ClientSetNull)</code> reflects the FK delete rule defined in the database - this is not EF&#8217;s default, it was read directly from the schema</p></li><li><p><code>HasConstraintName</code> preserves the original FK constraint name from SQL Server</p></li></ul><h3><code>partial void OnModelCreatingPartial</code></h3><p>This is the extension point. In a second file, implement it like this to add custom configuration without modifying generated code:</p><pre><code><code>public partial class ECommerceDbContext
{
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
    {
        // your custom Fluent API configuration here
        modelBuilder.Entity&lt;Product&gt;()
            .HasQueryFilter(p =&gt; !p.IsDeleted);
    }
}</code></code></pre><div><hr></div><h2>Cleaning Up After Scaffolding</h2><p>Two things to do immediately after the scaffold command runs.</p><h3>Move the DbContext Up One Level</h3><p>The <code>DbContext</code> gets placed inside the <code>Entities</code> output folder alongside all the entity files. Move it one level up to the project root or a dedicated <code>Data</code> folder, and update its namespace accordingly.</p><h3>Remove <code>OnConfiguring</code> and Move the Connection String</h3><p>Delete the entire <code>OnConfiguring</code> method. Move the connection string to <code>appsettings.json</code>:</p><p>Then register the context in <code>Program.cs</code>:</p><pre><code><code>builder.Services.AddDbContext&lt;ECommerceDbContext&gt;(options =&gt;
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DbConnectionString")));</code></code></pre><div><hr></div><h2>Querying with the Scaffolded Context</h2><p>With the context registered, a single endpoint demonstrates the full navigation chain across all the generated relationships:</p><p>csharp</p><pre><code><code>app.MapGet("/api/demo", async (ECommerceDbContext dbContext) =&gt;
{
    var results = await dbContext.Orders
        .Include(o =&gt; o.Customer)
        .Include(o =&gt; o.OrderDetails)
            .ThenInclude(od =&gt; od.Product)
                .ThenInclude(p =&gt; p.Category)
        .Include(o =&gt; o.Shippings)
        .Select(o =&gt; new
        {
            CustomerName = $"{o.Customer.FirstName} {o.Customer.LastName}",
            o.OrderDate,
            o.TotalAmount,
            o.Status,
            Products = o.OrderDetails.Select(od =&gt; new
            {
                od.Product.ProductName,
                od.Quantity,
                od.UnitPrice,
                Categories = od.Product.Category.Name
            }).ToList()
        })
        .ToListAsync();

    return results;
});</code></code></pre><ul><li><p><code>Include</code> / <code>ThenInclude</code> follows the navigation properties generated by scaffolding - no manual wiring needed</p></li><li><p><code>Select</code> projects the result to avoid over-fetching - never return raw EF entities from an API endpoint</p></li><li><p><code>ToListAsync</code> keeps the call non-blocking</p></li></ul><div><hr></div><h2>Pros and Cons of Database First Scaffolding</h2><h3>Pros</h3><ul><li><p>Instant model generation from any existing schema - no manual entity writing</p></li><li><p>All Fluent API configuration is generated automatically, including complex relationships</p></li><li><p>The <code>partial</code> class pattern lets you extend safely without touching generated code</p></li><li><p>Reduces human error when dealing with large or complex schemas</p></li></ul><h3>Cons</h3><ul><li><p>Generated code is verbose and can be hard to read in large schemas</p></li><li><p>Re-scaffolding on schema changes can overwrite customizations not protected by the partial pattern</p></li><li><p>The <code>OnConfiguring</code> connection string issue requires a cleanup step every time</p></li><li><p>Navigation property and constraint names are sometimes awkward and may need renaming</p></li></ul><h3>When to Use Database First</h3><p>Use Database First when the database already exists and is maintained independently - legacy systems, DBAs who own the schema, shared databases across multiple services. If you control the database lifecycle from day one, Code First with migrations is the better fit.</p><div><hr></div><h2>Key Takeaways</h2><ul><li><p>Three NuGet packages are required before scaffolding: <code>Tools</code>, <code>Design</code>, and the database provider.</p></li><li><p>The scaffold command generates both the <code>DbContext</code> and all entities with full Fluent API configuration in a single step.</p></li><li><p>Always delete the <code>OnConfiguring</code> method from the generated context and move the connection string to <code>appsettings.json</code>.</p></li><li><p><code>virtual</code> DbSet properties exist for testability - they allow mocking the context in unit tests.</p></li><li><p><code>OnDelete(DeleteBehavior.ClientSetNull)</code>, <code>IsUnicode(false)</code>, and exact column types like <code>decimal(18,2)</code> are read directly from the database schema - do not change them without understanding the underlying constraint.</p></li></ul><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[Refit - The Better Way to Consume APIs in .NET]]></title><description><![CDATA[Stop writing the same HttpClient boilerplate for every external API endpoint - there&#8217;s a cleaner way.]]></description><link>https://newsletter.remigiuszzalewski.com/p/refit-the-better-way-to-consume-apis</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/refit-the-better-way-to-consume-apis</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 04 Apr 2026 06:01:56 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/QsTaZOGgIVo" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>You&#8217;re integrating with an external API. You create a service, inject <code>HttpClient</code>, build the URL, append query parameters, deserialize the response. It works. You do it again for the next endpoint. And the next. By endpoint number eight, you&#8217;re copy-pasting the same structure with minor variations, and every new developer on the team has to understand your bespoke HTTP layer just to add a method.</p><p>This is the reality for most .NET teams consuming third-party APIs. The Typed HttpClient pattern with <code>IHttpClientFactory</code> is solid and production-proven - but it generates a lot of repetitive code that scales poorly when an API surface grows.</p><p>Refit solves this by generating the HTTP service for you at compile time, based on a C# interface you define. You write the contract, Refit writes the implementation.</p><p>In this post I&#8217;ll walk through both approaches using a real OpenWeatherMap integration: first the full typed HttpClient setup with a delegating handler, then the same thing rebuilt with Refit. You&#8217;ll see exactly where the complexity lives and what disappears.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-QsTaZOGgIVo" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;QsTaZOGgIVo&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/QsTaZOGgIVo?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><h2>Setting Up the Project</h2><p>The demo project targets .NET 10 and uses following nuget package:</p><pre><code><code>&lt;PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" /&gt;</code></code></pre><p>The key package for Refit is <code>Refit.HttpClientFactory</code> - it provides the <code>AddRefitClient&lt;T&gt;()</code> extension that integrates cleanly with <code>IHttpClientFactory</code>.</p><h2>Approach 1: Typed HttpClient with IHttpClientFactory</h2><h3>Configuration and Options</h3><p>Any external API integration starts with configuration. The OpenWeatherMap API needs a base URL, an API key, and a units setting. These go into <code>appsettings.json</code>:</p><pre><code><code>{
  "OpenWeatherApi": {
    "BaseUrl": "https://api.openweathermap.org/data/2.5",
    "ApiKey": "",
    "Units": "metric"
  }
}</code></code></pre><p>Rather than injecting <code>IConfiguration</code> directly into services, the Options pattern is the right approach. Create a strongly typed options class:</p><pre><code><code>public class OpenWeatherApiOptions
{
    public const string OpenWeatherApiOptionsKey = "OpenWeatherApi";

    [Required]
    [Url]
    public string BaseUrl { get; set; } = string.Empty;

    [Required]
    public string ApiKey { get; set; } = string.Empty;

    [Required]
    public string Units { get; set; } = string.Empty;
}</code></code></pre><p>Key points:</p><ul><li><p><code>OpenWeatherApiOptionsKey</code> is a <code>const string</code> used to bind to the correct <code>appsettings.json</code> section</p></li><li><p>Data annotations (<code>[Required]</code>, <code>[Url]</code>) enable startup validation</p></li><li><p>Using <code>string.Empty</code> as defaults satisfies the nullable reference type compiler</p></li></ul><p>Register the options in <code>Program.cs</code>:</p><pre><code><code>builder.Services.AddOptions&lt;OpenWeatherApiOptions&gt;()
    .BindConfiguration(OpenWeatherApiOptions.OpenWeatherApiOptionsKey)
    .ValidateDataAnnotations()
    .ValidateOnStart();</code></code></pre><p><code>ValidateOnStart()</code> is important - without it, validation only triggers on first use. With it, a misconfigured API key will fail immediately at startup rather than silently at runtime.</p><h3>The Service and Interface</h3><p>Start with the interface that defines the contract:</p><pre><code><code>public interface IOpenWeatherApiService
{
    Task&lt;OpenWeatherApiResponse?&gt; GetCurrentWeatherDataAsync(
        double lat,
        double lon,
        CancellationToken ct);
}</code></code></pre><p>And the implementation. Notice how <code>HttpClient</code> is injected via primary constructor and the query parameters are built manually in the URL string:</p><pre><code><code>public class OpenWeatherApiService(HttpClient client) : IOpenWeatherApiService
{
    public async Task&lt;OpenWeatherApiResponse?&gt; GetCurrentWeatherDataAsync(
        double lat,
        double lon,
        CancellationToken ct)
    {
        var response = await client.GetFromJsonAsync&lt;OpenWeatherApiResponse&gt;(
            $"weather?lat={lat}&amp;lon={lon}");
        return response;
    }
}</code></code></pre><p>Key points:</p><ul><li><p><code>HttpClient</code> is injected by <code>IHttpClientFactory</code> - the base address and any shared configuration are set at registration time, not inside the service</p></li><li><p>The method builds a relative URL with <code>lat</code> and <code>lon</code> as query parameters</p></li><li><p>Notice what is missing: the API key and units are not here - that&#8217;s the job of the delegating handler covered next</p></li><li><p>Every new endpoint from this API means a new method in this class and a new entry in the interface</p></li></ul><h3>The Delegating Handler</h3><p>Right now the service only appends <code>lat</code> and <code>lon</code>. But every request to OpenWeatherMap also needs <code>appid</code> (the API key) and <code>units</code>. You could add those to every method call - but that means touching every method whenever the API contract changes, and duplicating the same parameter wiring across dozens of endpoints.</p><p>A delegating handler solves this cleanly. It sits in the HTTP pipeline and intercepts every outgoing request before it leaves the application. You override <code>SendAsync</code>, mutate the request, and call <code>base.SendAsync</code> to continue the pipeline.</p><pre><code><code>public class OpenWeatherApiDelegatingHandler(IOptions&lt;OpenWeatherApiOptions&gt; options)
    : DelegatingHandler
{
    private readonly OpenWeatherApiOptions _options = options.Value;

    protected override async Task&lt;HttpResponseMessage&gt; SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var uri = request.RequestUri!;
        var separator = uri.Query.Length &gt; 0 ? "&amp;" : "?";
        request.RequestUri = new Uri(
            $"{uri}{separator}appid={_options.ApiKey}&amp;units={_options.Units}");

        return await base.SendAsync(request, cancellationToken);
    }
}</code></code></pre><p>Key points:</p><ul><li><p>Inherits from <code>DelegatingHandler</code> and overrides <code>SendAsync</code></p></li><li><p>Detects whether a <code>?</code> already exists in the query string and appends <code>&amp;</code> or <code>?</code> accordingly - without this check you&#8217;d produce malformed URLs like <code>weather?lat=52&amp;lon=13??appid=...</code></p></li><li><p>Reads the API key and units from <code>IOptions&lt;OpenWeatherApiOptions&gt;</code> - no magic strings anywhere in the pipeline</p></li><li><p>This is also the right place for <code>Authorization: Bearer {token}</code> headers for token-authenticated APIs - one handler, all requests covered</p></li></ul><p>The delegating handler makes <code>OpenWeatherApiService</code> completely unaware of authentication or shared parameters. Adding 15 more endpoints to the interface means zero changes to the handler.</p><h3>Registering Everything in Program.cs</h3><pre><code><code>builder.Services.AddTransient&lt;OpenWeatherApiDelegatingHandler&gt;();

builder.Services.AddHttpClient&lt;IOpenWeatherApiService, OpenWeatherApiService&gt;
    ((provider, client) =&gt;
    {
        var options = provider
            .GetRequiredService&lt;IOptions&lt;OpenWeatherApiOptions&gt;&gt;()
            .Value;
        client.BaseAddress = new Uri(options.BaseUrl);
    })
    .AddHttpMessageHandler&lt;OpenWeatherApiDelegatingHandler&gt;();</code></code></pre><p>Key points:</p><ul><li><p>The delegating handler must be registered as <code>Transient</code> before it can be used with <code>AddHttpMessageHandler</code></p></li><li><p><code>ConfigureHttpClient</code> resolves <code>IOptions&lt;OpenWeatherApiOptions&gt;</code> from the DI container to set the base address - this is why the service itself never needs to know the base URL</p></li><li><p><code>AddHttpMessageHandler</code> chains the handler into the request pipeline for this specific typed client</p></li></ul><p>&#9989; <strong>Pros</strong></p><ul><li><p>Full control over every request detail</p></li><li><p>No additional dependencies beyond what ships with .NET</p></li><li><p>Works well for simple integrations with one or two endpoints</p></li></ul><p>&#10060; <strong>Cons</strong></p><ul><li><p>Every new endpoint requires a new method in the service class and a matching entry in the interface</p></li><li><p>The service, interface, and registration all need to be maintained in sync</p></li><li><p>Boilerplate grows linearly with the number of API endpoints</p></li></ul><div><hr></div><h2>Approach 2: Refit</h2><p>Refit takes a different approach entirely. Instead of writing a service class, you declare a C# interface that describes the API contract using attributes. Refit generates the implementation at compile time - the <code>OpenWeatherApiService</code> class disappears completely.</p><h3>The Interface</h3><p>The interface that previously only defined the method signature now also carries the full HTTP contract:</p><pre><code><code>public interface IOpenWeatherApiService
{
    [Get("/weather")]
    Task&lt;OpenWeatherApiResponse?&gt; GetCurrentWeatherDataAsync(
        [Query] double lat,
        [Query] double lon,
        CancellationToken ct = default);
}</code></code></pre><p>Key points:</p><ul><li><p><code>[Get("/weather")]</code> maps to the HTTP GET method and the relative path - note the leading slash, which Refit requires</p></li><li><p><code>[Query]</code> attributes map method parameters to URL query parameters automatically - no manual string interpolation</p></li><li><p>The base URL is still configured at registration time, same as before</p></li><li><p>No implementation class is needed - this interface is the entire definition of the integration</p></li></ul><h3>Registration</h3><pre><code><code>builder.Services.AddTransient&lt;OpenWeatherApiDelegatingHandler&gt;();

builder.Services.AddRefitClient&lt;IOpenWeatherApiService&gt;()
    .ConfigureHttpClient((provider, client) =&gt;
    {
        var options = provider
            .GetRequiredService&lt;IOptions&lt;OpenWeatherApiOptions&gt;&gt;()
            .Value;
        client.BaseAddress = new Uri(options.BaseUrl);
    })
    .AddHttpMessageHandler&lt;OpenWeatherApiDelegatingHandler&gt;();</code></code></pre><p>The only change from the typed HttpClient registration is <code>AddRefitClient&lt;T&gt;()</code> instead of <code>AddHttpClient&lt;TInterface, TImplementation&gt;()</code>. The delegating handler, options resolution, and base address setup remain identical - Refit slots into the same pipeline.</p><h3>What Refit Generates</h3><p>When you set a breakpoint and inspect the injected <code>IOpenWeatherApiService</code> at runtime, you&#8217;ll see a class like <code>AutoGeneratedIOpenWeatherApiService</code> - produced by Refit&#8217;s source generator at compile time. It wraps <code>HttpClient</code>, builds the request URL from your <code>[Get]</code> and <code>[Query]</code> declarations, and handles deserialization. You get the same runtime behavior as the hand-written service with zero implementation code on your side.</p><p>Adding a new endpoint from the same API now means one new method in the interface. No new class, no new registration, no new URL string to construct.</p><h3>When to Use Typed HttpClient</h3><ul><li><p>You have one or two endpoints and adding a new NuGet dependency is not justified</p></li><li><p>You need fine-grained control over request construction that attributes cannot express</p></li><li><p>The API requires complex, dynamic query building that varies significantly per call</p></li></ul><h3>When to Use Refit</h3><ul><li><p>You are integrating with an API that has 5 or more endpoints</p></li><li><p>You want the interface to serve as living documentation of the external API contract</p></li><li><p>You want to reduce boilerplate and keep new endpoint additions to a single line</p></li></ul><div><hr></div><h2>Key Takeaways</h2><ul><li><p>The Options pattern with <code>ValidateDataAnnotations()</code> and <code>ValidateOnStart()</code> catches misconfiguration at startup, not silently at runtime.</p></li><li><p>Delegating handlers intercept every outgoing request in the pipeline - use them for API keys, auth headers, or any parameter that repeats across all endpoints.</p></li><li><p>Without a delegating handler, shared parameters like <code>appid</code> and <code>units</code> leak into every service method, making future changes expensive.</p></li><li><p>Refit replaces the service implementation class entirely by generating it from a decorated interface at compile time - <code>AddRefitClient&lt;T&gt;()</code> is the only registration change needed.</p></li><li><p>For small integrations with one or two endpoints, typed HttpClient is perfectly fine. Refit pays off as the API surface grows.</p></li><li><p>Both approaches work side by side in the same application - mix them based on the complexity of each integration.</p></li></ul><h2>Resources</h2><ul><li><p>https://github.com/reactiveui/refit</p></li></ul><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[EF Core Stored Procedures: Complete Guide for .NET Developers]]></title><description><![CDATA[Learn how to create and call stored procedures in Entity Framework Core using Code First migrations. Covers FromSqlRaw, FromSqlInterpolated, and ExecuteSqlInterpolatedAsync with full C# examples.]]></description><link>https://newsletter.remigiuszzalewski.com/p/ef-core-stored-procedures-complete</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/ef-core-stored-procedures-complete</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 28 Mar 2026 07:00:45 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/uecoaWRqv_E" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>LINQ is convenient, but it is not always the right tool. Complex filtering logic, multi-table aggregations, and performance-sensitive queries can become unreadable or impossible to express cleanly in LINQ. Stored procedures solve this - the SQL lives in the database, it is reusable across applications, and it executes faster than the equivalent dynamically generated query.</p><p>The question is how to manage stored procedures properly in a Code First EF Core project. You do not want to run raw SQL scripts manually against every environment. You want them version-controlled, applied automatically with migrations, and called safely from your API without opening the door to SQL injection.</p><p>This post covers the full workflow: creating stored procedures inside EF Core migrations, calling them from Minimal API endpoints using <code>FromSqlRaw</code>, <code>FromSqlInterpolated</code>, and <code>ExecuteSqlInterpolatedAsync</code>, and understanding when to reach for each method.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-uecoaWRqv_E" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;uecoaWRqv_E&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/uecoaWRqv_E?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>The Starting Point</h2><p>The demo project is a Minimal API with two entities - <code>Author</code> and <code>Book</code>:</p><pre><code><code>public class Author
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Country { get; set; }
}

public class Book
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public Author Author { get; set; }
    public int AuthorId { get; set; }
}</code></code></pre><p>Four endpoints exist initially, all written with LINQ. The goal is to replace them with stored procedure equivalents, with each procedure created and managed entirely through EF Core migrations.</p><div><hr></div><h2>What Are Stored Procedures and When to Use Them</h2><p>A stored procedure is a named SQL statement stored inside the database itself. It can be called by any application that has access to that database, and the database engine can cache its execution plan for faster repeated execution.</p><p>For simple CRUD operations, LINQ is fine and Code First keeps things clean. Stored procedures make sense when:</p><ul><li><p>The SQL is too complex or verbose to express cleanly in LINQ</p></li><li><p>The query cannot be expressed in LINQ at all</p></li><li><p>The same logic needs to be shared across multiple applications or services</p></li><li><p>Raw query performance matters and you want the database engine to optimize the plan</p></li></ul><p>In this demo, the operations are intentionally simple - the point is demonstrating the pattern, not justifying every stored procedure.</p><div><hr></div><h2>Creating Stored Procedures in EF Core Migrations</h2><p>The right way to manage stored procedures in Code First is through blank migrations. You add an empty migration, write the <code>CREATE PROCEDURE</code> SQL in the <code>Up</code> method, and write the <code>DROP PROCEDURE</code> SQL in the <code>Down</code> method. This keeps every procedure version-controlled and applied automatically with <code>dotnet ef database update</code>.</p><h3>Creating a Blank Migration</h3><pre><code><code>dotnet ef migrations add AddedGetBooks \
  --startup-project StoredProcedures.API \
  --project StoredProcedures.Infrastructure</code></code></pre><ul><li><p><code>--startup-project</code> points to where your API lives</p></li><li><p><code>--project</code> points to where your <code>DbContext</code> and migrations folder live</p></li><li><p>The generated migration will have empty <code>Up</code> and <code>Down</code> methods ready for your SQL</p></li></ul><h3>Writing the Migration</h3><pre><code><code>public partial class AddedGetBooks : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        var createProcedure =
            "CREATE PROCEDURE [dbo].[GetBooks]\n" +
            "AS\n" +
            "BEGIN\n" +
            "    SET NOCOUNT ON;\n" +
            "    SELECT * FROM Books;\n" +
            "END";

        migrationBuilder.Sql(createProcedure);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        var dropStatement = "DROP PROCEDURE [dbo].[GetBooks]";
        migrationBuilder.Sql(dropStatement);
    }
}</code></code></pre><p>Key points:</p><ul><li><p><code>migrationBuilder.Sql()</code> executes any raw SQL string during migration</p></li><li><p><code>SET NOCOUNT ON</code> suppresses row-count messages - always include this in stored procedures</p></li><li><p>The <code>Down</code> method must drop the procedure so rollbacks work cleanly</p></li><li><p>Apply with <code>dotnet ef database update --startup-project ... --project ...</code></p></li></ul><h3>A Procedure with Parameters</h3><pre><code><code>public partial class AddedGetBooksByCategoryAndAuthorId : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        var sql =
            "CREATE PROCEDURE GetBooksByCategoryAndAuthorId\n" +
            "    @category NVARCHAR(100),\n" +
            "    @authorId INT\n" +
            "AS\n" +
            "BEGIN\n" +
            "    SET NOCOUNT ON;\n" +
            "    SELECT * FROM Books\n" +
            "    WHERE Category = @category AND AuthorId = @authorId;\n" +
            "END";

        migrationBuilder.Sql(sql);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        var sql = "DROP PROCEDURE GetBooksByCategoryAndAuthorId";
        migrationBuilder.Sql(sql);
    }
}</code></code></pre><ul><li><p>Parameters are declared with <code>@paramName TYPE</code> after the procedure name</p></li><li><p>The <code>WHERE</code> clause uses those parameters directly</p></li><li><p>The pattern for <code>Down</code> is identical - just drop the procedure by name</p></li></ul><div><hr></div><h2>&#9889; Calling Stored Procedures from Minimal API Endpoints</h2><p>There are three EF Core methods for executing stored procedures, and choosing the right one depends on whether you are querying data or executing a command.</p><h3><code>FromSqlRaw</code> - No Parameters</h3><p>Use <code>FromSqlRaw</code> when the stored procedure takes no parameters and you need to return entity results:</p><pre><code><code>app.MapGet("/stored-procedure-get-books", async (DemoDbContext dbContext) =&gt;
{
    var books = await dbContext.Books
        .FromSqlRaw("EXEC GetBooks")
        .ToListAsync();

    return books;
});</code></code></pre><ul><li><p><code>FromSqlRaw</code> executes a raw SQL string and maps results to the entity type</p></li><li><p>Only use this when there are no user-supplied parameters - never concatenate user input into a raw SQL string</p></li></ul><h3><code>FromSqlInterpolated</code> - Parameterized Queries</h3><p>Use <code>FromSqlInterpolated</code> when you need to pass parameters and return entity results. EF Core converts the interpolated string into a parameterized query, which prevents SQL injection:</p><pre><code><code>app.MapGet("/stored-procedure-get-books-by-category-and-authorid",
    async (string category, int authorId, DemoDbContext dbContext) =&gt;
    {
        var books = await dbContext.Books
            .FromSqlInterpolated(
                $"EXEC GetBooksByCategoryAndAuthorId @category={category}, @authorId {authorId}")
            .ToListAsync();

        return books;
    });</code></code></pre><ul><li><p>The <code>$</code> prefix makes this an interpolated string, but EF Core does not concatenate the values directly</p></li><li><p>Each interpolated value is converted to a proper SQL parameter under the hood</p></li><li><p>Results are mapped back to <code>Book</code> entities automatically</p></li></ul><h3><code>ExecuteSqlInterpolatedAsync</code> - Commands (No Return Value)</h3><p>Use <code>ExecuteSqlInterpolatedAsync</code> for <code>INSERT</code>, <code>UPDATE</code>, and <code>DELETE</code> procedures that do not return rows:</p><pre><code><code>app.MapPost("/stored-procedure-books",
    async (CreateBookRequest createBookRequest, DemoDbContext dbContext) =&gt;
    {
        await dbContext.Database.ExecuteSqlInterpolatedAsync(
            $"EXEC CreateBook @name={createBookRequest.Name}, @category={createBookRequest.Category}, @authorId={createBookRequest.AuthorId}");

        return Results.Ok();
    });

app.MapPut("/stored-procedure-books",
    async (UpdateBookRequest updateBookRequest, DemoDbContext dbContext) =&gt;
    {
        await dbContext.Database.ExecuteSqlInterpolatedAsync(
            $"EXEC UpdateBook @bookId={updateBookRequest.BookId}, @name={updateBookRequest.Name}, @category={updateBookRequest.Category}, @authorId={updateBookRequest.AuthorId}");

        return Results.Ok();
    });</code></code></pre><ul><li><p>Called on <code>dbContext.Database</code>, not on a <code>DbSet</code></p></li><li><p>Async by default - always await it</p></li><li><p>Same SQL injection protection as <code>FromSqlInterpolated</code> - values are parameterized automatically</p></li></ul><div><hr></div><h2>The Full <code>BooksStoredProcedureModule</code></h2><pre><code><code>public static class BooksStoredProcedureModule
{
    public static void AddBooksStoredProcedureEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/stored-procedure-books",
            async (CreateBookRequest createBookRequest, DemoDbContext dbContext) =&gt;
            {
                await dbContext.Database.ExecuteSqlInterpolatedAsync(
                    $"EXEC CreateBook @name={createBookRequest.Name}, @category={createBookRequest.Category}, @authorId={createBookRequest.AuthorId}");
                return Results.Ok();
            });

        app.MapPut("/stored-procedure-books",
            async (UpdateBookRequest updateBookRequest, DemoDbContext dbContext) =&gt;
            {
                await dbContext.Database.ExecuteSqlInterpolatedAsync(
                    $"EXEC UpdateBook @bookId={updateBookRequest.BookId}, @name={updateBookRequest.Name}, @category={updateBookRequest.Category}, @authorId={updateBookRequest.AuthorId}");
                return Results.Ok();
            });

        app.MapGet("/stored-procedure-get-books", async (DemoDbContext dbContext) =&gt;
        {
            var books = await dbContext.Books
                .FromSqlRaw("EXEC GetBooks")
                .ToListAsync();
            return books;
        });

        app.MapGet("/stored-procedure-get-books-by-category-and-authorid",
            async (string category, int authorId, DemoDbContext dbContext) =&gt;
            {
                var books = await dbContext.Books
                    .FromSqlInterpolated(
                        $"EXEC GetBooksByCategoryAndAuthorId @category={category}, @authorId={authorId}")
                    .ToListAsync();
                return books;
            });
    }
}</code></code></pre><div><hr></div><h2>Migrations Keep Everything in Sync</h2><p>One major benefit of managing stored procedures through migrations is that dropping and recreating the database from scratch works perfectly. Running <code>dotnet ef database update</code> replays all migrations in order - tables first, then each stored procedure gets created automatically.</p><p>No manual SQL scripts, no environment drift, no missing procedures in staging. The database state is fully reproducible from migration history alone.</p><div><hr></div><h2>Key Takeaways</h2><ul><li><p>Create stored procedures inside blank EF Core migrations using <code>migrationBuilder.Sql()</code> - this keeps them version-controlled and applied automatically.</p></li><li><p>Always write a <code>DOWN</code> method that drops the procedure so rollbacks work cleanly.</p></li><li><p>Use <code>FromSqlRaw</code> only for no-parameter queries - never concatenate user input into a raw SQL string.</p></li><li><p>Use <code>FromSqlInterpolated</code> for parameterized queries that return entities - EF Core converts interpolated values to proper SQL parameters automatically.</p></li><li><p>Use <code>ExecuteSqlInterpolatedAsync</code> on <code>dbContext.Database</code> for INSERT, UPDATE, and DELETE procedures that do not return rows.</p></li><li><p>Stored procedures are worth reaching for when the SQL is too complex for LINQ, needs to be shared across applications, or when raw execution performance matters.</p></li></ul><h2 style="text-align: center;">Connect with me:</h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[EF Core Performance: N+1, Cartesian Explosion and How to Fix Both]]></title><description><![CDATA[Your app is making 1501 SQL queries per request - and the code looks completely fine.]]></description><link>https://newsletter.remigiuszzalewski.com/p/ef-core-performance-n1-cartesian</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/ef-core-performance-n1-cartesian</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 21 Mar 2026 07:01:39 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/iaZxzDmfTHU" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div><hr></div><blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p>&#128640; <strong>Sponsored</strong></p><p>Struggling with slow EF Core operations? With <a href="https://entityframework-extensions.net/">ZZZ Projects&#8217; EF Core Extensions</a>, you can boost performance like never before. Experience up to <strong>14&#215; faster Bulk Insert, Update, Delete, and Merge</strong> &#8212; and cut your save time by as much as <strong>94%</strong>.</p><p>&#128073; <a href="https://entityframework-extensions.net/">entityframework-extensions.net</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>You send a single HTTP request. Your app fetches 500 orders. Looks fine. But in the background, EF Core just fired 1501 SQL queries against your database - and the response took 7.5 seconds.</p><p>This is the N+1 problem, and it&#8217;s one of the most common performance killers in EF Core applications. The frustrating part is that the code causing it looks completely reasonable. No obvious red flags, no suspicious loops - just navigational properties being accessed like normal.</p><p>In this article you&#8217;ll see exactly why it happens, what Cartesian explosion is, and three different approaches to loading related data - each with different trade-offs depending on your situation.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-iaZxzDmfTHU" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;iaZxzDmfTHU&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/iaZxzDmfTHU?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>&#128034; The N+1 Problem</h2><p>Here&#8217;s the endpoint that triggered 1501 queries:</p><pre><code><code>group.MapGet("/nplusone", async (AppDbContext db) =&gt;
{
    var orders = await db.Orders.ToListAsync();
    var results = new List&lt;OrderResult&gt;();

    foreach (var order in orders)
    {
        results.Add(new OrderResult(
            order.Id,
            order.Customer.Name,
            [.. order.Items.Select(x =&gt; x.ProductName)],
            [.. order.Tags.Select(x =&gt; x.Name)]));
    }

    return Results.Ok(results);
});</code></code></pre><p>One call to <code>db.Orders.ToListAsync()</code> fetches the orders. Then in the <code>foreach</code>, accessing <code>order.Customer</code>, <code>order.Items</code>, and <code>order.Tags</code> fires a separate SQL query for each navigational property on each order. With 500 orders and 3 navigational properties each:</p><pre><code><code>1 (initial query) + 3 * 500 = 1501 SQL queries</code></code></pre><p>The root cause is <code>UseLazyLoadingProxies()</code> being enabled in the DbContext configuration - combined with the <code>virtual</code> keyword on navigational properties. EF Core intercepts every property access and goes back to the database silently.</p><pre><code><code>// What enables lazy loading - remove this
builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .UseLazyLoadingProxies() // the culprit
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging());</code></code></pre><p>Microsoft disables lazy loading by default for exactly this reason. Remove <code>UseLazyLoadingProxies()</code> and drop the <code>virtual</code> keywords from all navigational properties. After that, accessing them without explicitly loading the data throws a <code>NullReferenceException</code> - which is the right behavior. It forces you to be intentional.</p><p>The correct DbContext setup:</p><pre><code><code>builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging());</code></code></pre><p>Key points:</p><ul><li><p>Lazy loading is disabled by default in EF Core - do not re-enable it without understanding the query cost</p></li><li><p>The <code>virtual</code> keyword on navigational properties is what allows lazy loading proxies to intercept access</p></li><li><p>A <code>NullReferenceException</code> after removing lazy loading is expected - it means you now control what gets loaded</p></li></ul><div><hr></div><h2>Fix 1: Projections</h2><p>Projections let you select only the data you actually need. EF Core translates the <code>Select</code> into SQL joins and aggregate functions server-side.</p><pre><code><code>group.MapGet("/projection", async (AppDbContext db) =&gt;
{
    var results = await db.Orders
        .Select(o =&gt; new
        {
            o.Id,
            CustomerName = o.Customer.Name,
            o.Total,
            ItemCount = o.Items.Count
        })
        .ToListAsync();

    return Results.Ok(results);
});</code></code></pre><p>This produces a single SQL query with a JOIN and a COUNT aggregate. EF Core also skips change tracking automatically when projecting to anonymous types - no need to call <code>AsNoTracking()</code>.</p><p>Result: ~230ms for 500 orders.</p><h3>When Projections Break Down</h3><p>The moment you include collections in the projection, things change:</p><pre><code><code>group.MapGet("/projection-2", async (AppDbContext db) =&gt;
{
    var results = await db.Orders
        .Select(o =&gt; new
        {
            o.Id,
            CustomerName = o.Customer.Name,
            Items = o.Items.Select(x =&gt; x.ProductName),
            Tags = o.Tags.Select(x =&gt; x.Name)
        })
        .ToListAsync();

    return Results.Ok(results);
});</code></code></pre><p>Including both <code>Items</code> and <code>Tags</code> as collections forces EF Core to perform joins that multiply rows. Every order row gets duplicated for every item, and again for every tag. This is Cartesian explosion - and it caused a 15 second response time in testing.</p><p>&#9989; Use projections when:</p><ul><li><p>Accessing a single navigational property</p></li><li><p>Using aggregate functions (COUNT, SUM, AVG)</p></li><li><p>Projecting scalar values only</p></li></ul><p>&#10060; Avoid projections when:</p><ul><li><p>Including multiple collections in the projected shape</p></li></ul><div><hr></div><h2>Fix 2: Eager Loading</h2><p>Eager loading uses <code>Include()</code> to tell EF Core what to load upfront. You get the full object graph and map it yourself:</p><pre><code><code>group.MapGet("/eager", async (AppDbContext db) =&gt;
{
    var orders = await db.Orders
        .Include(o =&gt; o.Customer)
        .Include(o =&gt; o.Items)
        .Include(o =&gt; o.Tags)
        .AsNoTracking()
        .ToListAsync();

    var results = orders.Select(o =&gt; new OrderResult(
        o.Id,
        o.Customer.Name,
        [.. o.Items.Select(x =&gt; x.ProductName)],
        [.. o.Tags.Select(x =&gt; x.Name)])).ToList();

    return Results.Ok(results);
});</code></code></pre><p><code>AsNoTracking()</code> is essential for read-only endpoints - without it, EF Core tracks every returned entity, adding memory and CPU overhead that serves no purpose here.</p><p>The problem is the same as with <code>projection-2</code>. Multiple <code>Include()</code> calls on collections produce SQL JOINs that multiply result rows. With three collections the database returns every combination of order, item, and tag as a separate row. In testing this endpoint timed out completely - the Cartesian explosion was severe enough that the API could not return a response at all.</p><p>&#9989; Use eager loading when:</p><ul><li><p>Including a single collection</p></li><li><p>You need the full entity, not just specific columns</p></li></ul><p>&#10060; Avoid eager loading when:</p><ul><li><p>Including multiple collections simultaneously - Cartesian explosion will occur</p></li></ul><div><hr></div><h2>&#9889; Fix 3: AsSplitQuery</h2><p><code>AsSplitQuery()</code> solves Cartesian explosion when you need multiple collection includes. Instead of one JOIN-heavy query that multiplies rows, EF Core fires separate SQL queries per collection and assembles the result in memory.</p><pre><code><code>group.MapGet("/split", async (AppDbContext db) =&gt;
{
    var orders = await db.Orders
        .Include(o =&gt; o.Customer)
        .Include(o =&gt; o.Items)
        .Include(o =&gt; o.Tags)
        .AsNoTracking()
        .AsSplitQuery()
        .ToListAsync();

    var results = orders.Select(o =&gt; new OrderResult(
        o.Id,
        o.Customer.Name,
        [.. o.Items.Select(x =&gt; x.ProductName)],
        [.. o.Tags.Select(x =&gt; x.Name)])).ToList();

    return Results.Ok(results);
});</code></code></pre><p>One method call added to the eager loading endpoint. That&#8217;s it.</p><p>Result: ~1 second on the first request, ~700ms on the second. No timeout. No Cartesian explosion.</p><h3>Trade-offs to Know</h3><p><code>AsSplitQuery</code> is not free:</p><ul><li><p>High database latency multiplies the cost - each split query is a separate round-trip, so latency stacks up</p></li><li><p>No consistency guarantee - if data changes between the split queries executing, different parts of your result could reflect different database states</p></li></ul><p>&#9989; Use <code>AsSplitQuery</code> when:</p><ul><li><p>Including multiple collections that would otherwise cause Cartesian explosion</p></li><li><p>Database latency is low</p></li><li><p>You can tolerate slight inconsistency between collections</p></li></ul><p>&#10060; Avoid <code>AsSplitQuery</code> when:</p><ul><li><p>Database latency is high and you have strict performance budgets</p></li><li><p>You need guaranteed snapshot consistency across all included collections</p></li></ul><div><hr></div><h2>Key Takeaways</h2><ul><li><p>Lazy loading is disabled by default in EF Core for good reason - never re-enable it in production without understanding the 1+N query cost.</p></li><li><p>Use projections for scalar properties and aggregate functions, but avoid projecting into multiple collections or you will hit Cartesian explosion just as fast as with eager loading.</p></li><li><p>Multiple <code>Include()</code> calls on collections produce Cartesian explosion - the result set grows exponentially and can cause full request timeouts.</p></li><li><p><code>AsSplitQuery()</code> eliminates Cartesian explosion by splitting includes into separate queries - one method call away from fixing a timeout.</p></li><li><p>Always add <code>AsNoTracking()</code> for read-only queries - it removes change tracking overhead that serves no purpose when you are not updating data.</p></li></ul><h2>Connect with me</h2><ul><li><p>Follow me on LinkedIn</p></li></ul><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;Follow me&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>Follow me</span></a></p><ul><li><p>Subscribe on YouTube</p></li></ul><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;Subscribe on Youtube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>Subscribe on Youtube</span></a></p><ul><li><p><em><strong>Want to sponsor this newsletter? </strong><a href="https://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></em></p></li></ul><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[How to Set Up a Release Pipeline for ASP.NET Core in Azure DevOps]]></title><description><![CDATA[Deploy your ASP.NET Core app automatically to IIS on an Azure VM - zero manual steps required.]]></description><link>https://newsletter.remigiuszzalewski.com/p/how-to-set-up-a-release-pipeline</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/how-to-set-up-a-release-pipeline</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 14 Mar 2026 07:01:09 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/VpxdLdYIEr8" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>At some point, manually deploying your ASP.NET Core app to a server gets old fast. You merge to main, then you open Azure DevOps, kick off a release, RDP into the VM, check the logs - it&#8217;s a lot of overhead that compounds with every deploy.</p><p>The fix is a proper CD pipeline. Once it&#8217;s wired up, every successful CI build triggers a deployment automatically. Your code goes from a merge to a live site without you lifting a finger.</p><p>In this article, you&#8217;ll see the full setup: provisioning an Azure VM, enabling IIS, installing the .NET Hosting Bundle, connecting the VM to Azure DevOps via a Deployment Group, and building the Release Pipeline itself with an IIS Web App Deploy task.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-VpxdLdYIEr8" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;VpxdLdYIEr8&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/VpxdLdYIEr8?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>&#9881;&#65039; Provisioning the Azure Virtual Machine</h2><p>If you already have a VM running Windows Server with IIS, skip this section and jump straight to the Deployment Group setup.</p><p>For everyone starting fresh: in the Azure Portal, create a new Virtual Machine with these settings:</p><ul><li><p>OS: Windows Server 2025 Datacenter Gen 2</p></li><li><p>Inbound ports: HTTP and HTTPS only</p></li><li><p>RDP: do not open to all IPs - lock it down after creation</p></li></ul><p>On RDP specifically - once the VM is created, go to the Network Security Group and add an inbound rule that allows RDP only from your own public IP. If you leave RDP open to the world and your credentials ever leak, the machine is exposed. Check your current IP at <a href="https://whatismyip.com">whatismyip.com</a>, then create the rule with that IP as the source.</p><div><hr></div><h2>&#127760; Enabling IIS and Installing the .NET Hosting Bundle</h2><p>Connect to the VM via RDP and do two things before touching Azure DevOps.</p><h3>Enable IIS</h3><p>Open Server Manager, go to Manage &gt; Add Roles and Features, and enable the Web Server (IIS) role. Once installed, open IIS Manager from the Tools menu - you should see a Default Web Site already present.</p><h3>Install the .NET Hosting Bundle</h3><p>Your ASP.NET Core app needs the Hosting Bundle installed on the server - not just the runtime. Open a command prompt and run:</p><pre><code><code>dotnet --list-runtimes</code></code></pre><p>If you get &#8220;command not found&#8221; or an empty list, the bundle is not installed. Download the correct version from the official .NET download page (in this case .NET 10) and select the Hosting Bundle option - not the SDK or the runtime alone.</p><p>After installation, restart the VM and verify:</p><pre><code><code>dotnet --list-runtimes</code></code></pre><p>You should now see the installed runtime. In IIS Manager, go to the server node &gt; Modules, and confirm that <code>AspNetCoreModuleV2</code> is listed. That module is what allows IIS to host ASP.NET Core apps as a reverse proxy.</p><p>Key points:</p><ul><li><p>Always install the Hosting Bundle, not just the runtime</p></li><li><p>The ASP.NET Core Module V2 must appear in IIS Modules before any deployment</p></li><li><p>Match the bundle version to the target framework of your application</p></li></ul><div><hr></div><h2>&#128279; Connecting the VM to Azure DevOps via a Deployment Group</h2><p>A Deployment Group is how Azure DevOps establishes a trusted connection to your server. Once registered, the VM appears as a deployment target inside your release pipeline.</p><p>In Azure DevOps, go to Pipelines &gt; Deployment Groups and click Add a deployment group. Give it a name - for example <code>ProductionDeploymentGroup</code>.</p><p>Azure DevOps will generate a PowerShell registration script. Check the option to use a Personal Access Token for authentication, then copy the script.</p><p>On the VM, open PowerShell as Administrator, paste the script, and press Enter through the default prompts. When the script completes, go back to the Deployment Groups page in Azure DevOps and refresh - you should see your VM listed with a status of Online and Healthy.</p><p>Key points:</p><ul><li><p>Run the registration script with admin privileges</p></li><li><p>The agent installed by the script runs as a Windows service on the VM</p></li><li><p>One Deployment Group can hold multiple VMs for multi-server deployments</p></li></ul><div><hr></div><h2>&#128640; Building the Release Pipeline</h2><p>Now for the CD pipeline itself.</p><p>Go to Pipelines &gt; Releases &gt; New Pipeline. You will configure two things: the artifact source and the deployment stage.</p><h3>Artifact Source</h3><p>Click Add an artifact and select your CI build pipeline as the source. Enable the Continuous Deployment trigger (the lightning bolt icon). This is what makes every successful CI build automatically kick off a new release - no manual intervention needed.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!2uKL!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!2uKL!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 424w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 848w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 1272w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!2uKL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png" width="486" height="500" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:500,&quot;width&quot;:486,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:63409,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://remigiuszzalewski.substack.com/i/190807119?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!2uKL!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 424w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 848w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 1272w, https://substackcdn.com/image/fetch/$s_!2uKL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F64f100a8-353e-4e19-ba06-84eb1853421f_486x500.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h3>Deployment Stage</h3><p>Add a stage and select the IIS Website Deployment template. Rename the stage to something like <code>Deploy to IIS Server</code> for clarity.</p><p>Click into the stage tasks. You will configure three tasks:</p><p><strong>Deployment process</strong></p><p>Setup the binding that you would like to associate your ASP.NET Core application with. In my case it will be port 81 with http. Choose your desired website name.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!-u6f!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!-u6f!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 424w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 848w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 1272w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!-u6f!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png" width="436" height="632" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/f83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:632,&quot;width&quot;:436,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:61382,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://remigiuszzalewski.substack.com/i/190807119?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!-u6f!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 424w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 848w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 1272w, https://substackcdn.com/image/fetch/$s_!-u6f!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff83c6788-c6f5-4ad4-8446-c2ce5db143de_436x632.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>IIS Deployment</strong></p><ul><li><p>Set the Deployment Group to <code>ProductionDeploymentGroup</code></p></li><li><p>Enable Allow scripts to access the OAuth token</p><p></p></li></ul><p><strong>IIS Web App Manage</strong></p><p>Configure the website and app pool that Azure DevOps will create or update on the VM:</p><ul><li><p>Website name: <code>TaskManager</code> (or your app name) - will be automatically fetched from previous step</p></li><li><p>Physical path: a dedicated folder on the VM, e.g. <code>%SystemDrive%\inetpub\wwwroot\TaskManager</code></p></li><li><p>Physical path authentication: Application User</p></li><li><p>Binding port: automatically fetched from the first step</p></li><li><p>App pool name: <code>TaskManager</code></p></li><li><p>.NET CLR version: No Managed Code (required for ASP.NET Core)</p></li><li><p>Identity: Custom Account - use pipeline variables for credentials (see below)</p></li></ul><p><strong>IIS Web App Deploy</strong></p><p>Leave the Package or Folder field pointing to the artifact zip from your CI pipeline. Azure DevOps populates this automatically when you selected the artifact source.</p><h3>Pipeline Variables</h3><p>Instead of hardcoding VM credentials, use pipeline variables. Go to the Variables tab and add:</p><ul><li><p><code>username</code> - your VM admin username</p></li><li><p><code>password</code> - your VM admin password (mark as secret)</p></li></ul><p>Reference them in the app pool identity fields as <code>$(username)</code> and <code>$(password)</code>.</p><div><hr></div><h2>Running Your First Release</h2><p>Save the pipeline as <code>CD-[YourAppName]</code> and click Create a Release. Watch the logs - each task should complete with a green tick.</p><p>Once the pipeline finishes, open IIS Manager on the VM. Your site should appear alongside the Default Web Site. Click Browse and confirm the application loads. If you have a Swagger endpoint or scalar, navigate to <code>/swagger</code> or<code>/scalar </code>to verify the API is responding correctly.</p><p>From this point forward, every push that triggers your CI build will also trigger this release pipeline and deploy the latest version automatically.</p><div><hr></div><h2>Key Takeaways</h2><ul><li><p>Register your VM with Azure DevOps via a Deployment Group before building any release pipeline - it is the foundation of the connection.</p></li><li><p>Always install the .NET Hosting Bundle on the IIS server, not just the runtime, and verify that AspNetCoreModuleV2 is present in IIS Modules.</p></li><li><p>Lock RDP access to your specific IP in the Network Security Group - never leave it open to all addresses.</p></li><li><p>Use pipeline variables for VM credentials instead of hardcoding them in the pipeline configuration.</p></li><li><p>Enable the Continuous Deployment trigger on your artifact source to achieve true CD - zero manual steps from code push to live deployment.</p></li></ul><p>Connect with me:</p><ul><li><p>Follow me on LinkedIn</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;Linkedin&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>Linkedin</span></a></p></li><li><p>Subscribe on YouTube</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p></p></li><li><p>Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorships">Let&#8217;s work together &#8594;</a></p></li></ul><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Setup CI Build Pipeline in Azure DevOps for ASP.NET Core Web API]]></title><description><![CDATA[From zero to a working CI pipeline that builds your .NET app, runs unit tests, and produces a deployable artifact - step by step.]]></description><link>https://newsletter.remigiuszzalewski.com/p/setup-ci-build-pipeline-in-azure</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/setup-ci-build-pipeline-in-azure</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 07 Mar 2026 07:01:24 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/vBUpk393onI" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>Let&#8217;s say you have a .NET application on your local PC and you&#8217;d like to start using Azure DevOps - to store your code, automate builds, and run unit tests on every change.</p><p>Where do you even start?</p><p>In this article we&#8217;ll go from point zero - Visual Studio and your .NET code on your machine - to a fully working CI (Continuous Integration) pipeline in Azure DevOps that:</p><ul><li><p>Pushes your existing code to Azure Repos</p></li><li><p>Builds your application on every merge to main</p></li><li><p>Runs your unit tests automatically</p></li><li><p>Produces a deployable build artifact (a ZIP of your published app)</p></li><li><p>Validates every pull request before it can be merged - separated PR verification pipeline</p></li></ul><p>&#127916; Watch the full video here:</p><div id="youtube2-vBUpk393onI" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;vBUpk393onI&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/vBUpk393onI?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h3>Setting Up Azure DevOps</h3><p>If you&#8217;re starting from scratch, follow these steps:</p><ul><li><p>Create a Microsoft account if you don&#8217;t have one</p></li><li><p>Go to Azure DevOps and select &#8220;Get started for free&#8221;</p></li><li><p>Create an <strong>organization</strong></p></li><li><p>Create a new <strong>project</strong> inside that organization</p></li></ul><p>Once inside your project you&#8217;ll see the main sections: Boards, Repos, Pipelines, Test Plans, and Artifacts. We&#8217;ll be working mostly with Repos and Pipelines.</p><div><hr></div><h3>Pushing Your Code to Azure Repos</h3><p>Navigate to <strong>Repos &#8594; Files</strong>. Azure DevOps gives you a few options to get your code in:</p><ul><li><p><strong>Push an existing local repository</strong> - use the git commands shown on the page to add Azure Repos as a remote and push your full commit history</p></li><li><p><strong>Import from GitHub</strong> - paste your GitHub repo URL and provide credentials if needed</p></li><li><p><strong>Initialize a new empty repository</strong> - useful if you&#8217;re starting fresh; clone it locally, copy your code in, then commit and push</p></li></ul><p>In the tutorial I created a new repository called <code>TaskManager</code> and used the &#8220;push an existing repository&#8221; command since the project was already using Git locally:</p><pre><code><code>git remote add origin &lt;azure-repos-url&gt;
git push -u origin --all</code></code></pre><p>After refreshing the page, all code and full commit history appeared in Azure Repos.</p><p><strong>If you&#8217;re not using Git yet</strong>, the recommended path is: create the repo with the Visual Studio .gitignore template &#8594; clone it locally &#8594; copy your code in &#8594; git add, commit, and push.</p><div><hr></div><h3>The Two YAML Pipeline Files</h3><p>All pipeline logic lives in YAML files that you version alongside your code. We&#8217;ll create two:</p><ul><li><p><strong>Build.yaml</strong> - triggers on merges to main, builds the app, runs tests, publishes the app, and uploads the artifact</p></li><li><p><strong>PullRequestVerification.yaml</strong> - triggers on pull requests targeting main, builds the app and runs tests as a quality gate</p></li></ul><p>Create both files by right-clicking the solution in Visual Studio &#8594; Add &#8594; New Item.</p><div><hr></div><p>Build.yaml - CI Build Pipeline</p><p>This pipeline runs every time a commit lands on main (e.g. after a PR is merged). It builds the app, runs tests, publishes, and uploads the artifact.</p><pre><code><code>trigger:
  branches:
    include:
      - main
  paths:
    exclude:
      - "*.md"

variables:
  buildConfiguration: "Release"
  dotnetVersion: "10.0.x"
  solutionPath: "TaskManager.slnx"
  unitTestsPath: "TaskManager.Application.Tests/TaskManager.Application.Tests.csproj"

pool:
  vmImage: "ubuntu-latest"

steps:
  - task: UseDotNet@2
    displayName: "Install .NET $(dotnetVersion)"
    inputs:
      packageType: "sdk"
      version: $(dotnetVersion)

  - task: DotNetCoreCLI@2
    displayName: "Restore"
    inputs:
      command: "restore"
      projects: $(solutionPath)

  - task: DotNetCoreCLI@2
    displayName: "Build"
    inputs:
      command: "build"
      projects: $(solutionPath)
      arguments: "--configuration $(buildConfiguration) --no-restore"

  - task: DotNetCoreCLI@2
    displayName: "Run unit tests"
    inputs:
      command: "test"
      projects: $(unitTestsPath)
      arguments: "--configuration $(buildConfiguration) --no-build"
      publishTestResults: true

  - task: DotNetCoreCLI@2
    displayName: "dotnet publish"
    inputs:
      command: "publish"
      projects: "TaskManager.API/TaskManager.API.csproj"
      arguments: "--configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)/drop"

  - task: PublishBuildArtifacts@1
    displayName: "Upload artifact"
    inputs:
      PathtoPublish: "$(Build.ArtifactStagingDirectory)/drop"
      ArtifactName: "drop"
      publishLocation: "Container"</code></code></pre><p>A few things worth noting:</p><ul><li><p><strong>Markdown files are excluded</strong> from the trigger - changes to docs don&#8217;t cause a rebuild</p></li><li><p><strong>--no-restore and --no-build flags</strong> avoid repeating work already done in earlier steps</p></li><li><p><strong>Build.ArtifactStagingDirectory</strong> is a temporary holding area on the agent; the PublishBuildArtifacts task is what actually ships those files to Azure DevOps storage so they&#8217;re accessible from the pipeline UI and can be picked up by a release pipeline for deployment</p></li></ul><div><hr></div><p>PullRequestVerification.yaml - PR Quality Gate</p><p>This pipeline is the gatekeeper for the main branch. It runs on every pull request targeting main - not on direct commits.</p><pre><code><code>trigger: none

pr:
  branches:
    include:
      - main
  paths:
    exclude:
      - "*.md"

variables:
  buildConfiguration: "Release"
  dotnetVersion: "10.0.x"
  solutionPath: "TaskManager.slnx"
  unitTestsPath: "TaskManager.Application.Tests/TaskManager.Application.Tests.csproj"

pool:
  vmImage: "ubuntu-latest"

steps:
  - task: UseDotNet@2
    displayName: "Install .NET $(dotnetVersion)"
    inputs:
      packageType: "sdk"
      version: $(dotnetVersion)

  - task: DotNetCoreCLI@2
    displayName: "Restore"
    inputs:
      command: "restore"
      projects: $(solutionPath)

  - task: DotNetCoreCLI@2
    displayName: "Build"
    inputs:
      command: "build"
      projects: $(solutionPath)
      arguments: "--configuration $(buildConfiguration) --no-restore"

  - task: DotNetCoreCLI@2
    displayName: "Run unit tests"
    inputs:
      command: "test"
      projects: $(unitTestsPath)
      arguments: "--configuration $(buildConfiguration) --no-build"
      publishTestResults: true</code></code></pre><p><code>trigger: none</code> explicitly disables push-based triggers. The <code>pr</code> block is what activates this pipeline - whenever someone opens or updates a pull request targeting main, this kicks off automatically.</p><div><hr></div><h3>Creating the Pipelines in Azure DevOps</h3><p>Push both YAML files to main, then:</p><ul><li><p>Go to <strong>Pipelines &#8594; Pipelines &#8594; New Pipeline</strong></p></li><li><p>Select <strong>Azure Repos Git</strong> &#8594; choose your repository</p></li><li><p>Select <strong>Existing Azure Pipelines YAML file</strong></p></li><li><p>Pick <code>/build.yaml</code> &#8594; Save</p></li><li><p>Repeat for <code>/pull-request-verification.yaml</code></p></li></ul><p>Rename them for clarity via the three-dot menu:</p><ul><li><p><code>CI-Build&#8211;Task Manager</code></p></li><li><p><code>PullRequestVerification&#8211;TaskManager</code></p></li></ul><div><hr></div><p>Enabling Branch Policy on Main</p><p>To enforce the PR pipeline as a required check before merging:</p><ul><li><p>Go to <strong>Repos &#8594; Branches</strong></p></li><li><p>Click the three dots next to <code>main</code> &#8594; <strong>Branch policies</strong></p></li><li><p>Under <strong>Build validation</strong> &#8594; <strong>Add build policy</strong></p></li><li><p>Select the <code>PullRequestVerification&#8211;TaskManager</code> pipeline</p></li><li><p>Set trigger to <strong>Automatic</strong>, policy to <strong>Required</strong></p></li><li><p>Save</p></li></ul><p>Now no pull request can be merged to main unless the build and tests pass.</p><div><hr></div><p>End-to-End Test</p><p>To verify everything works:</p><ul><li><p>Create a feature branch</p></li><li><p>Make a small change and push</p></li><li><p>Open a pull request targeting main</p></li></ul><p>You&#8217;ll see the PR verification pipeline queue automatically. Once it passes (build succeeded, all unit tests green), you can complete the merge.</p><p>After merging, the CI Build pipeline kicks off on main - builds, tests, publishes, and uploads the artifact. The result is a <code>TaskManager.API.zip</code> in the Artifacts tab, ready for deployment.</p><div><hr></div><p>Key Takeaways</p><ul><li><p><strong>Azure Repos</strong> stores your code and full Git history - push your existing repo with a single command</p></li><li><p><strong>Build.yaml</strong> - CI pipeline triggered on main; builds, tests, publishes, and uploads a deployable artifact</p></li><li><p><strong>PullRequestVerification.yaml</strong> - PR gate; builds and runs tests before any merge is allowed</p></li><li><p><strong>trigger: none + pr block</strong> - the correct way to restrict a pipeline to PR events only</p></li><li><p><strong>--no-restore / --no-build flags</strong> - skip redundant work between pipeline steps</p></li><li><p><strong>PublishBuildArtifacts</strong> - transfers files from the build agent to Azure DevOps storage; without this step, the artifact doesn&#8217;t exist outside the agent</p></li><li><p><strong>Branch policies</strong> - enforce the PR pipeline as a required check on main so broken code can never be merged</p></li></ul><div><hr></div><p>What&#8217;s Next?</p><p>The next tutorial will cover creating a <strong>release pipeline</strong> that picks up this artifact and deploys it to an environment. Make sure to subscribe so you don&#8217;t miss it.</p><div><hr></div><p>Resources</p><ul><li><p>Azure DevOps Documentation: <a href="https://learn.microsoft.com/en-us/azure/devops/">https://learn.microsoft.com/en-us/azure/devops/</a></p></li><li><p>Azure Pipelines YAML reference: <a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/">https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/</a></p></li></ul><p><strong>Connect with me:</strong></p><ul><li><p>Follow me on LinkedIn</p></li></ul><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;Follow me&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>Follow me</span></a></p><ul><li><p>Subscribe on YouTube</p></li></ul><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;Subscribe on Youtube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>Subscribe on Youtube</span></a></p><p><em><strong>Want to sponsor this newsletter? </strong><a href="https://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></em></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[Insane Performance Boost in EF Core using Entity Framework Extensions]]></title><description><![CDATA[Learn how to replace slow EF Core bulk operations with Entity Framework Extensions]]></description><link>https://newsletter.remigiuszzalewski.com/p/insane-performance-boost-in-ef-core</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/insane-performance-boost-in-ef-core</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 28 Feb 2026 07:01:32 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/COaKjf0Nqws" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p>&#128640; <strong>Sponsored</strong></p><p>Struggling with slow EF Core operations? With <a href="https://entityframework-extensions.net/">ZZZ Projects&#8217; EF Core Extensions</a>, you can boost performance like never before. Experience up to <strong>14&#215; faster Bulk Insert, Update, Delete, and Merge</strong> &#8212; and cut your save time by as much as <strong>94%</strong>.</p><p>&#128073; <a href="https://entityframework-extensions.net/">entityframework-extensions.net</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>Imagine that you&#8217;ve built a great application using .NET and Entity Framework Core. You start to process thousands - or tens of thousands - of records at once, and you notice a huge bottleneck: your application is getting slower and slower.</p><p>Standard EF Core is like a delivery driver who takes one package at a time to the same house, driving back and forth a thousand times. Entity Framework Extensions is the freight truck that delivers all 1,000 packages in a single trip.</p><p>In this article I&#8217;ll show you how to use the Entity Framework Extensions library by ZZZ Projects to significantly improve the performance of your .NET application. We&#8217;ll compare standard EF Core and EF Extensions on real use-case scenarios with actual benchmark numbers.</p><p>In this article, I&#8217;ll walk you through:</p><ul><li><p>Installing the EF Extensions NuGet package</p></li><li><p>Why EF Core slows down at scale</p></li><li><p>BulkInsert, BulkInsertOptimized</p></li><li><p>BulkUpdate, BulkDelete</p></li><li><p>BulkMerge (upsert)</p></li><li><p>BulkSynchronize (upsert + delete missing)</p></li><li><p>BulkSaveChanges</p></li></ul><p>&#127916; Watch the full video here:</p><div id="youtube2-COaKjf0Nqws" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;COaKjf0Nqws&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/COaKjf0Nqws?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>Why Standard EF Core Struggles at Scale</h2><p>EF Core&#8217;s SaveChanges() is convenient - it tracks entity state and handles inserts, updates, and deletes automatically. But it generates one SQL statement per entity. Insert 10,000 products? That&#8217;s 10,000 round trips.</p><p>There are two reasons this kills performance:</p><p><strong>1. The change tracker becomes a bottleneck.</strong> Tracking the state of 50,000 objects in memory consumes massive amounts of CPU and RAM. Entity Framework Extensions allows you to process data without the overhead of tracking every single property change - freeing your server to handle actual logic instead of managing memory.</p><p><strong>2. Too many database round trips.</strong> You can see it directly in the console: EF Core floods it with individual INSERT statements, one per record. EF Extensions reduces all of those into a single bulk operation.</p><div><hr></div><p>Installing Entity Framework Extensions</p><p><strong>NuGet package:</strong></p><pre><code><code>dotnet add package Z.EntityFramework.Extensions.EFCore</code></code></pre><p>Or search for Z.EntityFramework.Extensions.EFCore directly in the Visual Studio NuGet package manager.</p><p>The package supports .NET 10 and works with:</p><ul><li><p>SQL Server</p></li><li><p>PostgreSQL</p></li><li><p>MySQL</p></li><li><p>Oracle</p></li><li><p>SQLite</p></li></ul><p><strong>Website and documentation:</strong> </p><p><a href="https://entityframework-extensions.net">https://entityframework-extensions.net</a> - full docs, online benchmarks, and live examples.</p><p><strong>Licensing:</strong> A free trial is available so you can evaluate the library end-to-end. For commercial projects, a paid license is required from ZZZ Projects. Check the pricing page once you&#8217;ve benchmarked it against your own workloads.</p><div><hr></div><h2>Benchmarks at a Glance</h2><p>All results below are measured on 10,000 records against a PostgreSQL database.</p><ul><li><p>Insert: ~2,800 ms &#8594; 177 ms (~16&#215;)</p></li><li><p>Insert Optimized: ~2,800 ms &#8594; 72 ms (~39&#215;)</p></li><li><p>Update: ~1,500 ms &#8594; 100 ms (~15&#215;)</p></li><li><p>Delete: ~800 ms &#8594; 31 ms (~26&#215;)</p></li><li><p>Merge (upsert): ~1,800 ms &#8594; ~100 ms (~18&#215;)</p></li><li><p>Synchronize: ~1,700 ms &#8594; 126 ms (~13&#215;)</p></li><li><p>BulkSaveChanges: ~1,400 ms &#8594; 425 ms (~3&#215;)</p></li></ul><div><hr></div><h3>Bulk Insert</h3><p>EF Core (standard):</p><pre><code><code>await context.Products.AddRangeAsync(products);
await context.SaveChangesAsync();</code></code></pre><p>EF Extensions:</p><pre><code><code>await context.BulkInsertAsync(products);</code></code></pre><p>Result: <strong>~2,800 ms &#8594; 177 ms</strong>.</p><p>Need even more speed and don&#8217;t need output values (like generated IDs) back? Use the optimized variant:</p><pre><code><code>await context.BulkInsertOptimizedAsync(products);</code></code></pre><p>This skips the output value pass-back entirely, dropping the operation to <strong>72 ms</strong> -roughly 39&#215; faster than standard EF Core.</p><div><hr></div><h3>Bulk Update</h3><p>EF Core:</p><pre><code><code>foreach (var p in products)
    p.Price *= 1.10m;
await context.SaveChangesAsync();</code></code></pre><p>EF Extensions:</p><pre><code><code>foreach (var p in products)
    p.Price *= 1.10m;
await context.BulkUpdateAsync(products);</code></code></pre><p>Result: <strong>~1,500 ms &#8594; 100 ms</strong> - 15&#215; faster.</p><div><hr></div><h3>Bulk Delete</h3><p>EF Core:</p><pre><code><code>context.Products.RemoveRange(products);
await context.SaveChangesAsync();</code></code></pre><p>EF Extensions:</p><pre><code><code>await context.BulkDeleteAsync(products);</code></code></pre><p>Result: <strong>~800 ms &#8594; 31 ms</strong> - over 25&#215; faster.</p><div><hr></div><h3>Bulk Merge (Upsert)</h3><p>A merge inserts new records and updates existing ones in a single operation. EF Extensions automatically detects what needs to be inserted vs. updated.</p><p>EF Core (manual):</p><pre><code><code>foreach (var p in existing)
    p.Price *= 1.15m;
context.Products.AddRange(incoming);
await context.SaveChangesAsync();</code></code></pre><p>EF Extensions:</p><pre><code><code>foreach (var p in existing)
    p.Price *= 1.15m;
var all = existing.Concat(incoming).ToList();
await context.BulkMergeAsync(all);</code></code></pre><p>Result (5,000 updates + 5,000 inserts = 15,000 total): <strong>~1,800 ms &#8594; ~100 ms</strong>.</p><div><hr></div><h3>Bulk Synchronize</h3><p>Synchronize goes one step further than Merge - it also deletes records that are not in your source list:</p><ul><li><p>Rows that match the entity key are <strong>updated</strong></p></li><li><p>Rows in the source but not in the database are <strong>inserted</strong></p></li><li><p>Rows in the database but not in the source are <strong>deleted</strong></p></li></ul><p>Think of it as mirroring your source to the database.</p><p>EF Core (manual - a lot of code to get right):</p><pre><code><code>var existingIds = source.Where(p =&gt; p.Id &gt; 0).Select(p =&gt; p.Id).ToHashSet();
var toDelete = await context.Products
    .Where(p =&gt; !existingIds.Contains(p.Id)).ToListAsync();
context.Products.RemoveRange(toDelete);
foreach (var p in source.Where(p =&gt; p.Id &gt; 0))
    context.Products.Update(p);
context.Products.AddRange(source.Where(p =&gt; p.Id == 0));
await context.SaveChangesAsync();</code></code></pre><p>EF Extensions:</p><pre><code><code>await context.BulkSynchronizeAsync(source);</code></code></pre><p>Result: <strong>~1,700 ms &#8594; 126 ms</strong> - and a fraction of the code.</p><div><hr></div><h3>Bulk SaveChanges</h3><p>Already using the EF Core change tracker with a mix of Add, Update, and Delete? Drop in BulkSaveChangesAsync() as a near-zero-friction replacement:</p><pre><code><code>await context.BulkSaveChangesAsync(); // instead of SaveChangesAsync()</code></code></pre><p>This is the easiest migration path if you have existing code and just want a performance lift.</p><p>Result on a mixed Add/Update/Delete workload: <strong>~1,400 ms &#8594; 425 ms</strong>.</p><div><hr></div><h3>When Should You Reach for This?</h3><p>You don&#8217;t need bulk operations for typical CRUD in a web app handling a handful of records at a time. But if any of these apply, the standard EF Core approach will hurt:</p><ul><li><p>Importing data from CSVs or external feeds</p></li><li><p>Syncing records between systems</p></li><li><p>Running nightly price or inventory updates across thousands of rows</p></li><li><p>Handling data migrations</p></li></ul><p>A one-line swap to BulkInsertAsync, BulkMergeAsync, or BulkSynchronizeAsync makes a real difference.</p><div><hr></div><h3>Key Takeaways</h3><ul><li><p>BulkInsertAsync - replaces AddRangeAsync + SaveChangesAsync, up to 16&#215; faster</p></li><li><p>BulkInsertOptimizedAsync - skips output values for maximum insert speed (~40&#215;)</p></li><li><p>BulkUpdateAsync - replaces foreach + SaveChangesAsync, 15&#215; faster</p></li><li><p>BulkDeleteAsync - replaces RemoveRange + SaveChangesAsync, 25&#215; faster</p></li><li><p>BulkMergeAsync - upsert in one call, auto-detects insert vs. update</p></li><li><p>BulkSynchronizeAsync - upsert + deletes records missing from source</p></li><li><p>BulkSaveChangesAsync - drop-in replacement for SaveChangesAsync with change tracker</p></li><li><p>Free trial available - commercial license required for production use</p></li></ul><div><hr></div><p><strong>Resources</strong></p><ul><li><p>Entity Framework Extensions:</p></li></ul><p><a href="https://entityframework-extensions.net">https://entityframework-extensions.net</a></p><ul><li><p>EF Core Documentation: <a href="https://learn.microsoft.com/en-us/ef/core/">https://learn.microsoft.com/en-us/ef/core/</a></p></li></ul><p><strong>Connect with me:</strong></p><ul><li><p>Follow me on LinkedIn</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;Follow me&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>Follow me</span></a></p><p></p></li><li><p>Subscribe on YouTube</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;Subscribe on Youtube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>Subscribe on Youtube</span></a></p><p></p></li><li><p><em><strong>Want to sponsor this newsletter? </strong><a href="https://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></em></p></li></ul><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[Authentication using ASP.NET Core Identity (.NET 10)]]></title><description><![CDATA[Add authentication to your .NET 10 Web API in minutes using the built-in Identity API endpoints - no custom controllers needed.]]></description><link>https://newsletter.remigiuszzalewski.com/p/authentication-with-aspnet-core-identity</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/authentication-with-aspnet-core-identity</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 21 Feb 2026 07:01:48 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/g9YSB4bXhes" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p>&#128640; <strong>Sponsored</strong></p><p>Struggling with slow EF Core operations? With <a href="https://entityframework-extensions.net/">ZZZ Projects&#8217; EF Core Extensions</a>, you can boost performance like never before. Experience up to <strong>14&#215; faster Bulk Insert, Update, Delete, and Merge</strong> &#8212; and cut your save time by as much as <strong>94%</strong>.</p><p>&#128073; <a href="https://entityframework-extensions.net/">entityframework-extensions.net</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>If you&#8217;ve ever built a .NET Web API that needed authentication, you know the drill: create an <code>AuthController</code>, wire up <code>UserManager</code>, write register/login endpoints from scratch...</p><p>It&#8217;s a lot of boilerplate.</p><p><strong>Not anymore.</strong></p><p>Starting with .NET 8 and improved in .NET 10, ASP.NET Core ships with <strong>Identity API Endpoints</strong> - a set of ready-made endpoints for registration, login, token refresh, and more, all backed by ASP.NET Core Identity and Bearer tokens.</p><p>In this article, I&#8217;ll walk you through setting up authentication from zero in a .NET 10 Minimal API project:</p><ol><li><p><strong>Installing and configuring Identity + EF Core</strong></p></li><li><p><strong>Running database migrations</strong></p></li><li><p><strong>Using the built-in Identity endpoints</strong></p></li><li><p><strong>Protecting routes with </strong><code>RequireAuthorization()</code></p></li><li><p><strong>Authenticating with Bearer tokens</strong></p></li></ol><p>&#127916; <strong>Watch the full video here</strong>:</p><div id="youtube2-g9YSB4bXhes" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;g9YSB4bXhes&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/g9YSB4bXhes?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><h2>What are Identity API Endpoints?</h2><p><code>MapIdentityApi&lt;TUser&gt;()</code> is a single method call that maps all the authentication endpoints you need:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;plaintext&quot;,&quot;nodeId&quot;:&quot;ecf68bf4-188f-4176-9574-4c95e9378de3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-plaintext">/register - POST - Register a new user
/login - POST - Login and receive a Bearer token
/refresh - POST - Refresh an expired token
/confirmEmail - GET - Confirm email address
/manage/info - GET / POST - Get or update user info</code></pre></div><p>No controllers. Just one line.</p><div><hr></div><h2>Project Setup</h2><h3>1. Create the Project</h3><pre><code><code>dotnet new webapi -n IdentityApiDemo --use-minimal-apis
cd IdentityApiDemo</code></code></pre><p>Make sure you&#8217;re targeting .NET 10 in your <code>.csproj</code>:</p><pre><code><code>&lt;Project Sdk="Microsoft.NET.Sdk.Web"&gt;
  &lt;PropertyGroup&gt;
    &lt;TargetFramework&gt;net10.0&lt;/TargetFramework&gt;
    &lt;Nullable&gt;enable&lt;/Nullable&gt;
    &lt;ImplicitUsings&gt;enable&lt;/ImplicitUsings&gt;
  &lt;/PropertyGroup&gt;
&lt;/Project&gt;</code></code></pre><h3>2. Install Required Packages</h3><pre><code><code>dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools</code></code></pre><p>Or if you prefer SQLite for local development:</p><pre><code><code>dotnet add package Microsoft.EntityFrameworkCore.Sqlite</code></code></pre><div><hr></div><h2>The Data Model</h2><h3>AppDbContext &#8212; Wiring Up Identity</h3><p>csharp</p><pre><code><code>public class AppDbContext : IdentityDbContext
{
    public AppDbContext(DbContextOptions&lt;AppDbContext&gt; options) 
        : base(options) { }
}</code></code></pre><p><strong>Key point:</strong> Inherit from <code>IdentityDbContext</code> instead of plain <code>DbContext</code>. This automatically includes all the Identity tables (<code>AspNetUsers</code>, <code>AspNetRoles</code>, <code>AspNetUserTokens</code>, etc.) &#8212; no extra configuration needed.</p><div><hr></div><h2>Program.cs Setup</h2><p>Here&#8217;s the full <code>Program.cs</code>:</p><pre><code><code>using IdentityApiDemo.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// 1. Add OpenAPI
builder.Services.AddOpenApi();

// 2. Configure DbContext
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");

builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseSqlServer(connectionString));

// 3. Configure Identity with Bearer token authentication
builder.Services
    .AddIdentityApiEndpoints&lt;IdentityUser&gt;()
    .AddEntityFrameworkStores&lt;AppDbContext&gt;();

// 4. Add Authorization
builder.Services.AddAuthorization();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseHttpsRedirection();

// 5. Map the built-in Identity endpoints
app.MapIdentityApi&lt;IdentityUser&gt;();

// 6. Your protected endpoints
app.MapGet("/me", (ClaimsPrincipal user) =&gt;
{
    return Results.Ok(new 
    { 
        Email = user.FindFirstValue(ClaimTypes.Email),
        Id = user.FindFirstValue(ClaimTypes.NameIdentifier)
    });
})
.RequireAuthorization();

app.MapGet("/public", () =&gt; Results.Ok("Anyone can see this!"));

await app.RunAsync();</code></code></pre><h3>What&#8217;s happening here?</h3><ul><li><p><code>AddIdentityApiEndpoints&lt;IdentityUser&gt;()</code> &#8212; registers Identity services <strong>and</strong> configures Bearer token authentication in one call. No need to manually call <code>AddAuthentication().AddBearerToken()</code>.</p></li><li><p><code>AddEntityFrameworkStores&lt;AppDbContext&gt;()</code> &#8212; tells Identity to use your EF Core context for persistence.</p></li><li><p><code>MapIdentityApi&lt;IdentityUser&gt;()</code> &#8212; maps all the <code>/register</code>, <code>/login</code>, <code>/refresh</code>, etc. routes.</p></li><li><p><code>RequireAuthorization()</code> &#8212; protects an endpoint; returns <code>401 Unauthorized</code> if no valid token is provided.</p></li></ul><div><hr></div><h2>Database Migration</h2><h3>1. Add the Initial Migration</h3><pre><code><code>dotnet ef migrations add InitialIdentitySchema</code></code></pre><p>This generates a migration that creates all the Identity tables:</p><ul><li><p><code>AspNetUsers</code> &#8212; your user accounts</p></li><li><p><code>AspNetRoles</code> &#8212; role definitions</p></li><li><p><code>AspNetUserRoles</code> &#8212; user-role assignments</p></li><li><p><code>AspNetUserClaims</code> &#8212; user claims</p></li><li><p><code>AspNetUserTokens</code> &#8212; tokens (refresh tokens live here)</p></li><li><p><code>AspNetUserLogins</code> &#8212; external login providers</p></li></ul><h3>2. Apply the Migration</h3><pre><code><code>dotnet ef database update</code></code></pre><p>Or apply it programmatically on startup:</p><pre><code><code>// In Program.cs, before app.Run()
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
    await db.Database.MigrateAsync();
}</code></code></pre><p><strong>Important:</strong> The automatic migration approach is convenient in development but should be handled carefully in production. Consider using a deployment pipeline to run migrations separately.</p><div><hr></div><h2>Using the Identity Endpoints</h2><h3>Register a New User</h3><pre><code><code>POST /register
Content-Type: application/json

{
  "email": "john@example.com",
  "password": "SecurePass123!"
}</code></code></pre><p><strong>Response: </strong><code>200 OK</code> (empty body on success)</p><div><hr></div><h3>Login and Get a Bearer Token</h3><pre><code><code>POST /login
Content-Type: application/json

{
  "email": "john@example.com",
  "password": "SecurePass123!"
}</code></code></pre><p><strong>Response:</strong></p><pre><code><code>{
  "tokenType": "Bearer",
  "accessToken": "eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 3600,
  "refreshToken": "CfDJ8NzFy..."
}</code></code></pre><p>The <code>accessToken</code> is a standard Bearer token. Use it in subsequent requests.</p><div><hr></div><h3>Call a Protected Endpoint</h3><pre><code><code>GET /me
Authorization: Bearer eyJhbGciOiJodHRwOi8vd3d3...</code></code></pre><p><strong>Response:</strong></p><pre><code><code>{
  "email": "john@example.com",
  "id": "a3f4b2c1-..."
}</code></code></pre><p>Without the token (or with an expired one):</p><pre><code><code>401 Unauthorized</code></code></pre><div><hr></div><h3>Refresh an Expired Token</h3><pre><code><code>POST /refresh
Content-Type: application/json

{
  "refreshToken": "CfDJ8NzFy..."
}</code></code></pre><p><strong>Response:</strong></p><pre><code><code>{
  "tokenType": "Bearer",
  "accessToken": "eyJhbGciOi...",
  "expiresIn": 3600,
  "refreshToken": "CfDJ8Abc..."
}</code></code></pre><p>The old refresh token is invalidated and a new one is issued.</p><div><hr></div><h2>Protecting Endpoints with RequireAuthorization()</h2><h3>Basic Protection</h3><pre><code><code>// Must be authenticated (any valid token)
app.MapGet("/dashboard", () =&gt; "Your dashboard")
   .RequireAuthorization();</code></code></pre><h3>Group-Level Authorization</h3><p>Apply authorization to a whole group of routes at once instead of repeating <code>.RequireAuthorization()</code> on every endpoint:</p><pre><code><code>var api = app.MapGroup("/api")
             .RequireAuthorization();

api.MapGet("/orders", () =&gt; "All orders");
api.MapGet("/products", () =&gt; "All products");
api.MapPost("/orders", () =&gt; "Create order");
// All three require authentication</code></code></pre><h3>Allow Anonymous on Specific Endpoints</h3><p>If you protect a group but need to open up certain routes:</p><pre><code><code>api.MapGet("/health", () =&gt; "OK")
   .AllowAnonymous();</code></code></pre><div><hr></div><h2>Best Practices</h2><h3>1. Always Use HTTPS in Production</h3><p>Bearer tokens are credentials - never send them over plain HTTP:</p><pre><code><code>app.UseHttpsRedirection();</code></code></pre><h3>2. Don&#8217;t Expose Sensitive Claims</h3><pre><code><code>// &#9989; Only return what the client needs
app.MapGet("/me", (ClaimsPrincipal user) =&gt;
{
    return Results.Ok(new 
    { 
        Email = user.FindFirstValue(ClaimTypes.Email)
    });
})
.RequireAuthorization();</code></code></pre><div><hr></div><h2>Full Flow Recap</h2><pre><code><code>1. POST /register         &#8594; Create account
2. POST /login            &#8594; Get accessToken + refreshToken
3. GET  /me               &#8594; Pass Bearer token in Authorization header
4. POST /refresh          &#8594; Get a new accessToken when expired</code></code></pre><div><hr></div><h2>Key Takeaways</h2><ol><li><p><code>AddIdentityApiEndpoints&lt;IdentityUser&gt;()</code> &#8212; wires up Identity + Bearer auth in one call</p></li><li><p><code>MapIdentityApi&lt;IdentityUser&gt;()</code> &#8212; maps <code>/register</code>, <code>/login</code>, <code>/refresh</code>, and more automatically</p></li><li><p><code>IdentityDbContext</code> &#8212; inherit from it to get all Identity tables for free</p></li><li><p><strong>Migrations</strong> &#8212; run <code>dotnet ef migrations add</code> + <code>dotnet ef database update</code></p></li><li><p><code>RequireAuthorization()</code> &#8212; protects individual endpoints or entire route groups</p></li><li><p><strong>Bearer tokens</strong> &#8212; pass as <code>Authorization: Bearer &lt;token&gt;</code> header; refresh with <code>/refresh</code></p></li></ol><p>You went from zero to authenticated API in minutes</p><div><hr></div><h2>Resources</h2><ul><li><p><a href="https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity">ASP.NET Core Identity Documentation</a></p></li><li><p><a href="https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity-api-authorization">Identity API Endpoints</a></p></li></ul><div><hr></div><p><strong>Connect with me:</strong></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://linkedin.com/in/remigiusz-zalewski&quot;,&quot;text&quot;:&quot;Follow me on LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://linkedin.com/in/remigiusz-zalewski"><span>Follow me on LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;Subscribe on YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>Subscribe on YouTube</span></a></p><div><hr></div><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Entity Framework Core Loading Strategies - Lazy, Eager, and Explicit Loading Explained]]></title><description><![CDATA[Learn the three fundamental loading strategies in Entity Framework Core: Lazy Loading, Eager Loading, and Explicit Loading with practical examples.]]></description><link>https://newsletter.remigiuszzalewski.com/p/entity-framework-core-loading-strategies</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/entity-framework-core-loading-strategies</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 14 Feb 2026 07:00:30 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/LSL3Bgf_nno" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>If you&#8217;ve ever worked with Entity Framework Core, you&#8217;ve probably encountered the infamous <strong>N+1 query problem</strong> or wondered why your API returns incomplete data.</p><p>The culprit? <strong>Loading strategies</strong>.</p><p>Entity Framework Core provides three ways to load related data from your database:</p><p>1. <strong>Lazy Loading</strong> - Load data automatically when accessed</p><p>2. <strong>Eager Loading</strong> - Load everything upfront with a single query</p><p>3. <strong>Explicit Loading</strong> - Manually control what gets loaded and when</p><p>Each approach has its trade-offs, and choosing the wrong one can lead to performance issues, unnecessary database round-trips, or incomplete data.</p><p>In this article, I&#8217;ll walk you through all three strategies using a mocked version of &#8220;E-Commerce API&#8221; built with .NET 10 and Minimal APIs.</p><p>&#127916; <strong>Watch the full video here</strong>:</p><div id="youtube2-LSL3Bgf_nno" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;LSL3Bgf_nno&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/LSL3Bgf_nno?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><h1>The data model</h1><p>Before diving into the loading strategies, let&#8217;s understand our data structure. We&#8217;re building a simplified e-commerce system with the following entities:</p><pre><code>public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public virtual ICollection&lt;Order&gt; Orders { get; set; } = new List&lt;Order&gt;();
}

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public int CustomerId { get; set; }
    public virtual Customer Customer { get; set; } = null!;
    public virtual ICollection&lt;OrderItem&gt; Items { get; set; } = new List&lt;OrderItem&gt;();
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public int OrderId { get; set; }
    public virtual Order Order { get; set; } = null!;
    public int ProductId { get; set; }
    public virtual Product Product { get; set; } = null!;
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public virtual Category Category { get; set; } = null!;
}

{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}</code></pre><h3>Entity Relationships</h3><p>- A <strong>Customer </strong>has many <strong>Orders</strong></p><p>- An <strong>Order </strong>has many <strong>OrderItems</strong></p><p>- Each <strong>OrderItem </strong>references one <strong>Product</strong></p><p>- Each <strong>Product </strong>belongs to one <strong>Category</strong></p><p>This creates a deep object graph: Customer &#8594; Orders &#8594; OrderItems &#8594; Products &#8594; Categories</p><h1>Project setup</h1><h3>DbContext Configuration</h3><pre><code>public class SimplifiedECommerceDbContext : DbContext
{
    public SimplifiedECommerceDbContext(DbContextOptions&lt;SimplifiedECommerceDbContext&gt; options) 
        : base(options) { }
    
    public DbSet&lt;Customer&gt; Customers =&gt; Set&lt;Customer&gt;();
    public DbSet&lt;Order&gt; Orders =&gt; Set&lt;Order&gt;();
    public DbSet&lt;OrderItem&gt; OrderItems =&gt; Set&lt;OrderItem&gt;();
    public DbSet&lt;Product&gt; Products =&gt; Set&lt;Product&gt;();
    public DbSet&lt;Category&gt; Categories =&gt; Set&lt;Category&gt;();
}</code></pre><h3>Program.cs Setup</h3><p>I will show here what&#8217;s important to setup in DI container:</p><pre><code>var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");

services.AddDbContext&lt;SimplifiedECommerceDbContext&gt;(options =&gt;
            options.UseLazyLoadingProxies()  // Enable lazy loading
                .UseSqlServer(connectionString)
                .EnableSensitiveDataLogging());

builder.Services.Configure&lt;Microsoft.AspNetCore.Http.Json.JsonOptions&gt;(options =&gt;
{
    options.SerializerOptions.ReferenceHandler = 
        System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});</code></pre><p><strong>Key point</strong>: Notice UseLazyLoadingProxies() - this enables lazy loading support. You&#8217;ll also need to install following Nuget package:</p><pre><code>Microsoft.EntityFrameworkCore.Proxies</code></pre><p><strong>Important:</strong> The `ReferenceHandler.IgnoreCycles` configuration prevents JSON serialization errors when dealing with circular references in lazy-loaded entities.</p><h1>Lazy loading</h1><p>Lazy loading is the <strong>automatic </strong>loading of related entities when you access their navigation properties. It&#8217;s &#8220;lazy&#8221; because the data isn&#8217;t loaded until you actually need it.</p><p>When you access a navigation property (like `customer.Orders`), Entity Framework Core:</p><p>1. Detects the access</p><p>2. Generates and executes a SQL query</p><p>3. Loads the data</p><p>4. Returns it to you</p><pre><code>public async Task&lt;List&lt;Customer&gt;&gt; GetCustomersLazyAsync()
{
    Console.WriteLine("=== LAZY LOADING START ===");
    
    // Load customers only - no related data yet
    var customers = await _simplifiedECommerceDbContext.Customers
        .ToListAsync();
    
    Console.WriteLine($"Loaded {customers.Count} customers with full graph");
    
    // Accessing navigation properties triggers additional queries
    foreach (var customer in customers)
    {
        foreach (var order in customer.Orders)  // &#8592; Query executed here
        {
            Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");

            foreach (var item in order.Items)  // &#8592; Query executed here
            {
                // Accessing Product triggers another query
                Console.WriteLine(
                    $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
            }
        }
    }
    
    Console.WriteLine("=== LAZY LOADING END ===");
    return customers;
}</code></pre><h4>SQL Queries Generated</h4><p>For 10 customers with 5 orders each, and 5 items per order, this generates:</p><pre><code>SELECT * FROM Customers                     -- 1 query

SELECT * FROM Orders WHERE CustomerId = 1   -- 10 queries (one per customer)

SELECT * FROM OrderItems WHERE OrderId = X  -- 50 queries (one per order)

SELECT * FROM Products WHERE Id = Y         -- 250 queries (one per item)</code></pre><p>Total: <strong>311 database queries</strong>! This is the <strong>N+1 problem</strong>.</p><p></p><h4>Pros and Cons</h4><p>&#9989; Pros:</p><p>- Simple to implement</p><p>- No need to specify `Include()`</p><p>- Only loads data you actually use</p><p>- Good for scenarios where related data is rarely accessed</p><p></p><p>&#10060; Cons:</p><p>- <strong>N+1 query problem</strong> - can cause hundreds or thousands of queries</p><p>- Poor performance for deep object graphs</p><p>- Requires `virtual` navigation properties</p><p>- Requires `UseLazyLoadingProxies()`</p><p>- Can cause issues with serialization</p><p></p><h4>When to Use Lazy Loading</h4><p>- Simple, shallow data structures</p><p>- When you rarely need related data</p><p>- Development/prototyping phase</p><p>- When database round-trips are cheap (same datacenter)</p><h1>Eager Loading</h1><p>Eager loading loads <strong>all related data upfront </strong>in a single query (or a few queries using split queries) using `Include()` and `ThenInclude()` methods.</p><p>You explicitly tell Entity Framework Core which related entities to load, and it generates an optimized SQL query with JOINs (or multiple parallel queries with split queries).</p><pre><code>public async Task&lt;List&lt;Customer&gt;&gt; GetCustomersEagerAsync()
{
    Console.WriteLine("=== EAGER LOADING START ===");
    
    // Load customers with ALL related data in one go
    var customers = await _simplifiedECommerceDbContext.Customers
        .Include(x =&gt; x.Orders)                    // Load orders
        .ThenInclude(x =&gt; x.Items)                 // Load items
        .ThenInclude(x =&gt; x.Product)               // Load products
        .ThenInclude(x =&gt; x.Category)              // Load categories
        .AsSplitQuery()                            // Use multiple queries instead of one huge JOIN
        .AsNoTracking()                            // Don't track changes for read-only data
        .ToListAsync();
    
    Console.WriteLine($"Loaded {customers.Count} customers with full graph");
    
    // All data is already loaded - no additional queries
    foreach (var customer in customers)
    {
        foreach (var order in customer.Orders)
        {
            Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");

            foreach (var item in order.Items)
            {
                Console.WriteLine(
                    $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
            }
        }
    }
    
    Console.WriteLine("=== EAGER LOADING END ===");
    return customers;
}</code></pre><h4>SQL Queries generated:</h4><p>With `AsSplitQuery()`, Entity Framework generates <strong>5 separate queries </strong>that run in parallel:</p><pre><code>-- Query 1: Get all customers
SELECT * FROM Customers

-- Query 2: Get all related orders
SELECT * FROM Orders WHERE CustomerId IN (1,2,3,...)

-- Query 3: Get all related order items
SELECT * FROM OrderItems WHERE OrderId IN (1,2,3,...)

-- Query 4: Get all related products
SELECT * FROM Products WHERE Id IN (1,2,3,...)

-- Query 5: Get all related categories
SELECT * FROM Categories WHERE Id IN (1,2,3,...)</code></pre><p><strong>Total:</strong> 5 queries instead of 311!</p><p></p><h4>AsSplitQuery vs Single Query</h4><p>Without `AsSplitQuery()`, EF Core generates one massive query with multiple JOINs, which can create a <strong>cartesian explosion</strong> - the result set can become enormous due to JOIN multiplication.</p><p><strong>Example: </strong>10 customers &#215; 5 orders &#215; 5 items = 250 rows returned for just 10 customers!</p><p>`AsSplitQuery()` solves this by breaking it into multiple queries.</p><p></p><h4>AsNoTracking()</h4><p>`AsNoTracking()` tells EF Core not to track changes to these entities. Use it for:</p><p>- Read-only operations</p><p>- API responses</p><p>- Better performance (no change tracking overhead)</p><p></p><h4><strong>Pros and Cons</strong></h4><p>&#9989; Pros:</p><p>- <strong>Optimal performance</strong> - minimal database round-trips</p><p>- Predictable query count</p><p>- No N+1 problem</p><p>- No need for `virtual` navigation properties</p><p>- Works without lazy loading proxies</p><p>- Best for API responses</p><p></p><p>&#10060; Cons:</p><p>- Can load unnecessary data if you don&#8217;t need everything</p><p>- More verbose code</p><p>- Need to know the object graph structure upfront</p><p>- Can cause cartesian explosion without split queries</p><p></p><h4>When to Use Eager Loading</h4><p>- API endpoints that return DTOs</p><p>- When you know you&#8217;ll need related data</p><p>- Performance-critical scenarios</p><p>- **Most common choice for production APIs**</p><h1>Explicit Loading</h1><p>Explicit loading gives you <strong>fine-grained control </strong>over what gets loaded and when. You manually load related entities using `Entry().Collection().LoadAsync()` or `Entry().Reference().LoadAsync()`.</p><p>You load the main entity first, then conditionally load related data based on business logic.</p><pre><code>public async Task&lt;List&lt;Customer&gt;&gt; GetCustomersExplicitAsync()
{
    Console.WriteLine("=== EXPLICIT LOADING START ===");

    // Load only customers first
    var customers = await _simplifiedECommerceDbContext.Customers.ToListAsync();

    foreach (var customer in customers)
    {
        // Only load orders for first 5 customers
        if (customer.Id &lt;= 5)
        {
            await _simplifiedECommerceDbContext.Entry(customer)
                .Collection(x =&gt; x.Orders)
                .LoadAsync();
            
            foreach (var order in customer.Orders)
            {
                Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");
                
                // Load items for each order
                await _simplifiedECommerceDbContext.Entry(order)
                    .Collection(o =&gt; o.Items)
                    .LoadAsync();

                foreach (var item in order.Items)
                {
                    // Only load product details for items with quantity &gt;= 3
                    if (item.Quantity &gt;= 3)
                    {
                        await _simplifiedECommerceDbContext.Entry(item)
                            .Reference(x =&gt; x.Product)
                            .LoadAsync();
                        
                        Console.WriteLine(
                            $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
                    }
                }
            }
        }
    }
    
    Console.WriteLine("=== EXPLICIT LOADING END ===");
    return customers;
}</code></pre><h4>Key methods</h4><p>- `Entry(entity)` - Gets the entry for tracking and loading</p><p>- `.Collection(x =&gt; x.Property)` - Loads a collection navigation property (1-to-many)</p><p>- `.Reference(x =&gt; x.Property)` - Loads a reference navigation property (1-to-1 or many-to-1)</p><p>- `.LoadAsync()` - Executes the load operation</p><p></p><h4>Use Case: Conditional Loading</h4><p>In the example above:</p><p>- Orders are only loaded for customers 1-5</p><p>- Product details are only loaded for items with quantity &#8805; 3</p><p>This is useful when:</p><p>- You have conditional business logic</p><p>- Different users see different data (permissions)</p><p>- You want to optimize based on data characteristics</p><p></p><h4>Pros and Cons</h4><p>&#9989; Pros:</p><p>- **Maximum control** over what gets loaded</p><p>- Conditional loading based on business logic</p><p>- Can optimize for specific scenarios</p><p>- No unnecessary data loaded</p><p>- Good for complex permission/filtering logic</p><p>&#10060; Cons:</p><p>- Most verbose approach</p><p>- Requires more code</p><p>- Easy to introduce N+1 if not careful</p><p>- Harder to maintain</p><p>- Requires understanding of EF Core entry API</p><p></p><h4>When to Use Explicit Loading</h4><p>- Complex permission-based filtering</p><p>- Conditional data loading based on business rules</p><p>- Scenarios where eager loading would load too much</p><p>- When you need surgical control over queries</p><p></p><h1>Key Takeaways</h1><p>1. <strong>Lazy Loading</strong> - Automatic but causes N+1 problems</p><p>2. <strong>Eager Loading</strong> - Optimal for APIs (5 queries with split query)</p><p>3. <strong>Explicit Loading </strong>- Maximum control for conditional scenarios</p><p>4. <strong>Use `AsSplitQuery()`</strong> - Prevents cartesian explosion</p><p>5. <strong>Use `AsNoTracking()`</strong> - Better performance for read-only operations</p><p></p><p>For most production APIs, <strong>Eager Loading with Split Queries</strong> is the winner.</p><p></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption"><strong>Subscribe to .NET Weekly Newsletter &#128071;</strong></p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Minimal API Validation in .NET 10 - Built-In Support with Data Annotations]]></title><description><![CDATA[Learn how .NET 10 introduces built-in validation for Minimal APIs using Data Annotations - no extra libraries needed]]></description><link>https://newsletter.remigiuszzalewski.com/p/minimal-api-validation-in-net-10</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/minimal-api-validation-in-net-10</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 07 Feb 2026 07:01:31 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/VmItyD6a8ic" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>If you&#8217;ve ever built a Minimal API in .NET, you probably know the pain - there was <strong>no built-in validation</strong> out of the box.</p><p>You had to rely on third-party libraries like <strong>FluentValidation</strong> or write manual checks inside your endpoint handlers.</p><p>That changes with <strong>.NET 10</strong>.</p><p>Microsoft has introduced <strong>native validation support for Minimal APIs using Data Annotations</strong> - the same <code>[Required]</code>, <code>[MaxLength]</code>, and custom validation attributes you already know from MVC controllers.</p><p>In this article, I&#8217;ll walk you through exactly how it works using a practical <strong>Library API</strong> example.</p><p>&#127916; Watch the full video here:</p><div id="youtube2-VmItyD6a8ic" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;VmItyD6a8ic&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/VmItyD6a8ic?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><h2>The Problem Before .NET 10</h2><p>In earlier versions of .NET, Minimal APIs had <strong>no automatic model validation</strong>. If you defined a POST endpoint like this:</p><pre><code><code>app.MapPost("/books", (CreateBookRequest request) =&gt;
{
    // No validation happens automatically!
    // You had to manually check or use FluentValidation
});</code></code></pre><p>The framework would happily accept <strong>any payload</strong> -  even an empty one - and let your handler deal with it.</p><p>This meant:</p><ul><li><p>&#10060; No automatic <code>400 Bad Request</code> responses</p></li><li><p>&#10060; No support for <code>[Required]</code>, <code>[MaxLength]</code>, etc.</p></li><li><p>&#10060; Extra libraries or boilerplate validation logic</p></li></ul><div><hr></div><h2>What&#8217;s New in .NET 10</h2><p>.NET 10 adds a <strong>single line</strong> that changes everything:</p><pre><code><code>builder.Services.AddValidation();</code></code></pre><p>That&#8217;s it.</p><p>By calling <code>AddValidation()</code> on your service collection, the framework will automatically validate any request model decorated with <strong>Data Annotation attributes</strong> <em>before</em> your endpoint handler executes.</p><p>If validation fails, the API returns a <strong>400 Bad Request</strong> with detailed error messages &#8212; no manual intervention required.</p><div><hr></div><h2>Full Example - Building a &#8220;Library API&#8221;</h2><p>Let&#8217;s build a simple Library API that validates book creation requests.</p><div><hr></div><h2>1. Project Setup</h2><p>Make sure you&#8217;re targeting <strong>.NET 10</strong>:</p><pre><code><code>&lt;Project Sdk="Microsoft.NET.Sdk.Web"&gt;

  &lt;PropertyGroup&gt;
    &lt;TargetFramework&gt;net10.0&lt;/TargetFramework&gt;
    &lt;Nullable&gt;enable&lt;/Nullable&gt;
    &lt;ImplicitUsings&gt;enable&lt;/ImplicitUsings&gt;
  &lt;/PropertyGroup&gt;

  &lt;ItemGroup&gt;
    &lt;PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" /&gt;
    &lt;PackageReference Include="Scalar.AspNetCore" Version="2.12.30" /&gt;
  &lt;/ItemGroup&gt;

&lt;/Project&gt;</code></code></pre><div><hr></div><h2>2. Define the Request Model with Validation</h2><p>This is where the magic happens.</p><pre><code><code>using System.ComponentModel.DataAnnotations;

namespace LibraryApi;

public record CreateBookRequest(
    [Required]
    [MinLength(2)]
    [MaxLength(200)]
    string Title,

    [Required]
    string Author,

    string? Isbn,

    [ValidPublishedYear]
    int? PublishedYear
);</code></code></pre><h3>What&#8217;s happening here?</h3><ul><li><p><strong>Title</strong> is required and must be between 2 and 200 characters</p></li><li><p><strong>Author</strong> is required</p></li><li><p><strong>Isbn</strong> is optional</p></li><li><p><strong>PublishedYear</strong> uses a <strong>custom validation attribute</strong></p></li></ul><div><hr></div><h2>3. Create a Custom Validation Attribute</h2><p>For scenarios where built-in attributes aren&#8217;t enough, you can create your own.</p><pre><code><code>using System.ComponentModel.DataAnnotations;

namespace LibraryApi;

[AttributeUsage(AttributeTargets.Parameter)]
public sealed class ValidPublishedYearAttribute : ValidationAttribute
{
    private const int MinYear = 1450;

    protected override ValidationResult? IsValid(
        object? value,
        ValidationContext validationContext)
    {
        if (value is null)
            return ValidationResult.Success;

        if (value is not int year)
            return new ValidationResult("Published year must be a number.");

        var currentYear = DateTime.UtcNow.Year;

        if (year &lt; MinYear || year &gt; currentYear)
        {
            return new ValidationResult(
                $"Published year must be between {MinYear} and {currentYear}.");
        }

        return ValidationResult.Success;
    }
}</code></code></pre><h3>Key details</h3><ul><li><p>&#9989; <code>null</code><strong> is allowed</strong> - the field is optional</p></li><li><p>&#9989; The year must be within a <strong>sensible range</strong></p></li><li><p>&#9989; Inherits from <code>ValidationAttribute</code></p></li><li><p>&#9888;&#65039; <code>[AttributeUsage(AttributeTargets.Parameter)]</code> is <strong>required</strong> because record constructor members are parameters, not properties</p></li></ul><div><hr></div><h2>4. Define the Response DTO</h2><pre><code><code>namespace LibraryApi;

public record BookDto(
    Guid Id,
    string Title,
    string Author,
    string? Isbn,
    int? PublishedYear
);</code></code></pre><div><hr></div><h2>5. Wire Everything in <code>Program.cs</code></h2><pre><code><code>using LibraryApi;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseHttpsRedirection();

app.MapPost("/books", (CreateBookRequest request) =&gt;
{
    var createdBook = new BookDto(
        Guid.NewGuid(),
        request.Title,
        request.Author,
        request.Isbn,
        request.PublishedYear
    );

    return Results.Created($"/books/{createdBook.Id}", createdBook);
})
.WithName("CreateBook");

app.Run();</code></code></pre><p>&#128204; The <strong>only new line</strong> compared to classic Minimal APIs is:</p><pre><code><code>builder.Services.AddValidation();</code></code></pre><div><hr></div><h2>How It Works Under the Hood</h2><p>When you call <code>AddValidation()</code>:</p><ol><li><p><strong>Request arrives</strong> &#8594; JSON is deserialized into your record</p></li><li><p><strong>Validation filter runs</strong> &#8594; Data Annotations are evaluated</p></li><li><p><strong>Valid</strong> &#8594; endpoint executes</p></li><li><p><strong>Invalid</strong> &#8594; pipeline short-circuits and returns <code>400 Bad Request</code></p></li></ol><p>No <code>ModelState</code>, no manual checks, no boilerplate.</p><div><hr></div><h2>Testing the Validation</h2><h3>&#9989; Valid Request</h3><pre><code><code>POST /books
{
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "isbn": "978-0132350884",
  "publishedYear": 2008
}</code></code></pre><p><strong>Response: 201 Created</strong></p><pre><code><code>{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "isbn": "978-0132350884",
  "publishedYear": 2008
}</code></code></pre><div><hr></div><h3>&#10060; Missing Required Fields</h3><pre><code><code>POST /books
{
  "isbn": "978-0132350884"
}</code></code></pre><p><strong>Response:</strong> <code>400 Bad Request</code><br>Validation errors for <code>title</code> and <code>author</code></p><div><hr></div><h3>&#10060; Title Too Short</h3><pre><code><code>POST /books
{
  "title": "A",
  "author": "John Doe"
}</code></code></pre><p><strong>Response:</strong> <code>400 Bad Request</code></p><div><hr></div><h3>&#10060; Future Published Year</h3><pre><code><code>POST /books
{
  "title": "Future Book",
  "author": "Time Traveler",
  "publishedYear": 2999
}</code></code></pre><p><strong>Response:</strong> <code>400 Bad Request</code></p><div><hr></div><h2>Key Takeaways</h2><ul><li><p><strong>.NET 10</strong> adds <strong>first-class validation</strong> for Minimal APIs</p></li><li><p>One line: <code>builder.Services.AddValidation()</code></p></li><li><p>Standard <strong>Data Annotations just work</strong></p></li><li><p>Custom validation is fully supported</p></li><li><p>Automatic <strong>400 responses with Problem Details</strong></p></li><li><p><strong>No third-party libraries required</strong></p></li></ul><p>This is a major quality-of-life improvement for Minimal APIs.<br>What used to require FluentValidation or manual checks now works <strong>out of the box</strong>.</p><p></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption"><strong>Subscribe to .NET Weekly Newsletter &#128071;</strong></p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[AddSingleton vs AddScoped vs AddTransient in ASP.NET Core Dependency Injection]]></title><description><![CDATA[A clear, practical guide to ASP .NET Core DI lifetimes - explaining the difference between Singleton, Scoped, and Transient services with real-world C# examples.]]></description><link>https://newsletter.remigiuszzalewski.com/p/addsingleton-vs-addscoped-vs-addtransient</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/addsingleton-vs-addscoped-vs-addtransient</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 31 Jan 2026 07:01:15 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/FPG3ME2HKa0" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>When building modern ASP .NET Core applications, <strong>Dependency Injection (DI)</strong> is a key design pattern that helps you write clean, maintainable, and testable code.<br>But one question often confuses developers:</p><p>&#10067; What&#8217;s the difference between <code>AddSingleton</code>, <code>AddScoped</code>, and <code>AddTransient</code> in ASP .NET Core?</p><p>In this article, we&#8217;ll break down each service lifetime, explain when to use which, and provide <strong>real C# examples</strong> you can copy and paste into your projects.</p><div id="youtube2-FPG3ME2HKa0" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;FPG3ME2HKa0&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/FPG3ME2HKa0?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><div><hr></div><h2>What Is Dependency Injection?</h2><p>Dependency Injection is a technique where the framework automatically provides (injects) instances of required services into your classes.</p><p>In ASP .NET Core, you register services in the DI container (usually in <code>Program.cs</code>) like this:</p><pre><code><code>builder.Services.AddSingleton&lt;IMyService, MyService&gt;();
builder.Services.AddScoped&lt;IMyService, MyService&gt;();
builder.Services.AddTransient&lt;IMyService, MyService&gt;();</code></code></pre><h3>But what&#8217;s the difference?</h3><p>Each of these methods defines <strong>how long the created service instance should live</strong> &#8212; in other words, its <strong>lifetime</strong>.</p><h2>&#129513; AddSingleton - One Instance for the Entire Application</h2><p>A <strong>Singleton</strong> service is created <strong>once</strong> and shared across the <strong>entire application lifetime</strong>.</p><p>That means:</p><ul><li><p>The same instance is reused for <strong>every request</strong> and <strong>every dependency</strong>.</p></li><li><p>The instance lives until the application stops.</p></li></ul><h3>&#9989; When to use AddSingleton</h3><p>Use <code>AddSingleton</code> for:</p><ul><li><p><strong>Stateless services</strong></p></li><li><p><strong>Configuration providers</strong></p></li><li><p><strong>Logging</strong></p></li><li><p><strong>Caching layers</strong></p></li></ul><h3>&#10060; Avoid when</h3><ul><li><p>The service <strong>holds request-specific data</strong></p></li><li><p>The service depends on a <strong>scoped or transient dependency</strong></p></li></ul><h3>&#128161; Example</h3><pre><code><code>public interface IGuidService
{
    string GetGuid();
}

public class SingletonGuidService : IGuidService
{
    private readonly string _guid;

    public SingletonGuidService()
    {
        _guid = Guid.NewGuid().ToString();
    }

    public string GetGuid() =&gt; _guid;
}

// In Program.cs
builder.Services.AddSingleton&lt;IGuidService, SingletonGuidService&gt;();</code></code></pre><p>Each time you request IGuidService, you get the same instance and the same GUID value.</p><h2>&#127760; AddScoped &#8211; One Instance per HTTP Request</h2><p><code>AddScoped</code> creates <strong>one instance per HTTP request</strong>.<br>All components handling that same request share <strong>the same instance</strong>, but a <strong>new instance</strong> is created for each new request.</p><p>This makes it ideal for request-specific data and database operations.</p><h3>&#9989; Best For</h3><ul><li><p><strong>Database contexts</strong> (<code>DbContext</code> in Entity Framework Core)</p></li><li><p><strong>Unit of Work</strong> pattern</p></li><li><p><strong>Request-specific data</strong> (services, repositories)</p></li></ul><div><hr></div><h3>&#128161; Example</h3><pre><code><code>public interface IRequestService
{
    Guid GetRequestId();
}

public class RequestService : IRequestService
{
    private readonly Guid _requestId = Guid.NewGuid();
    public Guid GetRequestId() =&gt; _requestId;
}

// Register in Program.cs
builder.Services.AddScoped&lt;IRequestService, RequestService&gt;();

// Example controller
[ApiController]
[Route("api/[controller]")]
public class ScopedExampleController : ControllerBase
{
    private readonly IRequestService _service1;
    private readonly IRequestService _service2;

    public ScopedExampleController(IRequestService service1, IRequestService service2)
    {
        _service1 = service1;
        _service2 = service2;
    }

    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            FirstInstance = _service1.GetRequestId(),
            SecondInstance = _service2.GetRequestId()
        });
    }
}</code></code></pre><p>&#129504; What you&#8217;ll see:</p><p>Within the same HTTP request, both FirstInstance and SecondInstance will return the same GUID. When you make another request, a new GUID will appear &#8212; confirming a new instance was created.</p><h2>&#9889; AddTransient - A New Instance Every Time</h2><p>An <strong>AddTransient</strong> service is created <strong>each time</strong> it&#8217;s requested from the Dependency Injection (DI) container.</p><p>Let&#8217;s imagine you&#8217;re building an <strong>email notification system</strong> in your ASP.NET Core application.<br>You might have a lightweight <code>EmailFormatter</code> service that prepares email content before it&#8217;s sent.</p><p>This service:</p><ul><li><p>Doesn&#8217;t store state</p></li><li><p>Performs quick, one-off operations</p></li><li><p>Should be short-lived (you don&#8217;t want to reuse old email content or data)</p></li></ul><p>That means:</p><ul><li><p>Every injection or method call gets a <strong>brand new object</strong>.</p></li><li><p>This is ideal for <strong>lightweight</strong>, <strong>stateless</strong>, and <strong>non-expensive</strong> services.</p></li></ul><div><hr></div><h3>&#9989; When to Use AddTransient</h3><p>Use <code>AddTransient</code> for:</p><ul><li><p><strong>Utility or helper classes</strong></p></li><li><p><strong>Small operations</strong></p></li><li><p><strong>Short-lived, non-expensive services</strong></p></li></ul><div><hr></div><h3>&#10060; Avoid When</h3><ul><li><p>The service performs <strong>expensive initialization</strong></p></li><li><p>The service <strong>holds shared state</strong> or data that needs to persist between calls</p></li></ul><div><hr></div><h3>&#128161; Example</h3><pre><code><code>public interface IEmailFormatter
{
    string FormatWelcomeEmail(string userName);
}

public class EmailFormatter : IEmailFormatter
{
    public string FormatWelcomeEmail(string userName)
    {
        return $"Hi {userName}, welcome to our platform! &#127881;";
    }
}

// Program.cs
builder.Services.AddTransient&lt;IEmailFormatter, EmailFormatter&gt;();</code></code></pre><p>&#128640; Why AddTransient Works Here</p><p>Each email send operation gets a fresh EmailFormatter. No state is reused between emails. It&#8217;s lightweight, stateless, and safe to recreate on demand.</p><p></p><p>&#129504; Key Takeaways</p><ul><li><p>Choose Singleton for shared, thread-safe, stateless services.</p></li><li><p>Choose Scoped for request-level services (like DbContext).</p></li><li><p>Choose Transient for lightweight, short-lived dependencies.</p></li><li><p>Correct use of lifetimes ensures performance, stability, and predictable behavior in your ASP.NET Core applications.</p></li></ul>]]></content:encoded></item><item><title><![CDATA[Factory Design Pattern in C#]]></title><description><![CDATA[Learn how to dynamically create objects without tight coupling using real-world examples.]]></description><link>https://newsletter.remigiuszzalewski.com/p/factory-design-pattern-in-c</link><guid isPermaLink="false">https://newsletter.remigiuszzalewski.com/p/factory-design-pattern-in-c</guid><dc:creator><![CDATA[Remigiusz Zalewski]]></dc:creator><pubDate>Sat, 24 Jan 2026 07:00:28 GMT</pubDate><enclosure url="https://substackcdn.com/image/youtube/w_728,c_limit/urvoINv_94Q" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote><h2 style="text-align: center;"><strong>Connect with me:</strong></h2><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://www.linkedin.com/in/remigiusz-zalewski/&quot;,&quot;text&quot;:&quot;LinkedIn&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://www.linkedin.com/in/remigiusz-zalewski/"><span>LinkedIn</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://youtube.com/@remigiuszzalewski&quot;,&quot;text&quot;:&quot;YouTube&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://youtube.com/@remigiuszzalewski"><span>YouTube</span></a></p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://remigiuszzalewski.com&quot;,&quot;text&quot;:&quot;Blog&quot;,&quot;action&quot;:null,&quot;class&quot;:&quot;button-wrapper&quot;}" data-component-name="ButtonCreateButton"><a class="button primary button-wrapper" href="https://remigiuszzalewski.com"><span>Blog</span></a></p><p style="text-align: center;">Want to sponsor this newsletter? <a href="https://remigiuszzalewski.com/sponsorshiphttps://remigiuszzalewski.com/sponsorship">Let&#8217;s work together &#8594;</a></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.remigiuszzalewski.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Subscribe to get more weekly .NET content &#128071;</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div></blockquote><h2>Introduction</h2><p>In the ever-changing world of software development, design patterns act as guides to efficiency and good practices. These well-tested answers to common coding challenges have become essential tools for developers.</p><p>They offer a organized way to build strong, expandable, and easy-to-maintain software systems. Design patterns aren&#8217;t strict rules. Instead, they&#8217;re flexible templates that can be adjusted to solve recurring design issues in different situations. They capture the shared knowledge of seasoned software engineers, creating a common language that works across different programming languages and technologies.</p><p>The Factory Pattern in C# is a creational design pattern that provides a way to create objects without specifying the exact class of object that will be created. This pattern is particularly useful in scenarios where the instantiation logic is complex or varies based on certain conditions. In this article, we will explore an example of using the Factory Pattern to create invoices in different formats based on a provided document type.</p><div id="youtube2-urvoINv_94Q" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;urvoINv_94Q&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="https://www.youtube-nocookie.com/embed/urvoINv_94Q?rel=0&amp;autoplay=0&amp;showinfo=0&amp;enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><p>Let&#8217;s first examine the code below, which does not use the Factory Pattern, and highlight its drawbacks in terms of violating SOLID principles and clean code practices:</p><pre><code><code>app.MapGet("/api/invoice/{id}/{format}", (Guid id, InvoiceFormat format) =&gt;
{
    var generator = new InvoiceGenerator();
    var invoiceData = generator.GenerateInvoice(id, format);
    var contentType = generator.GetContentType(format);

    string fileName = $"Invoice_{id}.{format.ToString().ToLower()}";

    return Results.File(invoiceData, contentType, fileName);
})
.WithName("GenerateInvoice")
.WithOpenApi();
</code></code></pre><pre><code><code>public class InvoiceGenerator
{
    public byte[] GenerateInvoice(Guid invoiceId, string format)
    {
        var invoice = MockInvoiceProvider.CreateMockInvoice(invoiceId);
        format = format.ToLower();

        switch (format)
        {
            case "pdf":
                return GeneratePdfInvoice(invoice);
            case "txt":
                return GenerateTxtInvoice(invoice);
            case "csv":
                return GenerateCsvInvoice(invoice);
            default:
                throw new ArgumentException("Invalid format", nameof(format));
        }
    }

    public string GetContentType(string format)
    {
        format = format.ToLower();

        switch (format)
        {
            case "pdf":
                return "application/pdf";
            case "txt":
                return "text/plain";
            case "csv":
                return "text/csv";
            default:
                throw new ArgumentException("Invalid format", nameof(format));
        }
    }
}</code></code></pre><ol><li><p><strong>Violation of the Single Responsibility Principle (SRP)</strong>: The InvoiceGenerator class is responsible for too many things: fetching invoice data, determining content types, and generating invoices in multiple formats. This makes the class harder to maintain and extend.</p></li><li><p><strong>Open/Closed Principle (OCP) Violation</strong>: The <code>switch</code> statements in both <code>GenerateInvoice</code> and <code>GetContentType</code> violate OCP because adding a new format (e.g., JSON or XML) requires modifying these methods. This increases the risk of introducing bugs and makes the code less extensible.</p></li><li><p><strong>Tight Coupling</strong>: The <code>InvoiceGenerator</code> class is tightly coupled to specific invoice generation logic (<code>GeneratePdfInvoice</code>, <code>GenerateTxtInvoice</code>, etc.). This makes it difficult to test or replace individual components without affecting the entire class.</p></li><li><p><strong>Code Duplication</strong>: The <code>switch</code> statement logic is duplicated across methods (<code>GenerateInvoice</code> and <code>GetContentType</code>). This redundancy increases maintenance overhead and can lead to inconsistencies if one part is updated but not the other.</p></li><li><p><strong>Lack of Scalability</strong>: As more formats are added, the class becomes bloated with additional cases in the <code>switch</code> statements, making it harder to read and maintain.</p></li></ol><p>Let&#8217;s refactor the code to apply the Factory design pattern step-by-step and examine the benefits we achieve:</p><ul><li><p><strong>Define the invoice generator interface</strong>: All types of formats are using two methods: <code>GenerateInvoice</code> and <code>GetContentType</code> . We can create an interface called <code>IInvoiceGenerator</code> that later on could be implemented by concrete classes.</p></li></ul><pre><code><code>public interface IInvoiceGenerator
{
    byte[] GenerateInvoice(Guid invoiceId);
    string GetContentType();
}</code></code></pre><ul><li><p><strong>Create Concrete Classes</strong>: Now I will create three classes &#8212; <code>PdfInvoiceGenerator</code> , <code>TxtInvoiceGenerator</code> and <code>CsvInvoiceGenerator</code> that will implement IInvoiceGenerator interface and it will generate the invoice based on their format. At the same time I will remove the InvoiceGenerator class (as it won&#8217;t be needed anymore).</p></li></ul><pre><code><code>public class PdfInvoiceGenerator : IInvoiceGenerator
{
    public byte[] GenerateInvoice(Guid invoiceId)
    {
        var invoice = MockInvoiceProvider.CreateMockInvoice(invoiceId);

        var document = Document.Create(container =&gt;
        {
            container.Page(page =&gt;
            {
                page.Size(PageSizes.A4);
                page.Margin(2, Unit.Centimetre);
                page.Header().Text($"Invoice #{invoice.Id}").SemiBold().FontSize(20);
                page.Content().PaddingVertical(1, Unit.Centimetre).Column(column =&gt;
                {
                    column.Item().Text($"Date: {invoice.Date:yyyy-MM-dd}");
                    column.Item().Text($"Customer: {invoice.CustomerName}");
                    column.Item().Text($"Amount: ${invoice.Amount:F2}").FontSize(14);
                });
                page.Footer().AlignCenter().Text(x =&gt;
                {
                    x.Span("Page ");
                    x.CurrentPageNumber();
                });
            });
        });

        return document.GeneratePdf();
    }

    public string GetContentType() =&gt; "application/pdf";
}</code></code></pre><pre><code><code>public class TxtInvoiceGenerator : IInvoiceGenerator
{
    public byte[] GenerateInvoice(Guid invoiceId)
    {
        var invoice = MockInvoiceProvider.CreateMockInvoice(invoiceId);

        string content = $"Invoice #{invoice.Id}\n" +
                         $"Date: {invoice.Date:yyyy-MM-dd}\n" +
                         $"Customer: {invoice.CustomerName}\n" +
                         $"Amount: ${invoice.Amount:F2}";

        return Encoding.UTF8.GetBytes(content);
    }

    public string GetContentType() =&gt; "text/plain";
}</code></code></pre><pre><code><code>public class CsvInvoiceGenerator : IInvoiceGenerator
{
    public byte[] GenerateInvoice(Guid invoiceId)
    {
        var invoice = MockInvoiceProvider.CreateMockInvoice(invoiceId);

        string csvContent = "Invoice ID,Date,Customer,Amount\n" +
                            $"{invoice.Id},{invoice.Date:yyyy-MM-dd},{invoice.CustomerName},{invoice.Amount:F2}";

        return Encoding.UTF8.GetBytes(csvContent);
    }

    public string GetContentType() =&gt; "text/csv";
}</code></code></pre><ul><li><p><strong>Define enum to cover the supported invoice formats</strong>: I will get rid of handling the string as an input and make it more concise:</p></li></ul><pre><code><code>public enum InvoiceFormat
{
    Pdf,
    Txt,
    Csv
}</code></code></pre><ul><li><p><strong>Define the factory class interface</strong>: I will define the IInvoiceGeneratorFactory interface that will be implemented later on by concrete factory class</p></li></ul><pre><code><code>public interface IInvoiceGeneratorFactory
{
    IInvoiceGenerator CreateInvoiceGenerator(InvoiceFormat invoiceFormat);
}</code></code></pre><ul><li><p><strong>Create Factory class</strong>: After completion of all the steps we are ready to create the class that will implement the IInvoiceGeneratorFactory interface and based on InvoiceFormat enum that I have created, will decide which InvoiceGenerator class will return as the result</p></li></ul><pre><code><code>public class InvoiceGeneratorFactory : IInvoiceGeneratorFactory
{
    public IInvoiceGenerator CreateInvoiceGenerator(InvoiceFormat invoiceFormat)
    {
        return invoiceFormat switch
        {
            InvoiceFormat.Pdf =&gt; new PdfInvoiceGenerator(),
            InvoiceFormat.Txt =&gt; new TxtInvoiceGenerator(),
            InvoiceFormat.Csv =&gt; new CsvInvoiceGenerator(),
            _ =&gt; throw new ArgumentException("Invalid/Unsupported InvoiceFormat", nameof(invoiceFormat))
        };
    }
}</code></code></pre><ul><li><p>You can register your classes in DI container and resolve the dependencies using IServiceProvider to return the concrete class from the container instead of creating the class with the new operator:</p></li></ul><pre><code><code>public class InvoiceGeneratorFactory : IInvoiceGeneratorFactory
{
    private readonly IServiceProvider _serviceProvider;

    public InvoiceGeneratorFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IInvoiceGenerator CreateInvoiceGenerator(InvoiceFormat invoiceFormat)
    {
        return invoiceFormat switch
        {
            InvoiceFormat.Pdf =&gt; _serviceProvider.GetRequiredService&lt;PdfInvoiceGenerator&gt;(),
            InvoiceFormat.Txt =&gt; _serviceProvider.GetRequiredService&lt;TxtInvoiceGenerator&gt;(),
            InvoiceFormat.Csv =&gt; _serviceProvider.GetRequiredService&lt;CsvInvoiceGenerator&gt;(),
            _ =&gt; throw new ArgumentException("Invalid/Unsupported InvoiceFormat", nameof(invoiceFormat))
        };
    }
}</code></code></pre><ul><li><p><strong>Register factory class in DI container</strong>: We need to register our factory class together with an interface to inject it to the place where it will be used (here minimal api endpoint)</p></li></ul><pre><code><code>var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton&lt;IInvoiceGeneratorFactory, InvoiceGeneratorFactory&gt;();</code></code></pre><p>Finally we can use our factory in the minimal api endpoint as follows:</p><pre><code><code>app.MapGet("/api/invoice/{id}/{format}", (Guid id, InvoiceFormat format,
        IInvoiceGeneratorFactory invoiceGeneratorFactory) =&gt;
{
    var generator = invoiceGeneratorFactory.CreateInvoiceGenerator(format);
    var invoiceData = generator.GenerateInvoice(id);
    var contentType = generator.GetContentType();

    string fileName = $"Invoice_{id}.{format.ToString().ToLower()}";

    return Results.File(invoiceData, contentType, fileName);
})
.WithName("GenerateInvoice")
.WithOpenApi();</code></code></pre><p>Now we don&#8217;t need to pass the invoice format to the main InvoiceGenerator class, because the InvoiceGeneratorFactory class based on the format will return for us the concrete class that handles only this specific invoice format.</p><h2><strong>Summary</strong></h2><p>Applying the Factory Pattern addresses these issues by delegating object creation to a dedicated factory class. Here&#8217;s how it helps:</p><ol><li><p><strong>Adherence to Single Responsibility Principle</strong>: The responsibility for creating specific invoice formats is moved out of InvoiceGenerator class, allowing it to focus solely on orchestrating invoice generation.</p></li><li><p><strong>Compliance with Open/Closed Principle</strong>: New formats can be added by creating new classes that implement a common interface (e.g., <code>IInvoiceGenerator</code>) without modifying existing code.</p></li><li><p><strong>Improved Testability</strong>: Each format-specific logic is encapsulated in its own class, making unit testing more straightforward.</p></li><li><p><strong>Reduced Code Duplication</strong>: The factory centralizes format handling logic, eliminating redundant <code>switch</code> statements.</p></li><li><p><strong>Enhanced Maintainability</strong>: The separation of concerns makes it easier to understand, extend, and modify individual parts of the codebase.</p></li></ol><p>By refactoring this code with the Factory Pattern, we can create a cleaner, more modular design that adheres to SOLID principles and other clean code practices.</p>]]></content:encoded></item></channel></rss>