<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" 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">
    <channel>
        <title>Milan Jovanović: .NET &amp; Software Architecture Articles</title>
        <link>https://milanjovanovic.tech/articles</link>
        <description>In-depth, evergreen guides on .NET, ASP.NET Core, EF Core, distributed systems, testing, and software architecture.</description>
        <lastBuildDate>Mon, 31 Aug 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>Feed for milanjovanovic.tech articles</generator>
        <image>
            <title>Milan Jovanović: .NET &amp; Software Architecture Articles</title>
            <url>https://milanjovanovic.tech/profile.png</url>
            <link>https://milanjovanovic.tech/articles</link>
        </image>
        <copyright>All rights reserved 2026, Milan Jovanović</copyright>
        <atom:link href="https://milanjovanovic.tech/rss/articles.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Temporal Tables in EF Core for Data Auditing]]></title>
            <link>https://milanjovanovic.tech/blog/temporal-tables-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/temporal-tables-ef-core</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Temporal tables automatically track the full history of every row. EF Core 6+ has built-in support for configuring and querying temporal tables on SQL Server.]]></description>
            <content:encoded><![CDATA[<p><strong>Temporal tables</strong> are a SQL Server feature that automatically keeps the full history of every row, and EF Core 6+ can configure and query them.
On each update or delete, SQL Server copies the previous version into a history table, so you can read a row as it existed at any point in time.
That covers recovery and investigation without audit code, provided retention and SQL Server lock-in are acceptable.</p>
<p>An audit record written by application code can miss changes made outside the application.
Temporal tables move row-history capture into the database instead.</p>
<h2>What Are Temporal Tables?</h2>
<p>Temporal tables are a SQL Server feature that automatically maintains the full history of data changes. Every time a row is inserted, updated, or deleted, SQL Server copies the previous version to a history table with timestamps.</p>
<p>You don't need to write any <a href="https://milanjovanovic.tech/blog/audit-logging-ef-core">audit logging</a> code. The database handles it transparently. EF Core 6+ added first-class support for configuring and querying temporal tables.</p>
<h2>Configuring Temporal Tables</h2>
<p>Enable temporal tables in your entity configuration:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.ToTable(&quot;Orders&quot;, b =&gt; b.IsTemporal());
    }
}
</code></pre>
<p>That's it. When you create a <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices">migration</a>, EF Core generates:</p>
<pre><code class="language-sql">CREATE TABLE [Orders] (
    [Id] uniqueidentifier NOT NULL,
    [Status] nvarchar(50) NOT NULL,
    [TotalAmount] decimal(18,2) NOT NULL,
    [PeriodStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    [PeriodEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
    CONSTRAINT [PK_Orders] PRIMARY KEY ([Id]),
    PERIOD FOR SYSTEM_TIME ([PeriodStart], [PeriodEnd])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [dbo].[OrdersHistory]));
</code></pre>
<p>SQL Server adds two hidden columns (<code>PeriodStart</code> and <code>PeriodEnd</code>) and creates a history table automatically.</p>
<h2>Customizing Period Columns</h2>
<p>You can customize the column names and history table name:</p>
<pre><code class="language-csharp">builder.ToTable(&quot;Orders&quot;, b =&gt; b.IsTemporal(t =&gt;
{
    t.HasPeriodStart(&quot;ValidFrom&quot;);
    t.HasPeriodEnd(&quot;ValidTo&quot;);
    t.UseHistoryTable(&quot;OrderAuditHistory&quot;);
}));
</code></pre>
<p>The period columns are <strong>shadow properties</strong>.
They don't exist on your entity class, but you can still read them in queries with <code>EF.Property</code>:</p>
<pre><code class="language-csharp">var orders = await context.Orders
    .Select(o =&gt; new
    {
        o.Id,
        o.Status,
        PeriodStart = EF.Property&lt;DateTime&gt;(o, &quot;PeriodStart&quot;),
        PeriodEnd = EF.Property&lt;DateTime&gt;(o, &quot;PeriodEnd&quot;)
    })
    .ToListAsync();
</code></pre>
<p>Mapping the period columns to regular CLR properties on the entity isn't supported until EF Core 11 (in preview at the time of writing).
On earlier versions, <code>EF.Property</code> is the only way to get at them.</p>
<h2>How It Works</h2>
<p>When you update an entity through EF Core:</p>
<pre><code class="language-csharp">var order = await context.Orders.FindAsync(orderId);
order.Status = OrderStatus.Shipped;
await context.SaveChangesAsync();
</code></pre>
<p>SQL Server automatically:</p>
<ol>
<li>Copies the current row (with the old values) to the history table</li>
<li>Updates the current row with the new values</li>
<li>Sets the <code>PeriodStart</code> of the updated row to the transaction start time (UTC)</li>
<li>Sets the <code>PeriodEnd</code> of the history row to that same timestamp</li>
</ol>
<p>You don't need to intercept <code>SaveChangesAsync</code> or use the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> for audit tracking. The database does everything.</p>
<img src="https://milanjovanovic.tech/blogs/articles/temporal-tables-ef-core/temporal-update-flow.png" alt="When an Order row is updated, SQL Server updates the current row in the Orders table and automatically copies the previous version into the OrdersHistory table">
<h2>Querying Current Data</h2>
<p>Regular queries work exactly as before:</p>
<pre><code class="language-csharp">var orders = await context.Orders
    .Where(o =&gt; o.Status == OrderStatus.Shipped)
    .ToListAsync();
</code></pre>
<p>This returns only current data. The history table is invisible to normal queries.</p>
<h2>TemporalAsOf</h2>
<p>Query how the data looked at a specific point in time:</p>
<pre><code class="language-csharp">var yesterday = DateTime.UtcNow.AddDays(-1);

var ordersAsOfYesterday = await context.Orders
    .TemporalAsOf(yesterday)
    .Where(o =&gt; o.Id == orderId)
    .ToListAsync();
</code></pre>
<p>This returns the row as it existed at that exact timestamp. If an order was <code>Confirmed</code> yesterday but <code>Shipped</code> today, <code>TemporalAsOf</code> returns the <code>Confirmed</code> version.</p>
<h2>TemporalBetween</h2>
<p>Query all versions of a row within a time range:</p>
<pre><code class="language-csharp">var startDate = DateTime.UtcNow.AddDays(-7);
var endDate = DateTime.UtcNow;

var orderHistory = await context.Orders
    .TemporalBetween(startDate, endDate)
    .Where(o =&gt; o.Id == orderId)
    .OrderBy(o =&gt; EF.Property&lt;DateTime&gt;(o, &quot;PeriodStart&quot;))
    .ToListAsync();
</code></pre>
<p>This returns every version of the order from the last seven days. You get one row for each change.</p>
<h2>TemporalAll</h2>
<p>Get the complete history of a row from creation to now:</p>
<pre><code class="language-csharp">var fullHistory = await context.Orders
    .TemporalAll()
    .Where(o =&gt; o.Id == orderId)
    .OrderBy(o =&gt; EF.Property&lt;DateTime&gt;(o, &quot;PeriodStart&quot;))
    .Select(o =&gt; new
    {
        o.Id,
        o.Status,
        o.TotalAmount,
        ValidFrom = EF.Property&lt;DateTime&gt;(o, &quot;PeriodStart&quot;),
        ValidTo = EF.Property&lt;DateTime&gt;(o, &quot;PeriodEnd&quot;)
    })
    .ToListAsync();
</code></pre>
<p>This includes the current row and all historical versions. It's useful for building audit trails and change history views.</p>
<h2>TemporalContainedIn and TemporalFromTo</h2>
<p>Two more temporal operators for specific range semantics:</p>
<pre><code class="language-csharp">// Rows whose validity period started AND ended within the range
var contained = await context.Orders
    .TemporalContainedIn(startDate, endDate)
    .Where(o =&gt; o.Id == orderId)
    .ToListAsync();

// Rows that were active at any point between the two times
var fromTo = await context.Orders
    .TemporalFromTo(startDate, endDate)
    .Where(o =&gt; o.Id == orderId)
    .ToListAsync();
</code></pre>
<p><code>TemporalBetween</code> is nearly identical to <code>TemporalFromTo</code>.
The difference: it also includes rows that became active exactly on the upper boundary.</p>
<h2>Restoring Deleted Data</h2>
<p>One powerful use case - restoring accidentally deleted records:</p>
<pre><code class="language-csharp">// Find the deleted order in history
var deletedOrder = await context.Orders
    .TemporalAll()
    .Where(o =&gt; o.Id == orderId)
    .OrderByDescending(o =&gt; EF.Property&lt;DateTime&gt;(o, &quot;PeriodStart&quot;))
    .FirstOrDefaultAsync();

if (deletedOrder is not null)
{
    // Re-insert it
    context.Orders.Add(new Order
    {
        Id = deletedOrder.Id,
        Status = deletedOrder.Status,
        TotalAmount = deletedOrder.TotalAmount
    });

    await context.SaveChangesAsync();
}
</code></pre>
<p>The history table preserves deleted row versions until its retention or cleanup policy removes them, giving you a recovery path inside that window.</p>
<p>Queries using temporal operators are <strong>no-tracking by default</strong>.
A historical version is not the current row, so EF Core keeps it out of the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a>, and loading history while the current version is tracked doesn't cause identity conflicts.
That's also why the restore example creates a new <code>Order</code> instead of re-attaching the historical instance.</p>
<h2>Managing History Growth</h2>
<p>Every update writes a row to the history table.
On a hot table, that adds up fast, and the history table has no automatic cleanup by default.</p>
<p>SQL Server has a built-in retention policy, but EF Core doesn't expose it.
Apply it with raw SQL in a migration:</p>
<pre><code class="language-csharp">protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.Sql(
        @&quot;ALTER TABLE [Orders]
          SET (SYSTEM_VERSIONING = ON (HISTORY_RETENTION_PERIOD = 6 MONTHS));&quot;);
}
</code></pre>
<p>SQL Server then deletes history rows older than six months in the background.
Pick a retention period that matches your compliance requirements.
Keeping history forever on a frequently updated table just grows your storage bill.</p>
<h2>Temporal Tables vs Application-Level Auditing</h2>
<p>Temporal tables answer <em>what</em> changed and <em>when</em>. They can't answer <em>who</em> changed it or <em>why</em>, because SQL Server never sees your user context.</p>
<p>Use temporal tables when:</p>
<ul>
<li>You need point-in-time reconstruction of data (regulatory snapshots, debugging &quot;what did the customer see&quot;)</li>
<li>You want zero application code for history tracking</li>
<li>You're on SQL Server and can afford the storage</li>
</ul>
<p>Use <a href="https://milanjovanovic.tech/blog/audit-logging-ef-core">application-level audit logging</a> when:</p>
<ul>
<li>You need the acting user, correlation ID, or business reason attached to each change</li>
<li>You're on PostgreSQL or another provider</li>
<li>You only care about a handful of important entities, not every column change</li>
</ul>
<p>In practice, many systems combine both: temporal tables for full data history, plus a lightweight audit log with user context via <a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors"><strong>EF Core interceptors</strong></a>.</p>
<h2>Limitations</h2>
<p>Temporal tables have a few constraints:</p>
<ul>
<li><strong>SQL Server only</strong> - PostgreSQL and other databases have different history mechanisms</li>
<li><strong>No filtering on history table</strong> - you can't add <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core">query filters</a> to the history table</li>
<li><strong>Storage growth</strong> - frequent updates on large tables generate significant history data</li>
<li><strong>Schema changes</strong> - altering temporal tables requires extra care in migrations</li>
</ul>
<h2>Summary</h2>
<p>SQL Server temporal tables preserve row versions independently of the application write path.
Use EF Core's temporal operators for point-in-time reads and recovery, then define retention and storage policies before history grows without bound.
Add a separate application audit trail when you also need to know who made a change and why.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core DbContext: Configuration and Best Practices]]></title>
            <link>https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[DbContext configuration decides how your app behaves under load: service lifetime, tracking defaults, pooling, retries, and interceptors.]]></description>
            <content:encoded><![CDATA[<p><code>DbContext</code> is both a unit of work and the boundary around EF Core's tracked state.
That makes its lifetime, configuration, and ownership more important than the few lines required to register it.
In ASP.NET Core it belongs in DI as <strong>scoped</strong>, one instance per HTTP request, and never as a singleton, because it is not thread-safe.</p>
<p>The rest of the configuration that matters is where entity configuration lives, the default tracking behavior, connection retries, interceptors, and whether pooling is worth its constraints.
A good setup keeps requests isolated, startup predictable, and database concerns out of application code.</p>
<h2>What Is DbContext?</h2>
<p><code>DbContext</code> is your session with the database. It tracks changes to your entities, generates SQL, and manages the connection and transactions.</p>
<p>Every EF Core operation goes through the <code>DbContext</code>. How you configure and use it directly impacts the performance and correctness of your application.</p>
<h2>Registration and Lifetime</h2>
<p>Register your <code>DbContext</code> with the DI container using <code>AddDbContext</code>:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(builder.Configuration.GetConnectionString(&quot;Database&quot;)));
</code></pre>
<p>The default lifetime is <strong>Scoped</strong> - one instance per HTTP request. This is correct for most web applications.</p>
<p><strong>Never register DbContext as Singleton.</strong> It's not thread-safe, and you'll get concurrency exceptions.</p>
<p>For background services, create a scope manually:</p>
<pre><code class="language-csharp">public class OrderProcessingService(IServiceScopeFactory scopeFactory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var scope = scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
        // Use dbContext within this scope
    }
}
</code></pre>
<p>For more on DI lifetimes, see <strong>Dependency Injection Lifetimes in .NET</strong>.</p>
<h2>Applying Entity Configurations</h2>
<p>Don't configure entities inline in <code>OnModelCreating</code>. Use <code>IEntityTypeConfiguration&lt;T&gt;</code> classes:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.HasKey(o =&gt; o.Id);

        builder.Property(o =&gt; o.Status)
            .HasConversion&lt;string&gt;()
            .HasMaxLength(50);

        builder.HasMany(o =&gt; o.LineItems)
            .WithOne()
            .HasForeignKey(li =&gt; li.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.ComplexProperty(o =&gt; o.TotalAmount, money =&gt;
        {
            money.Property(m =&gt; m.Amount).HasColumnName(&quot;total_amount&quot;);
            money.Property(m =&gt; m.Currency).HasColumnName(&quot;total_currency&quot;);
        });
    }
}
</code></pre>
<p>Apply all configurations automatically:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
</code></pre>
<p>This keeps your DbContext class clean and each entity's configuration in its own file.</p>
<h2>Connection Resiliency</h2>
<p>Network issues happen. Configure retry logic for transient failures:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(
        builder.Configuration.GetConnectionString(&quot;Database&quot;),
        npgsqlOptions =&gt; npgsqlOptions
            .EnableRetryOnFailure(
                maxRetryCount: 3,
                maxRetryDelay: TimeSpan.FromSeconds(5),
                errorCodesToAdd: null)));
</code></pre>
<p>This handles temporary database outages without crashing your application.
I cover the details (including the execution strategy gotchas with manual transactions) in <a href="https://milanjovanovic.tech/blog/ef-core-connection-resiliency"><strong>EF Core connection resiliency</strong></a>.</p>
<h2>Query Tracking Behavior</h2>
<p>By default, EF Core tracks all entities returned by queries. This is useful for write operations but wasteful for read-only queries.</p>
<p><strong>Option 1: Disable tracking globally, enable per query:</strong></p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
          .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
</code></pre>
<p>Then opt in when you need tracking:</p>
<pre><code class="language-csharp">var order = await _dbContext.Orders
    .AsTracking()
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<p><strong>Option 2: Keep tracking on, use <code>AsNoTracking</code> for reads:</strong></p>
<pre><code class="language-csharp">var orders = await _dbContext.Orders
    .AsNoTracking()
    .Where(o =&gt; o.Status == OrderStatus.Pending)
    .ToListAsync();
</code></pre>
<p>If you're using <a href="https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start"><strong>CQRS</strong></a>, query handlers should always use <code>AsNoTracking()</code> since they never modify data.</p>
<h2>Interceptors</h2>
<p><a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors"><strong>EF Core Interceptors</strong></a> let you hook into the database pipeline for cross-cutting concerns:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;((sp, options) =&gt;
    options.UseNpgsql(connectionString)
           .AddInterceptors(
               sp.GetRequiredService&lt;PublishDomainEventsInterceptor&gt;(),
               sp.GetRequiredService&lt;AuditableEntityInterceptor&gt;()));

builder.Services.AddScoped&lt;PublishDomainEventsInterceptor&gt;();
builder.Services.AddScoped&lt;AuditableEntityInterceptor&gt;();
</code></pre>
<p>Common interceptor use cases:</p>
<ul>
<li><a href="https://milanjovanovic.tech/blog/domain-events-vs-integration-events"><strong>Publishing domain events</strong></a> after <code>SaveChanges</code></li>
<li>Setting <code>CreatedAt</code> and <code>ModifiedAt</code> timestamps automatically</li>
<li><a href="https://milanjovanovic.tech/blog/audit-logging-ef-core"><strong>Audit logging</strong></a> and soft-delete behavior</li>
<li>Query logging and diagnostics</li>
</ul>
<h2>Split Read/Write Contexts</h2>
<p>For applications with different read and write patterns, consider separate DbContext classes:</p>
<img src="https://milanjovanovic.tech/blogs/articles/dbcontext-configuration-best-practices/read-write-contexts.png" alt="Application sending commands to a WriteDbContext with change tracking against the primary database, and queries to a no-tracking ReadDbContext against a read replica">
<pre><code class="language-csharp">// Write context - full entity model with change tracking
public class WriteDbContext : DbContext
{
    public WriteDbContext(DbContextOptions&lt;WriteDbContext&gt; options) : base(options) { }

    public DbSet&lt;Order&gt; Orders { get; set; }
    public DbSet&lt;Customer&gt; Customers { get; set; }
}

// Read context - optimized for queries
public class ReadDbContext : DbContext
{
    public ReadDbContext(DbContextOptions&lt;ReadDbContext&gt; options) : base(options) { }

    // Read models, not domain entities
    public DbSet&lt;OrderReadModel&gt; Orders { get; set; }
}
</code></pre>
<p>Register them with different configurations:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;WriteDbContext&gt;(options =&gt;
    options.UseNpgsql(writeConnectionString));

builder.Services.AddDbContext&lt;ReadDbContext&gt;(options =&gt;
    options.UseNpgsql(readConnectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
</code></pre>
<p>This is particularly useful with read replicas.
When the contexts point to genuinely separate stores rather than replicas, keep their models and migrations isolated as described in <a href="https://milanjovanovic.tech/blog/ef-core-multiple-databases"><strong>using multiple databases with EF Core</strong></a>.</p>
<h2>Pooling</h2>
<p>For high-throughput applications, use <code>AddDbContextPool</code> to reuse DbContext instances:</p>
<pre><code class="language-csharp">builder.Services.AddDbContextPool&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString),
    poolSize: 128);
</code></pre>
<p>Pooling avoids the overhead of creating a new DbContext for every request. The default pool size is 1024.</p>
<p><strong>Note:</strong> Pooled contexts come with constraints.
The context is reset and reused, so it can only have a single public constructor accepting <code>DbContextOptions</code> - you can't inject other services into it, and you shouldn't store any private state on the context.
If your DbContext injects a tenant provider or current-user service, pooling isn't for you.</p>
<p>To be clear about the benefit: pooling saves the allocation and setup cost of the context instance itself.
It's measurable in benchmarks, but for most business applications the difference is negligible.
Don't reach for it until profiling says so.</p>
<p>If you need to create contexts on demand (Blazor components, parallel operations, background jobs), use a context factory:</p>
<pre><code class="language-csharp">builder.Services.AddPooledDbContextFactory&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString));
</code></pre>
<pre><code class="language-csharp">public class ReportGenerator(IDbContextFactory&lt;AppDbContext&gt; factory)
{
    public async Task GenerateAsync()
    {
        await using var dbContext = await factory.CreateDbContextAsync();
        // Each call gets its own context instance
    }
}
</code></pre>
<h2>Unit of Work Pattern</h2>
<p>DbContext already implements the Unit of Work pattern - <code>SaveChangesAsync</code> commits all tracked changes in a single transaction.</p>
<p>Expose it through an interface for Clean Architecture:</p>
<pre><code class="language-csharp">public interface IUnitOfWork
{
    Task&lt;int&gt; SaveChangesAsync(CancellationToken cancellationToken = default);
}

public class AppDbContext : DbContext, IUnitOfWork
{
    // SaveChangesAsync is already implemented by DbContext
}
</code></pre>
<p>Your Application layer depends on <code>IUnitOfWork</code> (abstraction), not on <code>AppDbContext</code> (implementation).</p>
<p>For more details, see <a href="https://milanjovanovic.tech/blog/unit-of-work-pattern-ef-core"><strong>Unit of Work Pattern With EF Core</strong></a>.</p>
<h2>Common Mistakes</h2>
<ol>
<li>
<p><strong>Injecting DbContext into singleton services</strong> - causes thread-safety issues. Use <code>IDbContextFactory</code> or <code>IServiceScopeFactory</code> instead.</p>
</li>
<li>
<p><strong>Not disposing DbContext</strong> - handled automatically by DI when registered as Scoped, but be careful with manual creation.</p>
</li>
<li>
<p><strong>Loading too much data</strong> - always filter and project. Use <code>Select</code> to return only the columns you need.</p>
</li>
<li>
<p><strong>Ignoring the N+1 problem</strong> - use <code>Include</code> for related data or project with <code>Select</code>. See <a href="https://milanjovanovic.tech/blog/n-plus-one-query-ef-core"><strong>N+1 Query Problem in EF Core</strong></a>.</p>
</li>
<li>
<p><strong>Not using transactions explicitly for multi-step operations</strong> - <code>SaveChangesAsync</code> is transactional, but if you call it multiple times, each call is a separate transaction. Wrap multi-save operations in an explicit transaction:</p>
</li>
</ol>
<pre><code class="language-csharp">await using var transaction =
    await _dbContext.Database.BeginTransactionAsync();

_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();

_dbContext.Shipments.Add(shipment);
await _dbContext.SaveChangesAsync();

await transaction.CommitAsync();
</code></pre>
<ol start="6">
<li><strong>Sharing a DbContext across threads</strong> - the context is not thread-safe. Never run parallel queries on the same instance; create a context per parallel operation with <code>IDbContextFactory</code>.</li>
</ol>
<h2>Summary</h2>
<p>Treat a <code>DbContext</code> as one short-lived unit of work and never share it across concurrent operations.
Keep entity configuration external, match tracking to query intent, and use interceptors for persistence concerns that truly apply to every write.
Pooling and retry policies are workload-specific optimizations, not substitutes for a clear context boundary.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Audit Logging With EF Core Interceptors]]></title>
            <link>https://milanjovanovic.tech/blog/audit-logging-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/audit-logging-ef-core</guid>
            <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A SaveChangesInterceptor gives you one place to capture every insert, update, and delete: the acting user, the old and new values, and the affected columns.]]></description>
            <content:encoded><![CDATA[<p><strong>Audit logging</strong> in EF Core records who changed which entity, when, and what the values were before and after the change.
The practical implementation is a <code>SaveChangesInterceptor</code> that inspects the <code>ChangeTracker</code> before saving, builds an audit entry for every added, modified, and deleted entity, and writes those rows in the same transaction as the change itself.</p>
<p>An update tells you what the database looks like now.
It does not tell you who changed it, what the previous value was, or which request made the change.
EF Core interceptors can capture that history at the persistence boundary without spreading audit code through every use case.</p>
<h2>Why Audit Logging?</h2>
<p>Audit logging answers the question &quot;who changed this and when?&quot; Regulatory compliance often requires it, and it makes debugging production data issues far easier.</p>
<p>The challenge is implementing it without scattering audit code across every handler.
If every command handler has to remember to write an audit record, someone will forget, and you'll discover the gap during an incident review.</p>
<p><a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors"><strong>EF Core interceptors</strong></a> give us a single place to capture changes.
Every write goes through <code>SaveChanges</code>, so an interceptor sees everything.</p>
<img src="https://milanjovanovic.tech/blogs/articles/audit-logging-ef-core/audit-interceptor-flow.png" alt="Audit interceptor flow: when SaveChangesAsync is called, the interceptor reads the ChangeTracker entries, builds an audit entry per changed IAuditable entity, adds the audit rows to the same DbContext, and everything commits in one transaction.">
<h2>The Audit Log Entity</h2>
<pre><code class="language-csharp">public class AuditLogEntry
{
    public Guid Id { get; set; }
    public string EntityName { get; set; }
    public string EntityId { get; set; }
    public string Action { get; set; } // Added, Modified, Deleted
    public string? UserId { get; set; }
    public DateTime Timestamp { get; set; }
    public string? OldValues { get; set; }
    public string? NewValues { get; set; }
    public string? AffectedColumns { get; set; }
}
</code></pre>
<h2>The IAuditable Marker Interface</h2>
<p>Tag entities you want to audit:</p>
<pre><code class="language-csharp">public interface IAuditable { }

public class Order : IAuditable
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }
    public decimal TotalAmount { get; set; }
    public string CustomerId { get; set; }
}
</code></pre>
<h2>The SaveChanges Interceptor</h2>
<pre><code class="language-csharp">public sealed class AuditLoggingInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public AuditLoggingInterceptor(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public override ValueTask&lt;InterceptionResult&lt;int&gt;&gt; SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult&lt;int&gt; result,
        CancellationToken ct = default)
    {
        var context = eventData.Context;

        if (context is null)
        {
            return base.SavingChangesAsync(eventData, result, ct);
        }

        var auditEntries = CreateAuditEntries(context);

        context.Set&lt;AuditLogEntry&gt;().AddRange(auditEntries);

        return base.SavingChangesAsync(eventData, result, ct);
    }

    private List&lt;AuditLogEntry&gt; CreateAuditEntries(DbContext context)
    {
        var entries = new List&lt;AuditLogEntry&gt;();

        context.ChangeTracker.DetectChanges();

        foreach (var entry in context.ChangeTracker.Entries&lt;IAuditable&gt;())
        {
            if (entry.State is EntityState.Detached or EntityState.Unchanged)
            {
                continue;
            }

            var auditEntry = new AuditLogEntry
            {
                Id = Guid.NewGuid(),
                EntityName = entry.Entity.GetType().Name,
                EntityId = GetPrimaryKey(entry),
                UserId = _currentUser.UserId,
                Timestamp = DateTime.UtcNow,
                Action = entry.State.ToString()
            };

            switch (entry.State)
            {
                case EntityState.Added:
                    auditEntry.NewValues = SerializeProperties(
                        entry.Properties);
                    break;

                case EntityState.Modified:
                    auditEntry.OldValues = SerializeOldValues(entry);
                    auditEntry.NewValues = SerializeNewValues(entry);
                    auditEntry.AffectedColumns = GetModifiedColumns(entry);
                    break;

                case EntityState.Deleted:
                    auditEntry.OldValues = SerializeProperties(
                        entry.Properties);
                    break;
            }

            entries.Add(auditEntry);
        }

        return entries;
    }
}
</code></pre>
<p><code>ICurrentUserService</code> is a small abstraction that exposes the authenticated user's id from the current request.</p>
<p>This overrides only the async path.
If any code path calls the synchronous <code>SaveChanges</code>, override <code>SavingChanges</code> as well and reuse the same <code>CreateAuditEntries</code> logic.</p>
<h2>Helper Methods</h2>
<pre><code class="language-csharp">private static string GetPrimaryKey(EntityEntry entry)
{
    // Handles composite keys by joining all key parts
    var keyParts = entry.Properties
        .Where(p =&gt; p.Metadata.IsPrimaryKey())
        .Select(p =&gt; p.CurrentValue?.ToString() ?? &quot;null&quot;);

    return string.Join(&quot;,&quot;, keyParts);
}

private static string SerializeProperties(
    IEnumerable&lt;PropertyEntry&gt; properties)
{
    var dict = properties.ToDictionary(
        p =&gt; p.Metadata.Name,
        p =&gt; p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}

private static string SerializeOldValues(EntityEntry entry)
{
    var dict = entry.Properties
        .Where(p =&gt; p.IsModified)
        .ToDictionary(
            p =&gt; p.Metadata.Name,
            p =&gt; p.OriginalValue);

    return JsonSerializer.Serialize(dict);
}

private static string SerializeNewValues(EntityEntry entry)
{
    var dict = entry.Properties
        .Where(p =&gt; p.IsModified)
        .ToDictionary(
            p =&gt; p.Metadata.Name,
            p =&gt; p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}

private static string GetModifiedColumns(EntityEntry entry)
{
    var columns = entry.Properties
        .Where(p =&gt; p.IsModified)
        .Select(p =&gt; p.Metadata.Name);

    return string.Join(&quot;,&quot;, columns);
}
</code></pre>
<h2>Registration</h2>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;ApplicationDbContext&gt;((sp, options) =&gt;
{
    options.UseNpgsql(connectionString);
    options.AddInterceptors(
        sp.GetRequiredService&lt;AuditLoggingInterceptor&gt;());
});

builder.Services.AddScoped&lt;AuditLoggingInterceptor&gt;();
</code></pre>
<h2>The DbContext Configuration</h2>
<pre><code class="language-csharp">public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions&lt;ApplicationDbContext&gt; options)
        : base(options)
    {
    }

    public DbSet&lt;AuditLogEntry&gt; AuditLogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;AuditLogEntry&gt;(builder =&gt;
        {
            builder.ToTable(&quot;AuditLogs&quot;);
            builder.HasKey(x =&gt; x.Id);
            builder.HasIndex(x =&gt; x.EntityName);
            builder.HasIndex(x =&gt; x.Timestamp);
            builder.HasIndex(x =&gt; new { x.EntityName, x.EntityId });
        });
    }
}
</code></pre>
<h2>Querying the Audit Log</h2>
<pre><code class="language-csharp">public sealed class GetAuditHistoryHandler
    : IRequestHandler&lt;GetAuditHistoryQuery, List&lt;AuditLogEntry&gt;&gt;
{
    private readonly ApplicationDbContext _db;

    public GetAuditHistoryHandler(ApplicationDbContext db)
    {
        _db = db;
    }

    public async Task&lt;List&lt;AuditLogEntry&gt;&gt; Handle(
        GetAuditHistoryQuery query, CancellationToken ct)
    {
        return await _db.AuditLogs
            .Where(a =&gt; a.EntityName == query.EntityName &amp;&amp;
                        a.EntityId == query.EntityId)
            .OrderByDescending(a =&gt; a.Timestamp)
            .ToListAsync(ct);
    }
}
</code></pre>
<h2>Don't Log Sensitive Data</h2>
<p>The audit log serializes every property by default.
That includes password hashes, API keys, and personal data.</p>
<p>Audit tables are a classic PII blind spot: teams carefully encrypt the <code>Users</code> table, then store every historical value of it in plain JSON next door.
If you're subject to GDPR, &quot;delete this user's data&quot; now includes the audit log too.</p>
<p>The fix is a deny list (or an attribute) applied before serialization:</p>
<pre><code class="language-csharp">private static readonly HashSet&lt;string&gt; ExcludedProperties =
    [&quot;PasswordHash&quot;, &quot;SecurityStamp&quot;, &quot;RefreshToken&quot;];

private static string SerializeProperties(
    IEnumerable&lt;PropertyEntry&gt; properties)
{
    var dict = properties
        .Where(p =&gt; !ExcludedProperties.Contains(p.Metadata.Name))
        .ToDictionary(
            p =&gt; p.Metadata.Name,
            p =&gt; p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}
</code></pre>
<p>Decide what's excluded when you build the feature, not after your first data audit.</p>
<h2>Performance and Retention</h2>
<p>Practical production considerations:</p>
<ul>
<li><strong>Every audited change is an extra insert.</strong> For typical CRUD workloads, this is noise. For hot write paths (thousands of writes per second), audit selectively - that's exactly what the <code>IAuditable</code> marker is for.</li>
<li><strong>The audit table grows forever</strong> unless you do something about it. Add a retention job that archives or deletes entries older than your compliance window.</li>
<li><strong>Index intentionally.</strong> The indexes in the DbContext configuration above support &quot;history of this entity&quot; queries. Skip indexes you don't query on; they slow down every insert.</li>
<li><strong>Don't query audit JSON in hot paths.</strong> If you need to report on audit data, project it into a proper reporting table instead of parsing JSON columns at query time.</li>
</ul>
<h2>Soft Deletes + Audit Log</h2>
<p>Combine audit logging with <a href="https://milanjovanovic.tech/blog/implementing-soft-delete-with-ef-core"><strong>soft deletes</strong></a>:</p>
<pre><code class="language-csharp">public override ValueTask&lt;InterceptionResult&lt;int&gt;&gt; SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult&lt;int&gt; result,
    CancellationToken ct = default)
{
    var context = eventData.Context;

    if (context is null)
    {
        return base.SavingChangesAsync(eventData, result, ct);
    }

    // Handle soft deletes
    foreach (var entry in context.ChangeTracker
        .Entries&lt;ISoftDeletable&gt;()
        .Where(e =&gt; e.State == EntityState.Deleted))
    {
        entry.State = EntityState.Modified;
        entry.Entity.IsDeleted = true;
        entry.Entity.DeletedAt = DateTime.UtcNow;
    }

    // Create audit entries (includes soft delete as &quot;Modified&quot;)
    var auditEntries = CreateAuditEntries(context);
    context.Set&lt;AuditLogEntry&gt;().AddRange(auditEntries);

    return base.SavingChangesAsync(eventData, result, ct);
}
</code></pre>
<h2>Alternative: Temporal Tables</h2>
<p>SQL Server <a href="https://milanjovanovic.tech/blog/temporal-tables-ef-core"><strong>temporal tables</strong></a> provide database-level change tracking.
They're a great fit when you need point-in-time queries (&quot;what did this row look like last Tuesday?&quot;) with zero application code.</p>
<p>But they only track what changed, not <strong>who</strong> changed it, and they capture the full row rather than just the modified columns.
They also don't see changes as business actions - just row versions.</p>
<p>For user-level audit trails, use the interceptor approach.
For time-travel queries or protection against direct SQL modifications, use temporal tables.
Some systems justify both.</p>
<p>One more caveat for the interceptor approach: it only sees changes that go through the change tracker.
Bulk operations like <code>ExecuteUpdateAsync</code> and raw SQL bypass <code>SaveChanges</code> entirely, so they bypass your audit log too.
If you use <a href="https://milanjovanovic.tech/blog/what-you-need-to-know-about-ef-core-bulk-updates"><strong>bulk updates</strong></a>, write their audit entries explicitly.</p>
<h2>Summary</h2>
<p><code>SaveChangesInterceptor</code> is a useful audit boundary because it sees tracked inserts, updates, and deletes in one place.
Filter the audited entities and properties, include user context, and keep sensitive values out of the payload.
Bulk APIs and raw SQL bypass this path, so cover those writes separately or make the limitation explicit.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Unit of Work Pattern With EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/unit-of-work-pattern-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/unit-of-work-pattern-ef-core</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core DbContext already implements the Unit of Work pattern. But sometimes you need an explicit abstraction.]]></description>
            <content:encoded><![CDATA[<p><code>DbContext</code> already tracks a set of changes and commits them through <code>SaveChanges</code>.
An additional unit-of-work interface is useful only when it creates a real application boundary or coordinates repositories over that same context.
Otherwise it is another name for an abstraction EF Core already provides.</p>
<h2>DbContext Is Already a Unit of Work</h2>
<p>The <strong>Unit of Work pattern</strong> groups all the changes made during one business operation and commits them together in a single transaction.</p>
<p>Before implementing anything, let's acknowledge the obvious: <code>DbContext</code> <strong>is</strong> a Unit of Work.
It does exactly that when you call <code>SaveChangesAsync</code>.</p>
<p>The <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> collects every insert, update, and delete. <code>SaveChangesAsync</code> wraps them all in a transaction. Either everything succeeds or nothing does. That's the Unit of Work pattern.</p>
<p>So why would you create an explicit <code>IUnitOfWork</code> interface?</p>
<h2>Why an Explicit Abstraction?</h2>
<p>There are two practical reasons:</p>
<ol>
<li>
<p><strong>Dependency inversion</strong> - your domain and application layers shouldn't reference <code>DbContext</code> or EF Core directly. An <code>IUnitOfWork</code> interface lets them coordinate persistence without knowing the implementation.</p>
</li>
<li>
<p><strong>Controlled save points</strong> - when multiple <a href="https://milanjovanovic.tech/blog/repository-pattern-csharp">repositories</a> modify entities in the same business operation, you want a single <code>SaveChangesAsync</code> call at the end. An explicit Unit of Work makes this coordination visible.</p>
</li>
</ol>
<p>If your application layer already references EF Core and you're fine with that coupling, you might not need a separate abstraction. But in <a href="https://milanjovanovic.tech/blog/clean-architecture-dotnet">clean architecture</a> or projects following DDD, the abstraction pays for itself.</p>
<h2>Defining the Interface</h2>
<p>Keep it minimal:</p>
<pre><code class="language-csharp">public interface IUnitOfWork
{
    Task&lt;int&gt; SaveChangesAsync(CancellationToken cancellationToken = default);
}
</code></pre>
<p>That's the core contract. The application layer calls <code>SaveChangesAsync</code> when the business operation is complete. It doesn't know or care that EF Core is behind it.</p>
<h2>Implementing With DbContext</h2>
<p>The implementation is straightforward - your <code>DbContext</code> implements <code>IUnitOfWork</code>:</p>
<pre><code class="language-csharp">public class AppDbContext : DbContext, IUnitOfWork
{
    public AppDbContext(DbContextOptions&lt;AppDbContext&gt; options)
        : base(options)
    {
    }

    public DbSet&lt;Order&gt; Orders { get; set; }
    public DbSet&lt;Customer&gt; Customers { get; set; }
    public DbSet&lt;Product&gt; Products { get; set; }
}
</code></pre>
<p><code>DbContext</code> already has <code>SaveChangesAsync</code>, so it satisfies the interface without any additional code.</p>
<p>Register both:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString));

builder.Services.AddScoped&lt;IUnitOfWork&gt;(sp =&gt;
    sp.GetRequiredService&lt;AppDbContext&gt;());
</code></pre>
<p>Both <code>AppDbContext</code> and <code>IUnitOfWork</code> resolve to the same instance within a scope. This is critical - repositories and the Unit of Work must share the same <code>DbContext</code>.</p>
<h2>Coordinating Repositories</h2>
<p>Here's the pattern in a use case. Multiple repositories make changes, and a single <code>SaveChangesAsync</code> commits everything:</p>
<pre><code class="language-csharp">public class PlaceOrderCommandHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderCommandHandler(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        var customer = await _customerRepository.GetByIdAsync(command.CustomerId, ct);

        var order = Order.Create(customer.Id, command.Items);

        customer.IncrementOrderCount();

        _orderRepository.Add(order);

        await _unitOfWork.SaveChangesAsync(ct);
    }
}
</code></pre>
<p>Both the <code>Order</code> insert and the <code>Customer</code> update happen in one transaction. If either fails, both are rolled back.</p>
<img src="https://milanjovanovic.tech/blogs/articles/unit-of-work-pattern-ef-core/unit-of-work-flow.png" alt="An order repository and a customer repository both stage changes that the Unit of Work commits with a single SaveChangesAsync call inside one transaction">
<h2>Repository Pattern With Unit of Work</h2>
<p>The repositories handle querying and adding entities. They don't call <code>SaveChangesAsync</code>:</p>
<pre><code class="language-csharp">public interface IOrderRepository
{
    Task&lt;Order?&gt; GetByIdAsync(Guid id, CancellationToken ct = default);
    void Add(Order order);
    void Remove(Order order);
}

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task&lt;Order?&gt; GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o =&gt; o.LineItems)
            .FirstOrDefaultAsync(o =&gt; o.Id == id, ct);
    }

    public void Add(Order order)
    {
        _context.Orders.Add(order);
    }

    public void Remove(Order order)
    {
        _context.Orders.Remove(order);
    }
}
</code></pre>
<p>Notice that <code>Add</code> and <code>Remove</code> are synchronous. They only tell the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> about the entity. The actual database operation happens in <code>SaveChangesAsync</code>.</p>
<h2>Explicit Transactions</h2>
<p>Sometimes <code>SaveChangesAsync</code> isn't enough. You need multiple save points or you're coordinating with external systems. Use <a href="https://milanjovanovic.tech/blog/working-with-transactions-in-ef-core">explicit transactions</a>:</p>
<pre><code class="language-csharp">public interface IUnitOfWork
{
    Task&lt;int&gt; SaveChangesAsync(CancellationToken cancellationToken = default);
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}
</code></pre>
<p>Implementation:</p>
<pre><code class="language-csharp">public class AppDbContext : DbContext, IUnitOfWork
{
    private IDbContextTransaction? _transaction;

    public async Task BeginTransactionAsync(CancellationToken ct)
    {
        _transaction = await Database.BeginTransactionAsync(ct);
    }

    public async Task CommitTransactionAsync(CancellationToken ct)
    {
        if (_transaction is null) return;

        await _transaction.CommitAsync(ct);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(CancellationToken ct)
    {
        if (_transaction is null) return;

        await _transaction.RollbackAsync(ct);
        await _transaction.DisposeAsync();
        _transaction = null;
    }
}
</code></pre>
<p>Use it when a business operation has multiple steps that each need to be persisted:</p>
<pre><code class="language-csharp">await _unitOfWork.BeginTransactionAsync(ct);

try
{
    _orderRepository.Add(order);
    await _unitOfWork.SaveChangesAsync(ct);

    await _paymentService.ChargeAsync(order.TotalAmount, ct);

    order.MarkAsPaid();
    await _unitOfWork.SaveChangesAsync(ct);

    await _unitOfWork.CommitTransactionAsync(ct);
}
catch
{
    await _unitOfWork.RollbackTransactionAsync(ct);
    throw;
}
</code></pre>
<p>A word of caution about that example: holding a database transaction open across an external HTTP call (the payment charge) means a slow payment provider keeps your database connection and locks tied up.
It's acceptable for low-traffic flows, but at scale you want the <a href="https://milanjovanovic.tech/blog/outbox-pattern-for-reliable-microservices-messaging"><strong>Outbox pattern</strong></a> instead: commit locally, then integrate asynchronously.</p>
<p>One more gotcha: if you enabled a retrying execution strategy (<code>EnableRetryOnFailure</code>), you can't just call <code>BeginTransactionAsync</code>.
Wrap the whole operation in <code>CreateExecutionStrategy().ExecuteAsync(...)</code>, or EF Core throws because a retry can't replay a manually started transaction.</p>
<h2>Domain Events and Unit of Work</h2>
<p>If you're using domain events, dispatch them after <code>SaveChangesAsync</code> succeeds. This ensures events aren't published for changes that were rolled back:</p>
<pre><code class="language-csharp">public override async Task&lt;int&gt; SaveChangesAsync(CancellationToken ct = default)
{
    var domainEvents = ChangeTracker.Entries&lt;Entity&gt;()
        .SelectMany(e =&gt; e.Entity.PopDomainEvents())
        .ToList();

    var result = await base.SaveChangesAsync(ct);

    foreach (var domainEvent in domainEvents)
    {
        await _publisher.Publish(domainEvent, ct);
    }

    return result;
}
</code></pre>
<p>The <code>_publisher</code> field is whatever event dispatcher you inject into the context (MediatR's <code>IPublisher</code>, for example).</p>
<p>Note the tradeoff: publishing after the save means a crash between the save and the publish loses the events.
If the events must not be lost, persist them in the same transaction using the transactional outbox instead of publishing in memory.</p>
<h2>When You Don't Need IUnitOfWork</h2>
<p>Not every project needs this abstraction. Skip it when:</p>
<ul>
<li>Your application layer already depends on EF Core</li>
<li>You have simple CRUD operations with a single repository per use case</li>
<li>You're building a small API without layered architecture</li>
</ul>
<p>In these cases, inject <code>AppDbContext</code> directly and call <code>SaveChangesAsync</code> in your handler. Adding <code>IUnitOfWork</code> would be ceremony without value.</p>
<h2>Summary</h2>
<p><code>DbContext</code> already tracks changes and commits them as a unit.
Add <code>IUnitOfWork</code> only when the application needs a narrow commit boundary, and ensure every participating repository shares the same scoped context.
Use an explicit transaction for multiple saves that must roll back together, not as ceremony around a single <code>SaveChanges</code> call.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Repository Pattern in C# With Entity Framework Core]]></title>
            <link>https://milanjovanovic.tech/blog/repository-pattern-csharp</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/repository-pattern-csharp</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The repository pattern is one of the most debated patterns in .NET. This guide shows a clean EF Core implementation, explains why generic repositories…]]></description>
            <content:encoded><![CDATA[<p>EF Core already abstracts the database, tracks identity, and coordinates writes.
Adding a repository can still protect an aggregate boundary, but a generic CRUD wrapper usually hides useful query capabilities without creating a meaningful seam.
The decision should follow the domain boundary, not a rule that every <code>DbSet</code> needs an interface.</p>
<h2>What Is the Repository Pattern?</h2>
<p>The <strong>Repository pattern</strong> mediates between the domain and data mapping layers.
It provides a collection-like interface for accessing domain objects, hiding the details of how data is persisted or retrieved.</p>
<p>In simpler terms: instead of your business logic talking directly to the database, it talks to a repository.
The repository handles the data access behind the scenes.</p>
<pre><code class="language-csharp">public interface IOrderRepository
{
    Task&lt;Order?&gt; GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task&lt;List&lt;Order&gt;&gt; GetByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
    void Add(Order order);
    void Remove(Order order);
}
</code></pre>
<p>The caller doesn't know (or care) whether you're using EF Core, Dapper, or a flat file.
That's the whole point.</p>
<h2>Why Use the Repository Pattern?</h2>
<p>There are a few practical reasons to add repositories on top of EF Core:</p>
<p><strong>1. Abstraction over data access</strong> - Your domain and application layers don't depend on <code>DbContext</code>. If you're following <a href="https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design"><strong>Clean Architecture</strong></a>, this keeps the <a href="https://milanjovanovic.tech/blog/clean-architecture-folder-structure"><strong>Dependency Rule</strong></a> intact.</p>
<p><strong>2. Testability</strong> - You can mock <code>IOrderRepository</code> in unit tests without setting up a database. Testing <a href="https://milanjovanovic.tech/blog/unit-testing-clean-architecture-use-cases"><strong>use cases in Clean Architecture</strong></a> becomes straightforward. For the repository implementations themselves, use real databases in <strong>integration tests</strong>.</p>
<p><strong>3. Encapsulation of query logic</strong> - Complex queries live inside the repository, not scattered across your application. This makes them easier to find, optimize, and reuse.</p>
<p><strong>4. Consistent data access patterns</strong> - Repositories give your team a clear pattern to follow. Every developer knows where data access code lives.</p>
<h2>Implementing a Repository With EF Core</h2>
<p>Here's a concrete implementation of the <code>IOrderRepository</code>:</p>
<pre><code class="language-csharp">public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _dbContext;

    public OrderRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task&lt;Order?&gt; GetByIdAsync(
        int id,
        CancellationToken cancellationToken = default)
    {
        return await _dbContext.Orders
            .Include(o =&gt; o.LineItems)
            .FirstOrDefaultAsync(o =&gt; o.Id == id, cancellationToken);
    }

    public async Task&lt;List&lt;Order&gt;&gt; GetByCustomerAsync(
        int customerId,
        CancellationToken cancellationToken = default)
    {
        return await _dbContext.Orders
            .Where(o =&gt; o.CustomerId == customerId)
            .OrderByDescending(o =&gt; o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public void Add(Order order)
    {
        _dbContext.Orders.Add(order);
    }

    public void Remove(Order order)
    {
        _dbContext.Orders.Remove(order);
    }
}
</code></pre>
<p>Notice that <code>Add</code> and <code>Remove</code> don't call <code>SaveChanges</code>.
That's intentional - saving is the responsibility of the <a href="https://milanjovanovic.tech/blog/working-with-transactions-in-ef-core"><strong>Unit of Work</strong></a>, not the repository.</p>
<h2>The Unit of Work Pattern</h2>
<p>Repositories handle individual aggregate persistence.
The <strong>Unit of Work</strong> coordinates saving changes across multiple repositories in a single transaction.</p>
<p>EF Core's <code>DbContext</code> already implements the Unit of Work pattern internally.
But exposing a clean interface keeps your code decoupled (I cover the full pattern in <a href="https://milanjovanovic.tech/blog/unit-of-work-pattern-ef-core"><strong>Unit of Work with EF Core</strong></a>):</p>
<pre><code class="language-csharp">public interface IUnitOfWork
{
    Task&lt;int&gt; SaveChangesAsync(CancellationToken cancellationToken = default);
}
</code></pre>
<pre><code class="language-csharp">public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _dbContext;

    public UnitOfWork(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task&lt;int&gt; SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        return await _dbContext.SaveChangesAsync(cancellationToken);
    }
}
</code></pre>
<p>Now your use case can orchestrate multiple repositories and save once:</p>
<pre><code class="language-csharp">public async Task Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
{
    var customer = await _customerRepository.GetByIdAsync(command.CustomerId, cancellationToken);

    var order = Order.Create(customer, command.Items);

    _orderRepository.Add(order);

    customer.IncrementOrderCount();

    await _unitOfWork.SaveChangesAsync(cancellationToken);
}
</code></pre>
<h2>The Generic Repository Debate</h2>
<p>You'll find many tutorials suggesting a generic repository:</p>
<pre><code class="language-csharp">public interface IRepository&lt;T&gt; where T : class
{
    Task&lt;T?&gt; GetByIdAsync(int id);
    Task&lt;List&lt;T&gt;&gt; GetAllAsync();
    void Add(T entity);
    void Update(T entity);
    void Remove(T entity);
}
</code></pre>
<p>I'd advise against this approach for most projects. Here's why:</p>
<p><strong>It's a leaky abstraction.</strong> You end up exposing methods that don't make sense for every entity. Should you really be able to call <code>GetAll()</code> on a table with millions of rows?</p>
<p><strong>It pushes query logic into the wrong place.</strong> Callers end up writing LINQ against <code>IQueryable&lt;T&gt;</code>, which defeats the purpose of the repository.</p>
<p><strong>It mirrors DbSet.</strong> If your generic repository just wraps <code>DbSet&lt;T&gt;</code>, you haven't gained anything meaningful - you've just added a layer of indirection.</p>
<p><strong>Better alternative:</strong> Write specific repository interfaces per aggregate root. Each interface exposes only the operations that make sense for that aggregate.</p>
<pre><code class="language-csharp">// ✅ Specific repository - clear intent
public interface IOrderRepository
{
    Task&lt;Order?&gt; GetByIdAsync(int id);
    Task&lt;List&lt;Order&gt;&gt; GetPendingOrdersAsync();
    void Add(Order order);
}

// ❌ Generic repository - unclear intent
public interface IRepository&lt;T&gt; where T : class
{
    Task&lt;T?&gt; GetByIdAsync(int id);
    Task&lt;List&lt;T&gt;&gt; GetAllAsync(); // Millions of rows?
    void Add(T entity);
    void Update(T entity); // EF Core tracks changes automatically
    void Remove(T entity);
}
</code></pre>
<h2>Should Repositories Return IQueryable?</h2>
<p>A common shortcut is exposing <code>IQueryable&lt;T&gt;</code> from the repository:</p>
<pre><code class="language-csharp">// ❌ Avoid this
public interface IOrderRepository
{
    IQueryable&lt;Order&gt; Orders { get; }
}
</code></pre>
<p>It looks flexible, but it defeats the purpose of the pattern:</p>
<p><strong>The abstraction leaks.</strong> Callers can compose any query, including ones that don't translate to SQL. The exception surfaces far from the code that caused it, at enumeration time.</p>
<p><strong>Query logic scatters.</strong> The whole point was to centralize data access. With <code>IQueryable</code>, every handler writes its own <code>Include</code>, filtering, and paging logic.</p>
<p><strong>You can't test the contract.</strong> An in-memory <code>IQueryable</code> behaves differently from the EF Core provider (case sensitivity, null handling, unsupported translations). Your mocks pass while production fails.</p>
<p>Return materialized results (<code>List&lt;T&gt;</code>, <code>T?</code>) or accept a specification object instead.
If a handler needs a truly one-off query, that's a sign it should use <code>DbContext</code> directly - and that's fine.</p>
<h2>When to Skip the Repository Pattern</h2>
<p>The repository pattern isn't always necessary. Skip it when:</p>
<p><strong>You're building a simple CRUD app.</strong> If your app mostly does basic create-read-update-delete operations, the repository adds overhead without meaningful benefit. Just use <code>DbContext</code> directly.</p>
<p><strong>You're using Vertical Slice Architecture.</strong> In <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture"><strong>VSA</strong></a>, each slice owns its data access. Adding a repository per slice is over-engineering - your handler <em>is</em> the data access boundary.</p>
<p><strong>You'll never swap your ORM.</strong> The &quot;what if we switch from EF Core to Dapper&quot; argument rarely materializes. If you're committed to EF Core, the abstraction may not justify its cost.</p>
<p><strong>Your team is small and co-located.</strong> Conventions and code reviews can enforce consistency without a formal pattern.</p>
<h2>Repository Pattern in Clean Architecture</h2>
<p>In Clean Architecture, repository interfaces live in the <strong>Domain layer</strong> (or Application layer) and implementations live in the <strong>Infrastructure layer</strong>.</p>
<img src="https://milanjovanovic.tech/blogs/articles/repository-pattern-csharp/clean-architecture-layers.png" alt="The application handler depends on the IOrderRepository interface in the Domain layer, while the OrderRepository EF Core implementation in the Infrastructure layer implements that interface">
<pre><code class="language-text">Domain/
  Entities/
    Order.cs
  Repositories/
    IOrderRepository.cs      ← Interface here

Infrastructure/
  Repositories/
    OrderRepository.cs        ← Implementation here
</code></pre>
<p>This keeps your domain free of EF Core dependencies while still allowing rich data access behind the scenes.</p>
<p>Register the repository in your DI container:</p>
<pre><code class="language-csharp">builder.Services.AddScoped&lt;IOrderRepository, OrderRepository&gt;();
builder.Services.AddScoped&lt;IUnitOfWork, UnitOfWork&gt;();
</code></pre>
<p>Or use <a href="https://milanjovanovic.tech/blog/improving-aspnetcore-dependency-injection-with-scrutor"><strong>Scrutor</strong></a> for automatic registration by convention.</p>
<h2>Repository Pattern With the Specification Pattern</h2>
<p>For complex query scenarios, combine repositories with the <a href="https://www.martinfowler.com/apsupp/spec.pdf"><strong>Specification pattern</strong></a>.
Specifications encapsulate query criteria into reusable objects:</p>
<pre><code class="language-csharp">public class PendingOrdersSpecification : Specification&lt;Order&gt;
{
    public override Expression&lt;Func&lt;Order, bool&gt;&gt; ToExpression()
    {
        return order =&gt; order.Status == OrderStatus.Pending
                     &amp;&amp; order.CreatedAt &gt;= DateTime.UtcNow.AddDays(-30);
    }
}
</code></pre>
<p>The abstract <code>Specification&lt;T&gt;</code> base class only needs to declare the <code>ToExpression</code> method.
The repository applies the specification to the query:</p>
<pre><code class="language-csharp">public async Task&lt;List&lt;Order&gt;&gt; GetAsync(
    Specification&lt;Order&gt; specification,
    CancellationToken cancellationToken = default)
{
    return await _dbContext.Orders
        .Where(specification.ToExpression())
        .ToListAsync(cancellationToken);
}
</code></pre>
<p>This avoids query logic leaking out of the repository while keeping things flexible.</p>
<h2>Summary</h2>
<p>The repository pattern is a useful abstraction when:</p>
<ul>
<li>You're following Clean Architecture and need to enforce the Dependency Rule</li>
<li>You want to isolate query logic in a consistent, testable way</li>
<li>Your domain has complex data access requirements beyond simple CRUD</li>
</ul>
<p>Skip it when:</p>
<ul>
<li>You're building simple CRUD applications</li>
<li>You're using Vertical Slice Architecture where each handler owns its data access</li>
<li>The abstraction adds complexity without meaningful benefit</li>
</ul>
<p>Avoid generic CRUD repositories.
When a repository protects a real aggregate boundary, expose only the operations and query intent that boundary needs.
EF Core already supplies a unit of work and identity map, so the repository should organize domain-facing access rather than recreate the ORM.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[PostgreSQL Locking for .NET Developers: FOR UPDATE, SKIP LOCKED, and Advisory Locks]]></title>
            <link>https://milanjovanovic.tech/blog/pessimistic-locking-ef-core-postgresql</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/pessimistic-locking-ef-core-postgresql</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[PostgreSQL gives you row locks, SKIP LOCKED job queues, and advisory locks that most .NET developers never touch.]]></description>
            <content:encoded><![CDATA[<p><code>SELECT FOR UPDATE</code> locks every row a query returns until the transaction ends, so other writers block instead of racing you.
<code>FOR UPDATE SKIP LOCKED</code> skips rows another transaction holds, turning a table into a job queue for competing workers.
Advisory locks lock an application-defined 64-bit key rather than a row.
EF Core has no LINQ operator for these, so you reach for raw SQL inside an explicit transaction.</p>
<p>Optimistic concurrency detects a race after another writer wins.
Some workflows need to prevent the conflicting work from starting, or let several workers claim different rows without waiting on each other.
PostgreSQL row and advisory locks provide those guarantees, but only while their transaction and timeout rules are explicit.</p>
<h2>When You Need Database Locks</h2>
<p><a href="https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking">Optimistic concurrency</a> works great when conflicts are rare.
But in high-contention scenarios - ticket booking, inventory management, financial transfers - retrying failed operations can be expensive or impractical.</p>
<p>That's when you lock rows in the database: acquire the lock first, then make your changes.
No other transaction can modify the locked row until you commit or roll back.</p>
<p>I covered the general EF Core approach in <a href="https://milanjovanovic.tech/blog/a-clever-way-to-implement-pessimistic-locking-in-ef-core"><strong>pessimistic locking in EF Core</strong></a>.
This article goes deeper into what PostgreSQL specifically gives you: <code>FOR UPDATE</code> and its variants, <code>SKIP LOCKED</code> job queues, and advisory locks.</p>
<h2>SELECT FOR UPDATE From EF Core</h2>
<p>PostgreSQL's <code>SELECT FOR UPDATE</code> acquires a row-level lock on every row returned by the query.
Other transactions that try to lock the same rows will <strong>block</strong> until the lock is released.</p>
<p>EF Core doesn't have built-in support for <code>SELECT FOR UPDATE</code>, but you can use raw SQL:</p>
<pre><code class="language-csharp">public async Task&lt;Order?&gt; GetOrderForUpdate(
    AppDbContext context,
    Guid orderId,
    CancellationToken ct = default)
{
    return await context.Orders
        .FromSqlInterpolated(
            $@&quot;SELECT * FROM &quot;&quot;Orders&quot;&quot;
               WHERE &quot;&quot;Id&quot;&quot; = {orderId}
               FOR UPDATE&quot;)
        .FirstOrDefaultAsync(ct);
}
</code></pre>
<p>The entity comes back fully tracked, so the normal <code>SaveChangesAsync</code> workflow still applies.
The lock is held for the duration of the transaction and released when it commits or rolls back.</p>
<h2>The Full Transaction Pattern</h2>
<p>The lock only makes sense inside an <strong>explicit transaction</strong>.
Without one, PostgreSQL runs the statement in its own auto-committed transaction, and the lock is released the moment the query finishes.</p>
<pre><code class="language-csharp">public async Task ProcessPayment(Guid orderId, decimal amount)
{
    await using var transaction = await context.Database
        .BeginTransactionAsync();

    try
    {
        // Lock the row - other transactions will wait here
        var order = await context.Orders
            .FromSqlInterpolated(
                $@&quot;SELECT * FROM &quot;&quot;Orders&quot;&quot;
                   WHERE &quot;&quot;Id&quot;&quot; = {orderId}
                   FOR UPDATE&quot;)
            .FirstOrDefaultAsync();

        if (order is null)
        {
            throw new InvalidOperationException(&quot;Order not found.&quot;);
        }

        order.RecordPayment(amount);

        await context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}
</code></pre>
<p>Between <code>BeginTransactionAsync</code> and <code>CommitAsync</code>, the locked row is exclusively yours.
Any other transaction running the same <code>SELECT FOR UPDATE</code> will block until this transaction completes.</p>
<p>One caveat if you enabled <code>EnableRetryOnFailure</code>: a retrying execution strategy can't replay a manually started transaction, so EF Core throws when you call <code>BeginTransactionAsync</code>.
Wrap the whole operation in <code>CreateExecutionStrategy().ExecuteAsync(...)</code> in that case.</p>
<h2>PostgreSQL Row Lock Modes</h2>
<p><code>FOR UPDATE</code> is the strongest row lock, but PostgreSQL has a whole family of locking clauses:</p>
<pre><code class="language-sql">-- Blocks other FOR UPDATE, allows plain reads
SELECT * FROM &quot;Orders&quot; WHERE &quot;Id&quot; = @id FOR UPDATE;

-- Weaker than FOR UPDATE: allows inserts into child tables
-- that reference this row via a foreign key
SELECT * FROM &quot;Orders&quot; WHERE &quot;Id&quot; = @id FOR NO KEY UPDATE;

-- Shared lock - multiple transactions can hold it simultaneously
SELECT * FROM &quot;Orders&quot; WHERE &quot;Id&quot; = @id FOR SHARE;

-- Fail immediately instead of waiting
SELECT * FROM &quot;Orders&quot; WHERE &quot;Id&quot; = @id FOR UPDATE NOWAIT;

-- Skip locked rows (great for job queues)
SELECT * FROM &quot;Orders&quot; WHERE &quot;Status&quot; = 'Pending'
FOR UPDATE SKIP LOCKED LIMIT 1;
</code></pre>
<p>A practical tip most people miss: if you're locking a parent row (say, an <code>Order</code>) while inserting child rows into another table that references it (say, <code>OrderLines</code> in a different transaction), prefer <code>FOR NO KEY UPDATE</code>.
Plain <code>FOR UPDATE</code> blocks those inserts because inserting a referencing row needs a key-share lock on the parent.</p>
<p><code>FOR UPDATE NOWAIT</code> is useful when you'd rather fail fast than block.
<code>SKIP LOCKED</code> deserves its own section.</p>
<h2>SKIP LOCKED for Job Queues</h2>
<p><code>SKIP LOCKED</code> turns a plain table into a competing-consumers job queue.
Each worker locks and processes a different row, and nobody waits on anybody:</p>
<pre><code class="language-csharp">public async Task&lt;Order?&gt; DequeueNextOrder(AppDbContext context)
{
    await using var transaction = await context.Database
        .BeginTransactionAsync();

    var order = await context.Orders
        .FromSqlRaw(
            @&quot;SELECT * FROM &quot;&quot;Orders&quot;&quot;
              WHERE &quot;&quot;Status&quot;&quot; = 'Pending'
              ORDER BY &quot;&quot;CreatedAt&quot;&quot;
              FOR UPDATE SKIP LOCKED
              LIMIT 1&quot;)
        .FirstOrDefaultAsync();

    if (order is not null)
    {
        order.Status = OrderStatus.Processing;
        await context.SaveChangesAsync();
    }

    await transaction.CommitAsync();
    return order;
}
</code></pre>
<p>Multiple workers can call this concurrently.
Each one gets a different row - no conflicts, no retries, no duplicate processing.</p>
<img src="https://milanjovanovic.tech/blogs/articles/pessimistic-locking-ef-core-postgresql/skip-locked-queue.png" alt="Three workers reading pending rows from the same Orders table, each locking a different row via FOR UPDATE SKIP LOCKED so none of them wait on each other">
<p>This is exactly how you scale a <strong>transactional outbox</strong> processor across multiple instances.
Each instance grabs its own batch of outbox messages with <code>SKIP LOCKED</code>, and the rows locked by one instance are invisible to the others.</p>
<p>One gotcha: keep the processing inside the transaction short.
The row stays locked until you commit, so if your handler calls a slow external API while holding the lock, you're serializing on that API call.
For long-running work, mark the row as <code>Processing</code> in one short transaction, commit, then do the work.</p>
<h2>Advisory Locks</h2>
<p>Sometimes you need to lock a <strong>concept</strong> rather than a row.
Maybe the row doesn't exist yet (preventing duplicate user registration), or the thing you're protecting isn't in the database at all (a file, an external API).</p>
<p>PostgreSQL advisory locks let you lock an arbitrary 64-bit integer key:</p>
<pre><code class="language-csharp">public async Task&lt;bool&gt; TryAcquireAdvisoryLock(
    AppDbContext context, long lockKey)
{
    var result = await context.Database
        .SqlQuery&lt;bool&gt;(
            $&quot;SELECT pg_try_advisory_xact_lock({lockKey}) AS \&quot;Value\&quot;&quot;)
        .FirstAsync();

    return result;
}
</code></pre>
<p><code>pg_try_advisory_xact_lock</code> returns <code>true</code> if the lock was acquired, <code>false</code> if another session holds it.
The <code>_xact_</code> variant is the one you want with connection pooling: it releases automatically when the transaction ends, so a returned pooled connection can never carry a forgotten lock.</p>
<p>Use advisory locks for things like:</p>
<ul>
<li>Preventing duplicate processing of the same event</li>
<li>Ensuring only one instance runs a scheduled job</li>
<li>Coordinating access to external resources</li>
</ul>
<pre><code class="language-csharp">public async Task ProcessEvent(Guid eventId)
{
    await using var transaction = await context.Database
        .BeginTransactionAsync();

    var lockKey = BitConverter.ToInt64(eventId.ToByteArray(), 0);

    if (!await TryAcquireAdvisoryLock(context, lockKey))
    {
        return; // Another process is handling this event
    }

    // Safe to process - we hold the lock
    await HandleEvent(eventId);
    await transaction.CommitAsync();
}
</code></pre>
<p>Deriving the key from the first 8 bytes of a <code>Guid</code> loses information, so two different GUIDs could theoretically map to the same key.
If that happens, the two operations serialize behind one lock. You lose some throughput, and correctness is unaffected.</p>
<p>Advisory locks are also the foundation for <a href="https://milanjovanovic.tech/blog/distributed-locking-in-dotnet-coordinating-work-across-multiple-instances"><strong>distributed locking in .NET</strong></a> when you don't want to bring in Redis or another external system.</p>
<h2>Bounding Wait Times With lock_timeout</h2>
<p>By default, a blocked <code>FOR UPDATE</code> waits forever.
In a web request, that means a hung request and a consumed connection.</p>
<p>Set <code>lock_timeout</code> inside the transaction with <code>SET LOCAL</code>, so it applies only to that transaction and resets automatically:</p>
<pre><code class="language-csharp">await using var transaction = await context.Database
    .BeginTransactionAsync();

await context.Database.ExecuteSqlRawAsync(
    &quot;SET LOCAL lock_timeout = '5s'&quot;);

// Throws after 5 seconds of waiting instead of hanging
var order = await GetOrderForUpdate(context, orderId);
</code></pre>
<p>Prefer <code>SET LOCAL</code> over plain <code>SET</code>.
A plain <code>SET</code> changes the session, and with Npgsql connection pooling you don't want session-level settings escaping the code that made them.</p>
<h2>Isolation Levels and Row Locks</h2>
<p>You can combine explicit row locks with a transaction isolation level:</p>
<pre><code class="language-csharp">await using var transaction = await context.Database
    .BeginTransactionAsync(IsolationLevel.Serializable);
</code></pre>
<p>Here's how PostgreSQL's isolation levels compare:</p>
<ul>
<li><strong>Read Committed</strong> (the default): each statement sees data committed before that statement started. Non-repeatable reads and phantoms are possible.</li>
<li><strong>Repeatable Read</strong>: the whole transaction sees a snapshot from its start. In PostgreSQL this also prevents phantom reads, which the SQL standard doesn't require at this level.</li>
<li><strong>Serializable</strong>: transactions behave as if they ran one at a time. PostgreSQL aborts transactions that would violate serializability, so you must be prepared to retry.</li>
</ul>
<p>For most locking scenarios, <code>ReadCommitted</code> combined with <code>SELECT FOR UPDATE</code> is sufficient.
<code>Serializable</code> gives you the strongest guarantees without explicit locks, but you pay with retry logic for serialization failures.</p>
<h2>Avoiding Deadlocks</h2>
<p>Deadlocks occur when two transactions lock rows in opposite order.
Transaction A locks row 1, transaction B locks row 2, then A tries to lock row 2 while B tries to lock row 1.
PostgreSQL detects this and kills one of the transactions with error <code>40P01</code>.</p>
<p>The main defense: lock rows in a <strong>deterministic order</strong>.</p>
<pre><code class="language-csharp">// Always lock rows in a consistent order
var orders = await context.Orders
    .FromSqlInterpolated(
        $@&quot;SELECT * FROM &quot;&quot;Orders&quot;&quot;
           WHERE &quot;&quot;Id&quot;&quot; = ANY({orderIds})
           ORDER BY &quot;&quot;Id&quot;&quot;
           FOR UPDATE&quot;)
    .ToListAsync();
</code></pre>
<p>Other deadlock prevention tips:</p>
<ul>
<li>Keep transactions <strong>short</strong> - acquire locks, do the work, commit</li>
<li>Use <code>FOR UPDATE NOWAIT</code> to fail fast instead of waiting indefinitely</li>
<li>Set a <code>lock_timeout</code> to bound wait times</li>
<li>Don't mix lock acquisition with slow I/O (external HTTP calls, file access)</li>
</ul>
<h2>Choosing the Right Tool</h2>
<p>Choose between the options from the contention model:</p>
<ul>
<li><strong>Optimistic concurrency</strong>: conflicts are rare, retries are cheap, you want maximum throughput. See <a href="https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking"><strong>optimistic locking in EF Core</strong></a>.</li>
<li><strong>FOR UPDATE</strong>: conflicts are frequent on specific rows, and the operation isn't safely retryable (payments, inventory decrements).</li>
<li><strong>SKIP LOCKED</strong>: multiple workers competing for rows in a queue-like table.</li>
<li><strong>Advisory locks</strong>: the thing you're protecting isn't a row - singleton jobs, external resources, &quot;create if not exists&quot; flows.</li>
</ul>
<h2>Summary</h2>
<p>Acquire row locks inside an explicit transaction and keep the protected work as short as possible.
Use <code>SKIP LOCKED</code> for workers competing over queue-like rows and transaction-scoped advisory locks for concepts that have no row to lock.
Bound wait time and acquire multiple locks in a consistent order so contention fails predictably instead of becoming a deadlock.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Optimistic Concurrency With Postgres xmin in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-postgresql-xmin-concurrency</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-postgresql-xmin-concurrency</guid>
            <pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Two users edit the same row, and the last write silently wins. PostgreSQL already versions every row internally through the xmin system column, and EF Core can…]]></description>
            <content:encoded><![CDATA[<p>PostgreSQL keeps a hidden <code>xmin</code> system column on every row that holds the ID of the transaction that wrote it, so the value changes on every update.
EF Core can use <code>xmin</code> as an optimistic concurrency token: add a <code>uint</code> property to the entity and configure it with <code>IsRowVersion()</code>, and the Npgsql provider maps that property to the system column.
No migration is needed, because the column already exists.</p>
<p>Two users load the same record.
Both edit it.
Both hit save.</p>
<p>Without a concurrency token, the second write silently overwrites the first, and nobody finds out until the data looks wrong.
The usual fix is adding a <code>Version</code> column and remembering to configure it on every entity.</p>
<p>But if you run PostgreSQL, you already have a version number on every row.
It is called <code>xmin</code>, and EF Core can use it as a concurrency token with zero schema changes.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-postgresql-xmin-concurrency/optimistic-conflict.png" alt="Sequence where two users both read a row at xmin 74821, User A updates it first so the row moves to xmin 74822, and User B">
<h2>What Is xmin in PostgreSQL?</h2>
<p>PostgreSQL uses multi-version concurrency control (MVCC).
Every update creates a new physical version of the row instead of overwriting it in place.</p>
<p>Each row version carries hidden system columns, and one of them is <code>xmin</code>: the ID of the transaction that created this version of the row.
Update the row, and the new version gets a new <code>xmin</code>.</p>
<p>You can see it yourself:</p>
<pre><code class="language-sql">SELECT xmin, id, name FROM products WHERE id = 1;

-- xmin  | id | name
-- 74821 | 1  | Keyboard

UPDATE products SET name = 'Mechanical Keyboard' WHERE id = 1;

SELECT xmin, id, name FROM products WHERE id = 1;

-- xmin  | id | name
-- 74822 | 1  | Mechanical Keyboard
</code></pre>
<p>The value changed because the update ran in a new transaction.
That is exactly the behavior you want from a row version: it changes on every write, and you do not have to maintain it.</p>
<h2>Mapping xmin in EF Core</h2>
<p>The <a href="https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started"><strong>Npgsql provider</strong></a> has first-class support for this.
Add a <code>uint</code> property to your entity and configure it as a row version:</p>
<pre><code class="language-csharp">public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    public uint Version { get; set; }
}
</code></pre>
<p>Then in <code>OnModelCreating</code>:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&lt;Product&gt;()
        .Property(p =&gt; p.Version)
        .IsRowVersion();
}
</code></pre>
<p>When the Npgsql provider sees a <code>uint</code> property configured as a row version, it maps it to the <code>xmin</code> system column instead of creating a real column.</p>
<p>Now generate a migration:</p>
<pre><code class="language-bash">dotnet ef migrations add MapXminConcurrencyToken
</code></pre>
<p>The migration is empty.
There is no schema change because the column already exists on every table.
That is the whole point: you get optimistic concurrency on legacy tables, on databases you share with other applications, and on tables you are not allowed to alter.</p>
<h2>What EF Core Does With It</h2>
<p>Once mapped, EF Core includes the token in every <code>UPDATE</code> and <code>DELETE</code>:</p>
<pre><code class="language-sql">UPDATE products
SET name = @p0, price = @p1
WHERE id = @p2 AND xmin = @p3
RETURNING xmin;
</code></pre>
<p>The <code>RETURNING</code> clause reads the new <code>xmin</code> back, so the tracked entity carries the fresh version after a successful save.
If another transaction updated the row after you read it, <code>xmin</code> no longer matches.
The <code>WHERE</code> clause matches zero rows, EF Core sees zero rows affected, and <code>SaveChangesAsync</code> throws a <code>DbUpdateConcurrencyException</code>.</p>
<p>Handling it looks like this:</p>
<pre><code class="language-csharp">try
{
    product.Price = newPrice;
    await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    // Someone else changed the row since we read it.
    // Reload, re-apply, or surface a conflict to the caller.
    return Results.Conflict(&quot;The product was modified by another user.&quot;);
}
</code></pre>
<p>For a full walkthrough of the retry-and-merge strategies, see my post on <a href="https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking"><strong>solving race conditions with EF Core optimistic locking</strong></a>.
The mechanics are identical.
The only difference is that here the token is maintained by Postgres itself.</p>
<h2>The Disconnected Scenario</h2>
<p>The typical web flow is disconnected: you send the row (including its version) to the client, the user edits it, and the update comes back in a later request with a fresh <code>DbContext</code>.</p>
<p>The trick is telling EF Core what the original version was when you read the entity, not what it is now:</p>
<pre><code class="language-csharp">app.MapPut(&quot;/products/{id}&quot;, async (
    int id,
    UpdateProductRequest request,
    AppDbContext dbContext) =&gt;
{
    var product = await dbContext.Products.FindAsync(id);

    if (product is null)
    {
        return Results.NotFound();
    }

    product.Name = request.Name;
    product.Price = request.Price;

    // The version the client originally read
    dbContext.Entry(product)
        .Property(p =&gt; p.Version)
        .OriginalValue = request.Version;

    try
    {
        await dbContext.SaveChangesAsync();
        return Results.NoContent();
    }
    catch (DbUpdateConcurrencyException)
    {
        return Results.Conflict();
    }
});
</code></pre>
<p>Setting <code>OriginalValue</code> makes EF Core send the client's version in the <code>WHERE</code> clause.
If anyone saved between the client's read and this write, the update fails and you return <code>409 Conflict</code>.</p>
<p>The <code>Version</code> property serializes as a plain number, so it travels through your API contract like any other field.
This pairs well with <a href="https://milanjovanovic.tech/blog/implementing-idempotent-rest-apis-in-aspnetcore"><strong>idempotent REST APIs</strong></a>, where the client is expected to participate in conflict handling anyway.</p>
<h2>Caveats You Should Know</h2>
<p>xmin is free, but it is not identical to a version column you own.
Three things to keep in mind:</p>
<p><strong>Any write bumps it, not just yours.</strong>
Triggers, batch jobs, another service touching the same table, even an <code>UPDATE</code> that sets a column your entity does not map.
All of them change <code>xmin</code>.
With a hand-rolled version column, you decide what counts as a conflicting change.
With <code>xmin</code>, every write conflicts.
In practice this is usually the behavior you want, but it can produce conflicts on writes you consider irrelevant.</p>
<p><strong>It is a 32-bit transaction ID.</strong>
<code>xmin</code> is not a monotonic counter you should store long-term or compare for ordering.
Transaction IDs wrap around, and PostgreSQL's freezing process is designed around that.
Use it as an opaque token: read it, send it back, compare for equality.
Do not build audit logic on top of it.</p>
<p><strong>It is Postgres-only.</strong>
If your codebase targets multiple providers (<strong>SQL Server and PostgreSQL side by side</strong>, for example), you need provider-specific model configuration.
SQL Server has <code>rowversion</code> for the same job, but the property type differs (<code>byte[]</code> vs <code>uint</code>), so the entity cannot be identical across providers without some mapping gymnastics.</p>
<h2>When I Still Add My Own Version Column</h2>
<p>xmin is my default for Postgres because zero schema changes is a real advantage.
Use an explicit column instead when:</p>
<ul>
<li>I need the version to survive export and import. <code>xmin</code> values are physical to the database instance, so restoring data elsewhere resets them. A real column travels with the data.</li>
<li>I want conflict detection scoped to specific fields. EF Core also supports <code>IsConcurrencyToken()</code> on individual properties, which detects conflicting changes only where they matter.</li>
<li>The domain needs a meaningful version number (an aggregate version in event sourcing, an ETag you control). A system column cannot carry business meaning.</li>
</ul>
<p>If none of those apply, the hidden column Postgres already maintains does the job with less code and no migration.
And if optimistic concurrency does not fit your write patterns at all, the alternative is <a href="https://milanjovanovic.tech/blog/pessimistic-locking-ef-core-postgresql"><strong>pessimistic locking with EF Core and PostgreSQL</strong></a>, which blocks the conflict instead of detecting it.</p>
<h2>Summary</h2>
<p>PostgreSQL versions every row for its own MVCC machinery, and the Npgsql EF Core provider lets you piggyback on that with a single <code>uint</code> property and an <code>IsRowVersion()</code> call.
You get last-write-wins protection with an empty migration, which makes it the cheapest concurrency token you will ever add.</p>
<p>Know the caveats: every write conflicts, the value is opaque and instance-local, and it does not port to other databases.
For most Postgres-backed applications, none of that matters, and the five lines of mapping code are all you need.</p>
<p>Set the client's version as <code>OriginalValue</code> in disconnected scenarios, catch <code>DbUpdateConcurrencyException</code>, return <code>409 Conflict</code>, and move on to the next feature.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Identity vs Sequence vs HiLo Key Generation in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-identity-sequence-hilo</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-identity-sequence-hilo</guid>
            <pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Identity columns are the default, but they only hand you the id after the insert. HiLo assigns ids before SaveChanges, which unlocks setting foreign keys on…]]></description>
            <content:encoded><![CDATA[<p>Identity columns and sequences generate the key during the <code>INSERT</code>, so the id exists only after <code>SaveChanges</code> returns.
HiLo reserves a block of ids from a sequence and assigns them client-side, so an entity has its id the moment you <code>Add</code> it.
Use identity by default, and HiLo when your code needs the id before the save.
Sequences match identity's timing but let several tables share one id range.</p>
<p>You build an aggregate in memory: an <code>Order</code> with ten <code>OrderLine</code> children.
With identity columns, none of those objects has a real id until <code>SaveChanges</code> returns, because the database generates the value <strong>during</strong> the insert.</p>
<p>Most of the time EF Core hides this from you.
But the moment you need the id before saving (for an outbox message, a domain event, a log line, a reference from an object EF does not manage), the default strategy becomes a constraint.
Identity, sequence, and HiLo differ in one dimension more than any other: <strong>when the id becomes known</strong>.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-identity-sequence-hilo/id-generation-timing.png" alt="Timeline of id availability across the save pipeline: HiLo knows the id when the entity is added, while sequence and identity only know it after the INSERT executes">
<h2>Identity: The Default</h2>
<p>On SQL Server, an <code>int</code> or <code>long</code> key maps to an <code>IDENTITY</code> column by default.
On PostgreSQL, Npgsql uses <code>GENERATED BY DEFAULT AS IDENTITY</code>:</p>
<pre><code class="language-csharp">public class Order
{
    public long Id { get; set; }   // identity by convention
    public List&lt;OrderLine&gt; Lines { get; set; } = [];
}
</code></pre>
<p>The id is born inside the <code>INSERT</code>.
EF Core appends a <code>RETURNING</code> / <code>OUTPUT</code> clause to read it back, then runs <strong>relationship fixup</strong>: it patches the real ids into tracked children's foreign keys and inserts them in dependency order.</p>
<p>That machinery is why identity feels free.
It also has consequences:</p>
<ul>
<li>Before <code>SaveChanges</code>, <code>order.Id</code> is <code>0</code>. Temporary negative ids exist only inside the change tracker.</li>
<li>Inserts of parent-child graphs must be ordered (parents first), constraining how EF batches statements.</li>
<li>Every insert round trip carries the overhead of returning generated values.</li>
</ul>
<p>For most CRUD workloads, none of this matters.
It starts to matter in outbox and event patterns, where you want to serialize an event containing <code>order.Id</code> in the same unit of work, ideally without contorting your code to run after the save. The <strong>transactional outbox</strong> gets much cleaner when ids exist up front.</p>
<h2>Sequence: Identity Timing, More Flexibility</h2>
<p>A sequence is a standalone database object, decoupled from any table.
EF Core 7+ supports it directly on SQL Server:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Order&gt;()
    .Property(o =&gt; o.Id)
    .UseSequence(&quot;OrderIds&quot;);
</code></pre>
<p>The migration creates the sequence and gives the key column a default constraint of <code>NEXT VALUE FOR [OrderIds]</code>, so the database still generates the value during the insert and EF Core reads it back, exactly like identity.
A sequence alone does not give you ids any earlier.
What it does give you:</p>
<ul>
<li><strong>Shared ranges.</strong> Multiple tables can draw from one sequence, guaranteeing ids unique across tables (useful for table-per-concrete-type <a href="https://milanjovanovic.tech/blog/tph-vs-tpt-ef-core"><strong>inheritance mappings</strong></a>).</li>
<li><strong>No identity semantics on the column.</strong> Plain inserts with explicit ids, no <code>IDENTITY_INSERT</code> dance when migrating data.</li>
<li><strong>Tunable caching</strong> on the database side (<code>CACHE 50</code>) to cut sequence round trips.</li>
</ul>
<p>Think of sequences as the infrastructure HiLo builds on.</p>
<h2>HiLo: Ids Before SaveChanges</h2>
<p>HiLo splits id generation between database and client.
The database sequence hands out <strong>high</strong> values; the application turns each high value into a block of ids and assigns the <strong>low</strong> values itself.</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Order&gt;()
    .Property(o =&gt; o.Id)
    .UseHiLo(&quot;OrderHiLo&quot;);

modelBuilder.Entity&lt;OrderLine&gt;()
    .Property(l =&gt; l.Id)
    .UseHiLo(&quot;OrderLineHiLo&quot;);
</code></pre>
<p>The migration creates sequences with an increment of 10 (the default block size).
When you <code>Add</code> the first entity, EF Core fetches one sequence value and now owns ids, say, 41 through 50, assignable with zero database contact.</p>
<p>The payoff is immediate and visible:</p>
<pre><code class="language-csharp">var order = new Order();
context.Orders.Add(order);

Console.WriteLine(order.Id); // real, final id. SaveChanges has NOT run.

var outboxMessage = OutboxMessage.From(
    new OrderCreatedEvent(order.Id)); // safe: the id is real

context.Add(outboxMessage);
await context.SaveChangesAsync();
</code></pre>
<p>Two things just became possible:</p>
<ul>
<li><strong>Foreign keys on unsaved graphs.</strong> You can wire up references between new objects by id, not just by navigation property, including references from things EF does not track (serialized events, cache keys, messages).</li>
<li><strong>Leaner batch inserts.</strong> Since ids are known, EF Core sends plain inserts without needing generated keys back, and insert ordering constraints relax. On graphs of thousands of new rows this measurably reduces save time, though for true bulk loads <code>SqlBulkCopy</code>-style APIs still win by an order of magnitude, as I showed in <a href="https://milanjovanovic.tech/blog/fast-sql-bulk-inserts-with-csharp-and-ef-core"><strong>fast SQL bulk inserts</strong></a>.</li>
</ul>
<p>The costs are mostly aesthetic and operational:</p>
<ul>
<li><strong>Gaps.</strong> An app instance that reserved ids 41-50 and restarted after using 41 discards nine ids. Sequences also produce gaps on rollback. If anyone in your organization believes invoice numbers must be gap-free, ids are the wrong place for that requirement anyway; model document numbers separately.</li>
<li><strong>Block size tuning.</strong> Increment 10 means a sequence round trip every 10 inserts per instance. High-throughput insert paths want a bigger block; you set it by configuring the sequence's increment and matching it in <code>UseHiLo</code>'s sequence definition.</li>
<li><strong>Provider support.</strong> SQL Server and PostgreSQL support HiLo well through their EF providers.</li>
</ul>
<h2>What About GUIDs?</h2>
<p>Client-generated GUIDs solve the same &quot;id before save&quot; problem with zero database coordination:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; } = Guid.CreateVersion7();
}
</code></pre>
<p>Random Version 4 GUIDs fragment clustered indexes badly, which is the historical argument for HiLo.
.NET 9's <code>Guid.CreateVersion7()</code> produces time-ordered values that insert nearly sequentially, removing most of that objection.
I compared the index behavior in detail in <strong>GUID v7 in .NET</strong>.</p>
<p>The remaining tradeoff is size and ergonomics: 16 bytes versus 8, and integer ids stay friendlier in URLs, logs, and support conversations.
If you were reaching for HiLo purely to get ids before save, UUIDv7 is the simpler modern answer.
If you want small sequential integers <strong>and</strong> early ids, HiLo remains the only game in town.</p>
<h2>Choosing</h2>
<ul>
<li><strong>Default CRUD app, ids never needed pre-save</strong>: identity. Zero configuration, everyone understands it.</li>
<li><strong>Ids needed before <code>SaveChanges</code></strong> (outbox, domain events, cross-aggregate references): HiLo for integer keys, UUIDv7 for GUID keys.</li>
<li><strong>Heavy graph inserts through EF Core</strong>: HiLo; it strips the returning overhead and ordering constraints. Combine with the batching guidance from <a href="https://milanjovanovic.tech/blog/optimizing-bulk-database-updates-in-dotnet"><strong>optimizing bulk database updates</strong></a>.</li>
<li><strong>Unique ids across several tables, data migrations with explicit ids</strong>: sequence.</li>
<li><strong>Distributed id generation across services</strong>: none of these; use UUIDv7 or an id service. Database-coordinated strategies stop at the database boundary.</li>
</ul>
<p>One migration note: switching strategies on an existing table is a real schema change (dropping identity, creating sequences, seeding the sequence past the current max id).
Plan it like any other <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>risky migration</strong></a>, and rehearse against a production-sized copy.</p>
<h2>Summary</h2>
<p>The three strategies answer one question differently: when do you learn the id?
Identity and sequences answer &quot;after the insert&quot;, and HiLo answers &quot;the moment you <code>Add</code> the entity&quot;, because the application owns a reserved block of ids.</p>
<p>That timing difference is not a micro-optimization.
Ids known before <code>SaveChanges</code> are what make outbox messages, domain events, and references across unsaved graphs clean to implement, and they let EF Core batch inserts without reading keys back.</p>
<p>Use identity until you feel the constraint.
When you do, reach for HiLo if you value compact integer keys, or UUIDv7 if you value zero coordination.
Both give you the id exactly when the interesting patterns need it: before the save.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Cascade Delete in EF Core: Behaviors and Pitfalls]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-cascade-delete</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-cascade-delete</guid>
            <pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[DeleteBehavior in EF Core controls two different things that people conflate: what EF does to tracked entities in memory, and what the database enforces with…]]></description>
            <content:encoded><![CDATA[<p>Cascade delete removes dependent entities, like the <code>OrderLine</code> rows under an <code>Order</code>, when the principal entity is deleted.
You configure it per relationship with <code>OnDelete(DeleteBehavior.Cascade)</code>, which makes EF delete tracked dependents and makes the migration emit <code>ON DELETE CASCADE</code> on the foreign key.
Required relationships get <code>Cascade</code> by convention, and optional ones get <code>ClientSetNull</code>, which only nulls the foreign key on dependents EF has already loaded.</p>
<p>Delete an order and its order lines should go with it.
Every ORM promises this, EF Core delivers it, and most developers stop reading there.</p>
<p>Then one day a delete that always worked starts throwing foreign key violations, but only in production, and only for some rows.
The root cause is almost always the same: <strong>DeleteBehavior configures two different mechanisms</strong>, one in EF's memory and one in the database, and they do not have to agree.</p>
<h2>The Two Mechanisms</h2>
<p>When you delete a principal (the <code>Order</code>), something has to happen to its dependents (the <code>OrderLine</code> rows holding the foreign key).
Two independent actors can handle it:</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-cascade-delete/two-mechanisms.png" alt="Deleting an Order triggers two independent actors: EF Core cascades only tracked dependents in memory, while the database FK constraint handles every dependent row including unloaded ones">
<p><strong>EF Core, in memory.</strong>
The <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core"><strong>change tracker</strong></a> knows about the entities it is tracking.
If you delete an order while its lines are loaded and tracked, EF marks the lines <code>Deleted</code> too and issues <code>DELETE</code> statements for them, children first, in the right order.</p>
<p><strong>The database, via the foreign key constraint.</strong>
The migration generates <code>ON DELETE CASCADE</code> (or <code>SET NULL</code>, or <code>NO ACTION</code>) on the FK.
The database then handles dependents for <strong>every</strong> delete, including rows EF has never seen.</p>
<p>Here is the point the documentation makes quietly and bugs make loudly: EF's in-memory cascade only applies to <strong>tracked</strong> entities.
If the lines are not loaded, EF sends one <code>DELETE</code> for the order and hopes the database handles the rest.
Whether it does depends entirely on what constraint the migration created.</p>
<h2>What Does DeleteBehavior Actually Map To?</h2>
<p>Each <code>DeleteBehavior</code> value answers both questions at once: what EF does to tracked dependents, and what DDL the migration generates.</p>
<ul>
<li><strong><code>Cascade</code></strong>: EF deletes tracked dependents, and the database gets <code>ON DELETE CASCADE</code>. Both actors cascade. Unloaded dependents are handled by the database.</li>
<li><strong><code>ClientCascade</code></strong>: EF deletes tracked dependents, but the database gets <code>NO ACTION</code>/<code>RESTRICT</code>. If any dependent is not loaded when you delete the principal, the database throws an FK violation.</li>
<li><strong><code>SetNull</code></strong>: the database gets <code>ON DELETE SET NULL</code>, and EF nulls the FK on tracked dependents. Only valid for optional (nullable FK) relationships.</li>
<li><strong><code>ClientSetNull</code></strong> (the default for optional relationships): EF nulls the FK on tracked dependents, the database gets <code>NO ACTION</code>. Same trap as <code>ClientCascade</code>, with nulling instead of deleting.</li>
<li><strong><code>Restrict</code> / <code>NoAction</code></strong>: nobody cascades. EF will even throw at <code>SaveChanges</code> if you leave tracked dependents orphaned. Deleting a principal with existing dependents is an error unless you handle the dependents yourself first.</li>
</ul>
<p>And the conventions that decide which one you get when you configure nothing:</p>
<ul>
<li><strong>Required relationship</strong> (non-nullable FK): <code>Cascade</code>.</li>
<li><strong>Optional relationship</strong> (nullable FK): <code>ClientSetNull</code>.</li>
</ul>
<p>Configuration lives in <code>OnModelCreating</code>, per relationship:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Order&gt;()
    .HasMany(o =&gt; o.Lines)
    .WithOne(l =&gt; l.Order)
    .HasForeignKey(l =&gt; l.OrderId)
    .OnDelete(DeleteBehavior.Cascade);
</code></pre>
<p>If you take one sentence from this article: <strong>the <code>Client*</code> behaviors mean the database will not help you</strong>, and any delete that touches unloaded dependents will fail with an FK violation.</p>
<h2>The Classic Failure, Step by Step</h2>
<p>This is the &quot;works in tests, fails in production&quot; bug.</p>
<p>Say the relationship between <code>Blog</code> and <code>Post</code> is configured as <code>ClientCascade</code>, or it is optional and got the default <code>ClientSetNull</code>.
Now delete a blog by id without loading its posts:</p>
<pre><code class="language-csharp">var blog = await dbContext.Blogs.FirstAsync(b =&gt; b.Id == blogId);

dbContext.Blogs.Remove(blog);

await dbContext.SaveChangesAsync();
// PostgresException: 23503: update or delete on table &quot;Blogs&quot;
// violates foreign key constraint &quot;FK_Posts_Blogs_BlogId&quot; on table &quot;Posts&quot;
</code></pre>
<p>In your test, the blog had no posts (or you had them loaded from an earlier assertion), so it passed.
If the blog has 400 posts that EF never loaded, the database sees a bare <code>DELETE FROM Blogs</code>, and the <code>NO ACTION</code> constraint rejects it.
(On SQL Server the same failure surfaces as a <code>SqlException</code> about a conflicted <code>REFERENCE</code> constraint.)</p>
<p>Three legitimate fixes, in order of my preference:</p>
<pre><code class="language-csharp">// 1. Let the database own it: switch to DeleteBehavior.Cascade
//    (migration adds ON DELETE CASCADE).

// 2. Delete dependents explicitly with a set-based query first.
await dbContext.Posts
    .Where(p =&gt; p.BlogId == blogId)
    .ExecuteDeleteAsync();

await dbContext.Blogs
    .Where(b =&gt; b.Id == blogId)
    .ExecuteDeleteAsync();

// 3. Load the graph so EF's client cascade has something to work on.
var blog = await dbContext.Blogs
    .Include(b =&gt; b.Posts)
    .FirstAsync(b =&gt; b.Id == blogId);

dbContext.Blogs.Remove(blog); // posts marked Deleted too
await dbContext.SaveChangesAsync();
</code></pre>
<p>Option 3 is the one to be suspicious of: loading 400 rows to delete them is pure waste.
Note that <code>ExecuteDeleteAsync</code> bypasses the change tracker entirely, so with it, client-side behaviors never run and only the database constraint matters.
If you use <a href="https://milanjovanovic.tech/blog/what-you-need-to-know-about-ef-core-bulk-updates"><strong>bulk deletes</strong></a>, your delete behavior IS your DDL, full stop.
And when a delete spans multiple statements like option 2, wrap it in a <a href="https://milanjovanovic.tech/blog/working-with-transactions-in-ef-core"><strong>transaction</strong></a>.</p>
<h2>Why ClientCascade Exists at All: SQL Server's Cascade Paths</h2>
<p>If <code>Cascade</code> is the honest behavior, why would anyone choose <code>ClientCascade</code>?</p>
<p>Because SQL Server refuses some cascades.
Create a schema where the same table is reachable through two <code>ON DELETE CASCADE</code> chains (a diamond), and the migration fails with:</p>
<pre><code class="language-text">Msg 1785: Introducing FOREIGN KEY constraint 'FK_...' on table '...'
may cause cycles or multiple cascade paths.
Specify ON DELETE NO ACTION or ON UPDATE NO ACTION,
or modify other FOREIGN KEY constraints.
</code></pre>
<p>The standard example: <code>Customer</code> has <code>Orders</code>, <code>Customer</code> has <code>Addresses</code>, and <code>Order</code> references <code>Address</code>.
Deleting a customer can reach the order both directly and through the address, and SQL Server will not create the second cascade.
PostgreSQL, for the record, allows this happily; it is a SQL Server limitation, and it is one of the behavioral differences worth knowing if you work <strong>across PostgreSQL and SQL Server</strong>.</p>
<p>Your options when you hit error 1785:</p>
<ul>
<li>Break one edge of the diamond with <code>DeleteBehavior.Restrict</code> or <code>NoAction</code> and delete that path explicitly in code.</li>
<li>Use <code>ClientCascade</code> on that edge: EF still cascades tracked graphs, and you accept the load-before-delete obligation documented above.</li>
<li>Reconsider whether both relationships should be required. Often one of them is really optional, and <code>SetNull</code> dissolves the diamond.</li>
</ul>
<p><code>ClientCascade</code> is a workaround with a maintenance contract attached, not a default to reach for.</p>
<h2>Cascades and Aggregates: Being Deliberate</h2>
<p>My actual rule for choosing behaviors has less to do with EF and more with domain design:</p>
<p><strong>Cascade inside an aggregate, restrict between aggregates.</strong></p>
<p><code>OrderLine</code> has no life without its <code>Order</code>: cascade, and let the database enforce it.
But <code>Order</code> referencing <code>Customer</code> is a relationship <strong>between</strong> aggregates, and silently vaporizing a customer's orders because someone deleted the customer is a catastrophe, not a convenience.
That edge gets <code>Restrict</code>, and &quot;delete a customer&quot; becomes an explicit use case that decides what happens to orders.
This mapping between aggregate boundaries and FK behaviors is a recurring theme in <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd"><strong>modeling aggregates with EF Core</strong></a>, and it is the lens I teach in <a href="https://milanjovanovic.tech/pragmatic-domain-driven-design">Pragmatic Domain-Driven Design</a>.</p>
<p>Two pitfalls to close the loop on:</p>
<p><strong>Soft delete changes everything.</strong>
If <code>Order</code> is <a href="https://milanjovanovic.tech/blog/implementing-soft-delete-with-ef-core"><strong>soft-deleted</strong></a> (an <code>IsDeleted</code> flag plus a query filter), a database <code>ON DELETE CASCADE</code> is now a landmine: the parent row never gets a SQL <code>DELETE</code> in normal operation, but any hard delete (cleanup jobs, GDPR erasure) will <strong>physically</strong> cascade to children you meant to keep as soft-deleted history.
Soft-deleted aggregates should pair with <code>Restrict</code> semantics and propagate the flag explicitly to dependents.</p>
<p><strong>Verify the DDL, not the C#.</strong>
The delete behavior you configured is a claim; the migration is the truth.
Read the generated migration (or the SQL from <code>dotnet ef migrations script</code>) and confirm the <code>onDelete:</code> values match your intent, the same way you would review any <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>migration before it ships</strong></a>.</p>
<h2>Summary</h2>
<ul>
<li><code>DeleteBehavior</code> sets two things at once: EF's handling of <strong>tracked</strong> dependents and the <code>ON DELETE</code> clause in your schema. Bugs live in the gap between them.</li>
<li><code>Cascade</code> means the database covers unloaded rows. <code>ClientCascade</code> and <code>ClientSetNull</code> mean it does not, and deletes fail the moment dependents exist that EF has not loaded.</li>
<li><code>ExecuteDeleteAsync</code> skips the change tracker, so only the database behavior applies to it.</li>
<li>SQL Server's multiple-cascade-paths error is the main legitimate reason <code>ClientCascade</code> exists. Prefer breaking the diamond with <code>Restrict</code> plus explicit deletes.</li>
<li>Design rule: cascade within an aggregate, restrict between aggregates, and never mix database cascades with soft delete without deciding exactly what a hard delete should destroy.</li>
</ul>
<p>Cascade delete is not one feature.
It is a contract between your code and your schema, and it only works when both sides say the same thing.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Modeling Hierarchical Data in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-hierarchical-data</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-hierarchical-data</guid>
            <pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Categories with subcategories, org charts, comment threads: hierarchies are everywhere, and the obvious self-referencing entity is easy to write and brutal to…]]></description>
            <content:encoded><![CDATA[<p>Model hierarchical data in EF Core with an adjacency list: a nullable <code>ParentId</code> foreign key, a <code>Parent</code> navigation, and a <code>Children</code> collection on the same entity.
Load the rows of the tree in one query and relationship fixup wires the object graph for you.
When the hierarchy outgrows that, push the recursion into the database with a recursive CTE, or store materialized paths with PostgreSQL <code>ltree</code>.</p>
<p>Product categories nest.
Org charts nest.
Comment threads, folder structures, chart-of-accounts, all of them are trees, and sooner or later you have to persist one in a relational database.</p>
<p>The natural model in EF Core, a self-referencing entity with a <code>ParentId</code>, takes five minutes to write.
Then you try to load a subtree, or delete a node, or find every descendant of &quot;Electronics&quot;, and discover that the write model and the read patterns are at war.</p>
<p>The adjacency list works well when its <a href="https://milanjovanovic.tech/blog/entity-relationships-ef-core"><strong>self-referencing relationship</strong></a> and read strategy are explicit.
The two escalation paths, recursive CTEs and PostgreSQL <code>ltree</code>, become useful when the tree gets large.</p>
<h2>The Adjacency List</h2>
<p>An <strong>adjacency list</strong> stores a tree in one table by giving every row a foreign key that points at its parent.
Roots have a null parent:</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-hierarchical-data/adjacency-tree.png" alt="Adjacency list tree where Electronics is a root with a null ParentId, branching into Computers and Phones, and Computers branching further into Laptops and Desktops">
<pre><code class="language-csharp">public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;

    public int? ParentId { get; set; }
    public Category? Parent { get; set; }
    public List&lt;Category&gt; Children { get; set; } = [];
}
</code></pre>
<p>The configuration deserves care, especially the delete behavior:</p>
<pre><code class="language-csharp">public class CategoryConfiguration : IEntityTypeConfiguration&lt;Category&gt;
{
    public void Configure(EntityTypeBuilder&lt;Category&gt; builder)
    {
        builder.HasOne(c =&gt; c.Parent)
            .WithMany(c =&gt; c.Children)
            .HasForeignKey(c =&gt; c.ParentId)
            .OnDelete(DeleteBehavior.Restrict);

        builder.HasIndex(c =&gt; c.ParentId);
    }
}
</code></pre>
<p>Two deliberate choices:</p>
<ul>
<li><strong><code>DeleteBehavior.Restrict</code>.</strong> SQL Server rejects cascade on self-referencing foreign keys outright (cycle detection), and even on PostgreSQL, where it works, cascading a node delete into silently vaporizing a subtree is rarely what the business meant. Make subtree deletion an explicit operation. The broader reasoning is in <a href="https://milanjovanovic.tech/blog/ef-core-cascade-delete"><strong>cascade delete in EF Core</strong></a>.</li>
<li><strong>An index on <code>ParentId</code>.</strong> Every children lookup filters on it. Skipping this index is the most common hierarchy performance bug I see.</li>
</ul>
<h2>How Do You Load a Whole Tree Without N+1 Queries?</h2>
<p>One level is easy:</p>
<pre><code class="language-csharp">var roots = await context.Categories
    .Where(c =&gt; c.ParentId == null)
    .Include(c =&gt; c.Children)
    .ToListAsync();
</code></pre>
<p>The trap is trying to go deeper with chained includes (<code>.Include(c =&gt; c.Children).ThenInclude(c =&gt; c.Children)</code> and so on).
That hardcodes a maximum depth, and each level multiplies the join.
The recursive-lazy-loading variant is worse: walking <code>Children</code> with lazy loading fires one query per node, the classic shape from <a href="https://milanjovanovic.tech/blog/n-plus-one-query-ef-core"><strong>the N+1 query problem</strong></a>.</p>
<p>For hierarchies of reasonable size (navigation menus, category trees, org units, anything up to a few thousand rows), the right move is almost embarrassingly simple: <strong>load the whole set in one query and let relationship fixup build the tree.</strong></p>
<pre><code class="language-csharp">var all = await context.Categories.ToListAsync();

var roots = all.Where(c =&gt; c.ParentId is null).ToList();
</code></pre>
<p>When EF Core materializes the rows, identity resolution connects every <code>Parent</code> and <code>Children</code> navigation automatically.
One <code>SELECT</code>, and <code>roots</code> is a fully wired object graph you can recurse in memory.
This works with tracking queries, and with <code>AsNoTrackingWithIdentityResolution</code> if you do not need tracking.
Plain <code>AsNoTracking</code> skips identity resolution, so the rows materialize as disconnected objects and the navigations stay unwired.</p>
<p>Scope the query if the table holds many independent trees:</p>
<pre><code class="language-csharp">var all = await context.Categories
    .Where(c =&gt; c.TreeId == treeId)
    .ToListAsync();
</code></pre>
<h2>Recursive CTEs: Push the Recursion into the Database</h2>
<p>When the hierarchy has hundreds of thousands of rows and you need one subtree, loading everything stops being cute.
Relational databases walk hierarchies natively with recursive CTEs, and EF Core consumes them cleanly through <code>SqlQuery</code> or <code>FromSqlRaw</code>:</p>
<pre><code class="language-csharp">var subtree = await context.Database
    .SqlQuery&lt;CategoryRow&gt;($&quot;&quot;&quot;
        WITH RECURSIVE subtree AS (
            SELECT &quot;Id&quot;, &quot;Name&quot;, &quot;ParentId&quot;
            FROM &quot;Categories&quot;
            WHERE &quot;Id&quot; = {rootId}

            UNION ALL

            SELECT c.&quot;Id&quot;, c.&quot;Name&quot;, c.&quot;ParentId&quot;
            FROM &quot;Categories&quot; c
            JOIN subtree s ON c.&quot;ParentId&quot; = s.&quot;Id&quot;
        )
        SELECT &quot;Id&quot;, &quot;Name&quot;, &quot;ParentId&quot; FROM subtree
        &quot;&quot;&quot;)
    .ToListAsync();
</code></pre>
<p>(That is PostgreSQL syntax; SQL Server drops the <code>RECURSIVE</code> keyword and brackets the identifiers.
One SQL Server catch: <code>SqlQuery</code> composes over your SQL as a subquery, and T-SQL does not allow a CTE inside one, so use <code>FromSql</code> on a mapped entity there instead.)</p>
<p>The same pattern answers the other classic questions: the ancestor chain of a node (flip the join direction), the depth of each node (carry a <code>level + 1</code> column through the recursion), and &quot;does moving node X under node Y create a cycle&quot; (check whether Y appears in X's descendants).</p>
<p>This is one of the places where dropping to SQL is not a failure of the ORM, it is using each tool for what it is good at.
I covered the mechanics and parameterization safety in <a href="https://milanjovanovic.tech/blog/ef-core-raw-sql-queries"><strong>EF Core raw SQL queries</strong></a>.</p>
<h2>PostgreSQL ltree: Materialized Paths with an Index</h2>
<p>If your workload is read-heavy on subtrees, PostgreSQL's <code>ltree</code> extension changes the data structure itself.
Each node stores its full path as a chain of labels (<code>electronics.computers.laptops</code>), and a GiST index makes subtree and ancestor queries indexed operations instead of recursion.</p>
<p>Npgsql maps the <code>LTree</code> type directly:</p>
<pre><code class="language-csharp">public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public LTree Path { get; set; }
}

// in OnModelCreating
modelBuilder.HasPostgresExtension(&quot;ltree&quot;);

modelBuilder.Entity&lt;Category&gt;()
    .HasIndex(c =&gt; c.Path)
    .HasMethod(&quot;gist&quot;);
</code></pre>
<p>Subtree and ancestor queries become one-liners that translate to indexed operators:</p>
<pre><code class="language-csharp">// all descendants of electronics.computers
var descendants = await context.Categories
    .Where(c =&gt; c.Path.IsDescendantOf(new LTree(&quot;electronics.computers&quot;)))
    .ToListAsync();

// ancestors of a node
var ancestors = await context.Categories
    .Where(c =&gt; new LTree(&quot;electronics.computers.laptops&quot;).IsDescendantOf(c.Path))
    .ToListAsync();
</code></pre>
<p>The cost shows up on writes: moving a subtree means rewriting the <code>Path</code> of every descendant, and the application owns path maintenance (nothing enforces that <code>electronics.computers</code> exists just because a path mentions it).
Many teams run <code>ltree</code> <strong>alongside</strong> a <code>ParentId</code>, treating the path as a derived, indexed acceleration structure.
If you are Postgres-curious about features like this, <strong>PostgreSQL vs SQL Server for .NET developers</strong> covers more of what you gain.</p>
<p>SQL Server's counterpart is <code>hierarchyid</code>, supported in EF Core 8+ via <code>Microsoft.EntityFrameworkCore.SqlServer.HierarchyId</code>, with similar tradeoffs.</p>
<h2>Choosing a Hierarchy Strategy</h2>
<ul>
<li><strong>Small tree, read whole or nearly whole</strong> (menus, categories, org units under ~5k rows): adjacency list, load-all plus fixup. Simplest code, one query, done.</li>
<li><strong>Large tree, occasional subtree or ancestor queries</strong>: adjacency list plus recursive CTEs for the heavy questions.</li>
<li><strong>Large tree, subtree queries on the hot path, rare moves</strong>: <code>ltree</code> (or <code>hierarchyid</code>) with a GiST index, likely alongside the adjacency columns.</li>
<li><strong>Comment threads and event-like data that only append</strong>: adjacency list; threads are shallow and append-only, recursion rarely hurts.</li>
</ul>
<h2>Summary</h2>
<p>The adjacency list is the right default: it is normalized, enforces referential integrity, and EF Core's relationship fixup gives you an underrated superpower, load the rows in one query and get a fully connected tree for free.
Configure <code>Restrict</code> on the self-reference, index <code>ParentId</code>, and never chain <code>ThenInclude</code> to fake recursion.</p>
<p>When the tree outgrows load-everything, do not fight LINQ into recursion it cannot express.
Recursive CTEs answer subtree, ancestor, and depth questions inside the database, and Postgres <code>ltree</code> turns subtree reads into indexed lookups when they dominate your workload.</p>
<p>Pick the structure by the queries you run, not by the shape of the data.
Trees are all shaped the same; workloads are not.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Custom Model Conventions in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-custom-conventions</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-custom-conventions</guid>
            <pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Fifty entity configurations all setting the same string length and decimal precision is not configuration, it is copy-paste.]]></description>
            <content:encoded><![CDATA[<p>Conventions in EF Core are the default rules the model builder applies to every entity and property, like treating a property named <code>Id</code> as the primary key.
You add your own by overriding <code>ConfigureConventions</code> in the <code>DbContext</code>, which sets type-based defaults such as string length, decimal precision, and value converters.
For rules that depend on names or model structure, write a convention class like <code>IModelFinalizingConvention</code>.</p>
<p>Open any mature EF Core codebase and count how many times <code>HasMaxLength(200)</code> appears.
Then <code>HasPrecision(18, 2)</code>.
Then the same <code>DateTime</code> UTC converter, pasted into every configuration class that touches a timestamp.</p>
<p>Every one of those lines is a default pretending to be a decision.
And every new entity is a chance to forget one, which is how you end up with an unbounded <code>nvarchar(max)</code> column holding a two-letter country code.</p>
<p>EF Core has a proper answer: conventions.
Set the default once, override it only where the entity genuinely differs.</p>
<h2>ConfigureConventions: Defaults by Type</h2>
<p>Override <code>ConfigureConventions</code> in your <code>DbContext</code>.
It runs before the model is built, and everything it sets acts as a default that individual configurations can still override:</p>
<pre><code class="language-csharp">public class AppDbContext(DbContextOptions&lt;AppDbContext&gt; options)
    : DbContext(options)
{
    protected override void ConfigureConventions(
        ModelConfigurationBuilder configurationBuilder)
    {
        configurationBuilder.Properties&lt;string&gt;()
            .HaveMaxLength(500);

        configurationBuilder.Properties&lt;decimal&gt;()
            .HavePrecision(18, 2);

        configurationBuilder.Properties&lt;Enum&gt;()
            .HaveConversion&lt;string&gt;()
            .HaveMaxLength(50);
    }
}
</code></pre>
<p>Three calls, and the entire model now has:</p>
<ul>
<li>No accidental <code>nvarchar(max)</code> columns. Every string is capped at 500 unless someone consciously raises it.</li>
<li>Consistent money precision. No more silent truncation because one configuration said <code>(18, 2)</code> and another said nothing.</li>
<li>Every enum stored as a readable string, a choice I unpack in <a href="https://milanjovanovic.tech/blog/ef-core-enum-mapping"><strong>mapping enums in EF Core</strong></a>.</li>
</ul>
<p>Per-entity configuration still wins, which is exactly what you want:</p>
<pre><code class="language-csharp">builder.Property(p =&gt; p.Description).HasMaxLength(4000);
</code></pre>
<p>The default handles the 90 percent case; the override documents the exception.</p>
<h2>Model-Wide Value Converters</h2>
<p>A broadly useful convention is a type-wide value converter.
The classic example is enforcing UTC for every <code>DateTime</code>, which also addresses the <a href="https://milanjovanovic.tech/blog/ef-core-postgresql-datetime-utc-error"><strong>Npgsql timestamptz Kind error</strong></a>:</p>
<pre><code class="language-csharp">public class UtcDateTimeConverter : ValueConverter&lt;DateTime, DateTime&gt;
{
    public UtcDateTimeConverter()
        : base(
            v =&gt; v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
            v =&gt; DateTime.SpecifyKind(v, DateTimeKind.Utc))
    {
    }
}
</code></pre>
<p>Registered once for the whole model:</p>
<pre><code class="language-csharp">protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties&lt;DateTime&gt;()
        .HaveConversion&lt;UtcDateTimeConverter&gt;();
}
</code></pre>
<p>The same mechanism registers converters for strongly typed IDs and value objects.
If you wrap identifiers in types like <code>OrderId</code>, one <code>Properties&lt;OrderId&gt;().HaveConversion&lt;OrderIdConverter&gt;()</code> beats fifty per-property calls.
I covered the converter side of this in <a href="https://milanjovanovic.tech/blog/value-conversions-ef-core"><strong>value conversions in EF Core</strong></a>.</p>
<h2>Can You Remove a Built-In Convention?</h2>
<p>Conventions are not just additive.
<code>configurationBuilder.Conventions</code> exposes the full convention set, and you can remove the ones you disagree with.</p>
<p>The one I remove most often is cascade delete for required relationships:</p>
<pre><code class="language-csharp">protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(CascadeDeleteConvention));

    // Only exists (and is only needed) on the SQL Server provider
    configurationBuilder.Conventions.Remove(typeof(SqlServerOnDeleteConvention));
}
</code></pre>
<p>With those gone, deletes fail loudly instead of fanning out silently, and every cascade in the schema is one someone chose.
Whether you want that depends on how you feel about the tradeoffs in <a href="https://milanjovanovic.tech/blog/ef-core-cascade-delete"><strong>cascade delete in EF Core</strong></a>, but the point stands: the built-in defaults are opinions, and you are allowed to override them.</p>
<h2>Custom Convention Classes for Everything Else</h2>
<p><code>ConfigureConventions</code> handles type-based defaults.
For rules that depend on names, attributes, or model structure, EF Core 7+ lets you write real convention classes.</p>
<p><code>IModelFinalizingConvention</code> is the workhorse: it runs once, right before the model is finalized, with the whole model available for inspection and mutation.
Here is one that caps any string property whose name ends in <code>Code</code> at 20 characters:</p>
<pre><code class="language-csharp">public class CodePropertyLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(
        IConventionModelBuilder modelBuilder,
        IConventionContext&lt;IConventionModelBuilder&gt; context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
        {
            foreach (var property in entityType.GetDeclaredProperties())
            {
                if (property.ClrType == typeof(string) &amp;&amp;
                    property.Name.EndsWith(&quot;Code&quot;, StringComparison.Ordinal))
                {
                    property.Builder.HasMaxLength(20);
                }
            }
        }
    }
}
</code></pre>
<p>Register it in the same <code>ConfigureConventions</code> override:</p>
<pre><code class="language-csharp">configurationBuilder.Conventions.Add(_ =&gt; new CodePropertyLengthConvention());
</code></pre>
<p>Other conventions that fit this pattern:</p>
<ul>
<li>Table names without the pluralization the <code>DbSet</code> name implies.</li>
<li>A global <code>RowVersion</code> shadow property on every aggregate root for <a href="https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking"><strong>optimistic locking</strong></a>.</li>
<li>Snake_case column naming for PostgreSQL across the entire model.</li>
</ul>
<p>One warning: <code>property.Builder</code> calls inside a convention use the <strong>convention</strong> precedence level.
That is by design.
It means explicit configuration and data annotations still override your convention, keeping the precedence hierarchy intact: explicit Fluent API beats attributes, attributes beat conventions.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-custom-conventions/configuration-precedence.png" alt="Configuration precedence hierarchy in EF Core: Fluent API overrides data annotations, which override custom conventions, which override built-in conventions">
<h2>Where Conventions Fit in Your Architecture</h2>
<p>Conventions do not replace <code>IEntityTypeConfiguration&lt;T&gt;</code> classes.
They change what those classes contain.</p>
<p>After adopting conventions, my per-entity configurations shrink to the things that are genuinely per-entity: keys, relationships, indexes, owned types, and the handful of properties that deviate from the defaults.
The configurations become readable because everything in them is a decision, not boilerplate.</p>
<p>A concrete structure that has worked well for me:</p>
<ul>
<li><code>ConfigureConventions</code> in the <code>DbContext</code>: type defaults (string length, precision, enum storage, UTC dates, strongly typed ID converters).</li>
<li>One or two convention classes: naming rules and cross-cutting shadow properties.</li>
<li><code>IEntityTypeConfiguration&lt;T&gt;</code> per aggregate, applied with <code>ApplyConfigurationsFromAssembly</code>: structure and exceptions.</li>
</ul>
<p>This keeps the persistence model boring and predictable, which is exactly what you want when the domain model is where the interesting decisions live.
That separation is a core theme of <a href="https://milanjovanovic.tech/pragmatic-clean-architecture"><strong>Pragmatic Clean Architecture</strong></a>.</p>
<p>One operational note: conventions apply to the whole model, so introducing one on an existing database is a schema change.
Adding a 500-character default to a model full of <code>nvarchar(max)</code> columns produces a large migration.
Review it, and roll it out like any other wide migration, ideally through the practices in <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>.</p>
<h2>Summary</h2>
<p>Repeated configuration is a smell, and EF Core gives you two tools to eliminate it.
<code>ConfigureConventions</code> sets type-based defaults (lengths, precision, converters, enum storage) in one place, and convention classes like <code>IModelFinalizingConvention</code> handle name-based and structural rules the simple API cannot express.</p>
<p>The payoff is not just fewer lines.
It is that defaults become enforced instead of remembered.
A new entity added six months from now gets capped strings, correct decimal precision, UTC dates, and string enums without its author thinking about any of it, and the per-entity configuration files are left holding only real decisions.</p>
<p>Set the defaults once.
Make every override a visible exception.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Computed Columns in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-computed-columns</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-computed-columns</guid>
            <pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Derived values calculated in C# drift out of sync the moment someone updates the database directly.]]></description>
            <content:encoded><![CDATA[<p>A computed column in EF Core is a column whose value the database derives from other columns in the same row, mapped with <code>HasComputedColumnSql</code> in your entity configuration.
EF Core treats it as read-only, never sends it in <code>INSERT</code> or <code>UPDATE</code>, and reads it back after <code>SaveChanges</code>.
Pass <code>stored: true</code> when the value is filtered, sorted, or joined, so it is written to disk and can be indexed.</p>
<p>You have an <code>OrderItem</code> with <code>UnitPrice</code> and <code>Quantity</code>, and half your queries need the line total.
Calculating it in C# works until a report queries the table directly, a bulk update skips your domain logic, or someone filters by total in SQL and gets a full table scan.</p>
<p>Computed columns move the calculation into the database schema.
The value cannot drift because nobody writes it, the database derives it.
EF Core maps them with one configuration call, but the <code>stored</code> flag you pass decides whether the column is just a convenience or something you can index and filter on cheaply.</p>
<h2>Mapping a Computed Column</h2>
<p><code>HasComputedColumnSql</code> takes a SQL expression and wires the property as database-generated:</p>
<pre><code class="language-csharp">public class OrderItem
{
    public Guid Id { get; set; }
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
    public decimal LineTotal { get; private set; }
}

public class OrderItemConfiguration : IEntityTypeConfiguration&lt;OrderItem&gt;
{
    public void Configure(EntityTypeBuilder&lt;OrderItem&gt; builder)
    {
        builder.Property(oi =&gt; oi.LineTotal)
            .HasComputedColumnSql(&quot;[UnitPrice] * [Quantity]&quot;, stored: true);
    }
}
</code></pre>
<p>The migration produces a column the database owns:</p>
<pre><code class="language-sql">ALTER TABLE [OrderItems] ADD [LineTotal] AS ([UnitPrice] * [Quantity]) PERSISTED;
</code></pre>
<p>A few things happen implicitly:</p>
<ul>
<li>The property becomes <code>ValueGeneratedOnAddOrUpdate</code>. EF Core never includes it in <code>INSERT</code> or <code>UPDATE</code> statements.</li>
<li>After <code>SaveChanges</code>, EF Core reads the value back, so the in-memory entity is up to date without a manual reload.</li>
<li>The <code>private set</code> keeps your own code honest. Assigning it does nothing useful, so make it impossible.</li>
</ul>
<p>Note the SQL is provider-specific.
<code>[UnitPrice]</code> is SQL Server quoting; on PostgreSQL you would write <code>&quot;UnitPrice&quot; * &quot;Quantity&quot;</code>.
If you support multiple providers, this is one of the places the abstraction leaks.</p>
<h2>Stored vs Virtual: The Decision That Matters</h2>
<p>The <code>stored</code> parameter is the whole game:</p>
<ul>
<li><strong>Virtual (default, <code>stored: false</code>)</strong>: the expression runs every time the row is read. Zero storage cost, and the value is recalculated even if you change the expression without rewriting rows. But every read pays the compute, and on SQL Server you generally cannot index it unless the expression is deterministic and precise.</li>
<li><strong>Stored / persisted (<code>stored: true</code>)</strong>: the value is calculated on insert and update and written to disk. Reads are as cheap as any column, and you can index it.</li>
</ul>
<p>My default is <code>stored: true</code> for anything used in a <code>WHERE</code>, <code>ORDER BY</code>, or <code>JOIN</code>.
The moment you filter on a virtual computed column, the database evaluates the expression for every candidate row.
That is a scan, exactly what I profile for in <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a>.</p>
<p>Indexing a stored column is just a normal index:</p>
<pre><code class="language-csharp">builder.HasIndex(oi =&gt; oi.LineTotal);
</code></pre>
<p>One PostgreSQL-specific gotcha: before PostgreSQL 18, generated columns are <strong>always stored</strong>, and Npgsql throws at migration time if you leave <code>stored: false</code> (the default).
PostgreSQL 18 adds virtual generated columns (the Npgsql provider supports them from version 10), but they cannot be indexed, so for anything you filter on the call stays:</p>
<pre><code class="language-csharp">builder.Property(oi =&gt; oi.LineTotal)
    .HasComputedColumnSql(&quot;\&quot;UnitPrice\&quot; * \&quot;Quantity\&quot;&quot;, stored: true);
</code></pre>
<h2>A More Useful Example: Searchable Full Names</h2>
<p>Concatenations are where computed columns earn their keep, because they make prefix searches and sorting trivial:</p>
<pre><code class="language-csharp">public class CustomerConfiguration : IEntityTypeConfiguration&lt;Customer&gt;
{
    public void Configure(EntityTypeBuilder&lt;Customer&gt; builder)
    {
        builder.Property(c =&gt; c.FullName)
            .HasComputedColumnSql(&quot;[FirstName] + ' ' + [LastName]&quot;, stored: true)
            .HasMaxLength(201);

        builder.HasIndex(c =&gt; c.FullName);
    }
}
</code></pre>
<p>Now this LINQ query uses the index instead of concatenating per row:</p>
<pre><code class="language-csharp">var customers = await context.Customers
    .Where(c =&gt; c.FullName.StartsWith(prefix))
    .OrderBy(c =&gt; c.FullName)
    .Take(20)
    .ToListAsync();
</code></pre>
<p>Without the computed column, <code>c.FirstName + &quot; &quot; + c.LastName</code> in the <code>Where</code> clause translates to an expression the database evaluates row by row.
Same result, very different query plan.</p>
<p>Another PostgreSQL pattern is a generated <code>tsvector</code> column for search, covered in <strong>PostgreSQL full-text search with EF Core</strong>.
Same mechanism, bigger payoff.</p>
<h2>What You Cannot Do</h2>
<p>Computed columns have hard limits, and hitting them late hurts:</p>
<ul>
<li><strong>No subqueries or other tables.</strong> The expression can only reference columns of the same row. A <code>TotalOrderValue</code> that sums child rows is not a computed column; that is a view, a trigger, or an application-maintained value.</li>
<li><strong>Deterministic expressions only for persisted columns.</strong> <code>GETUTCDATE()</code> or anything nondeterministic cannot be persisted on SQL Server and cannot be a generated column on Postgres at all.</li>
<li><strong>EF Core cannot validate your SQL.</strong> The expression is an opaque string. A typo surfaces when the migration runs, not at build time. Test migrations in CI, which is one more reason I like <a href="https://milanjovanovic.tech/blog/ef-core-migration-bundles"><strong>migration bundles</strong></a>.</li>
<li><strong>Changing the expression is a migration.</strong> For stored columns on Postgres and persisted columns on SQL Server, altering the expression means dropping and re-adding the column, and the table rewrite that comes with it. On a large table, plan for it like any other <a href="https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core"><strong>risky migration</strong></a>.</li>
</ul>
<h2>Computed Column or C# Property?</h2>
<p>Not every derived value belongs in the database.
A plain C# expression-bodied property is simpler when the value never appears in a query:</p>
<pre><code class="language-csharp">public decimal LineTotal =&gt; UnitPrice * Quantity;
</code></pre>
<p>My decision rule:</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-computed-columns/computed-column-decision.png" alt="Decision flowchart: a derived value only displayed becomes a C# property, one used in WHERE, ORDER BY or JOIN becomes a stored indexed computed column, and one derived from other rows becomes a view or projection">
<ul>
<li><strong>Only displayed, never queried</strong>: C# property. No schema, no migration, no provider-specific SQL.</li>
<li><strong>Filtered, sorted, joined, or read by other SQL consumers</strong> (reports, ETL, <a href="https://milanjovanovic.tech/blog/ef-core-vs-dapper"><strong>Dapper queries</strong></a>, triggers): computed column, stored, probably indexed.</li>
<li><strong>Derived from other rows or tables</strong>: neither. Use a view, a projection, or maintain it explicitly with concurrency control.</li>
</ul>
<p>The worst option is the one teams drift into by accident: a normal column that application code tries to keep in sync.
Every code path that forgets is a data bug, and <a href="https://milanjovanovic.tech/blog/what-you-need-to-know-about-ef-core-bulk-updates"><strong>bulk updates</strong></a> with <code>ExecuteUpdateAsync</code> will forget, because they bypass your domain logic entirely.
A computed column is immune to that by construction: <code>ExecuteUpdateAsync</code> changes <code>Quantity</code>, and the database recalculates <code>LineTotal</code> in the same statement.</p>
<h2>Summary</h2>
<p>Computed columns hand the derivation to the only party that sees every write: the database.
<code>HasComputedColumnSql</code> maps them in one line, EF Core treats them as read-only and refreshes them after save, and no code path, not even raw SQL or bulk updates, can make the value inconsistent.</p>
<p>The <code>stored</code> flag is the real decision.
Virtual columns cost nothing to store but recompute on every read and mostly cannot be indexed.
Stored columns pay a small write cost and give you indexable, filter-friendly values.
On PostgreSQL, pass <code>stored: true</code>: generated columns are always stored before PostgreSQL 18, and the virtual ones PostgreSQL 18 adds cannot be indexed.</p>
<p>If the value is only ever rendered, keep it as a C# property.
The database earns the job the moment the value shows up in a <code>WHERE</code> clause.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Mapping JSON Columns in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-json-columns</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-json-columns</guid>
            <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Some data does not deserve its own table: settings blobs, address snapshots, flexible metadata.]]></description>
            <content:encoded><![CDATA[<p>Map a JSON column in EF Core by modeling the data as an owned type and calling <code>ToJson</code> on it: <code>OwnsOne(o =&gt; o.Shipping, b =&gt; b.ToJson())</code>.
The owned object lives as a single JSON document in one column of its owner's row, <code>nvarchar(max)</code> on SQL Server or <code>jsonb</code> on PostgreSQL.
LINQ still translates into the document, and changing one property generates a partial update rather than a full rewrite.</p>
<p>Not every object in your domain earns a table.
User preferences, a shipping address snapshot frozen at order time, integration metadata with a shape you do not control: normalizing these into tables buys you joins and migrations without buying you anything.</p>
<p>Stuffing them into a string column and serializing by hand loses querying, change tracking, and type safety.
JSON columns with <code>ToJson</code> keep all three.
You get a document inside the row, and LINQ still translates into the document.</p>
<h2>Mapping with ToJson</h2>
<p>JSON columns build on owned types.
Model the payload as a plain class, own it, and call <code>ToJson</code>:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public DateTime CreatedAtUtc { get; set; }

    public ShippingInfo Shipping { get; set; } = null!;
}

public class ShippingInfo
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string CountryCode { get; set; } = string.Empty;
    public DeliveryInstructions? Instructions { get; set; }
}

public class DeliveryInstructions
{
    public string? GateCode { get; set; }
    public bool LeaveAtDoor { get; set; }
}
</code></pre>
<p>The configuration is one call on the owned navigation:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.OwnsOne(o =&gt; o.Shipping, shipping =&gt;
        {
            shipping.ToJson();
            shipping.OwnsOne(s =&gt; s.Instructions);
        });
    }
}
</code></pre>
<p>The resulting table has a single <code>Shipping</code> column of type <code>nvarchar(max)</code> on SQL Server or <code>jsonb</code> on PostgreSQL, holding the nested document.
Nesting is free: <code>Instructions</code> lives inside the same document, no extra configuration beyond declaring the ownership.</p>
<p>Collections work the same way with <code>OwnsMany</code>:</p>
<pre><code class="language-csharp">builder.OwnsMany(o =&gt; o.StatusHistory, h =&gt; h.ToJson());
</code></pre>
<p>This is a natural fit for the value-object style of modeling I covered in <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd"><strong>owned types and DDD</strong></a>: the JSON column is an implementation detail, and the domain model stays clean C#.</p>
<p>Also worth knowing: since EF Core 8, primitive collections (<code>List&lt;string&gt;</code>, <code>int[]</code>) on an entity are mapped automatically with no configuration, stored as JSON on SQL Server and SQLite.
On PostgreSQL, Npgsql maps them to native arrays instead.</p>
<h2>Querying into the Document</h2>
<p>This is the part that separates <code>ToJson</code> from a hand-rolled serialized string.
LINQ translates into JSON path operations:</p>
<pre><code class="language-csharp">var orders = await context.Orders
    .Where(o =&gt; o.Shipping.City == &quot;Oslo&quot;)
    .Where(o =&gt; o.Shipping.Instructions!.LeaveAtDoor)
    .OrderBy(o =&gt; o.Shipping.CountryCode)
    .ToListAsync();
</code></pre>
<p>On PostgreSQL that becomes <code>jsonb</code> extraction operators; on SQL Server, <code>JSON_VALUE</code> calls:</p>
<pre><code class="language-sql">SELECT o.&quot;Id&quot;, o.&quot;CreatedAtUtc&quot;, o.&quot;Shipping&quot;
FROM &quot;Orders&quot; AS o
WHERE (o.&quot;Shipping&quot; -&gt;&gt; 'City') = 'Oslo'
  AND CAST(o.&quot;Shipping&quot; #&gt;&gt; '{Instructions,LeaveAtDoor}' AS boolean)
ORDER BY o.&quot;Shipping&quot; -&gt;&gt; 'CountryCode'
</code></pre>
<p>Projections reach inside too, so you can select just a fragment without materializing the owner:</p>
<pre><code class="language-csharp">var cities = await context.Orders
    .Select(o =&gt; o.Shipping.City)
    .Distinct()
    .ToListAsync();
</code></pre>
<p>The translation coverage keeps expanding with each EF release (EF 8 brought JSON collection querying so you can run <code>Any</code> over an <code>OwnsMany</code> document; EF 9 and 10 keep filling gaps).
When you hit an edge the provider cannot translate, <a href="https://milanjovanovic.tech/blog/ef-core-raw-sql-queries"><strong>raw SQL</strong></a> against the same column is always available.</p>
<h2>Partial Updates</h2>
<p>Change tracking works through the document.
Modify one property and save:</p>
<pre><code class="language-csharp">var order = await context.Orders.FirstAsync(o =&gt; o.Id == orderId);

order.Shipping.Instructions!.GateCode = &quot;4471&quot;;

await context.SaveChangesAsync();
</code></pre>
<p>EF Core does not rewrite the whole document.
It generates a targeted patch, <code>JSON_MODIFY</code> on SQL Server, <code>jsonb_set</code> on PostgreSQL, updating only the changed path.
Small documents make this mostly a correctness nicety; on documents holding sizable collections it is a real write amplification saver.</p>
<p>The usual <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core"><strong>change tracker</strong></a> rules apply: the owned instances are tracked with their owner, and replacing the whole <code>Shipping</code> object marks the full document modified.</p>
<h2>How Do You Index a Value Inside a JSON Column?</h2>
<p>The first JSON column that ends up in a hot <code>WHERE</code> clause will need an index, and EF Core's fluent API stops at the column boundary.
Two patterns cover it.</p>
<p>On SQL Server, extract the value into a computed column and index that:</p>
<pre><code class="language-csharp">builder.Property&lt;string&gt;(&quot;ShippingCity&quot;)
    .HasComputedColumnSql(&quot;JSON_VALUE([Shipping], '$.City')&quot;, stored: true);

builder.HasIndex(&quot;ShippingCity&quot;);
</code></pre>
<p>That is the same mechanism I described in <a href="https://milanjovanovic.tech/blog/ef-core-computed-columns"><strong>computed columns in EF Core</strong></a>, applied to JSON extraction.</p>
<p>On PostgreSQL, add a GIN or expression index with raw SQL in a migration:</p>
<pre><code class="language-csharp">migrationBuilder.Sql(
    &quot;&quot;&quot;
    CREATE INDEX ix_orders_shipping_city
    ON &quot;Orders&quot; (((&quot;Shipping&quot; -&gt;&gt; 'City')));
    &quot;&quot;&quot;);

migrationBuilder.Sql(
    &quot;&quot;&quot;
    CREATE INDEX ix_orders_shipping_gin
    ON &quot;Orders&quot; USING gin (&quot;Shipping&quot;);
    &quot;&quot;&quot;);
</code></pre>
<p>The expression index serves equality on one known path; the GIN index serves containment queries across arbitrary paths.
More on picking between Postgres index types in <strong>PostgreSQL indexes for .NET developers</strong>.</p>
<h2>Where JSON Columns Are the Wrong Tool</h2>
<p>The failure mode of document-in-row is using it for things that are actually relational:</p>
<ul>
<li><strong>Anything referenced by other tables.</strong> There are no foreign keys into a JSON document. If another entity needs to point at it, it is a table.</li>
<li><strong>Data queried independently of its owner at scale.</strong> Filtering ten million rows by a JSON property, even indexed, competes poorly with a proper column. Promote hot properties to real columns; keep the long tail in the document.</li>
<li><strong>Data with strong schema guarantees.</strong> The database will happily store a document missing half its fields. Your C# types constrain what your app writes, not what exists.</li>
<li><strong>Concurrent partial edits from multiple writers.</strong> Two processes editing different parts of the same document still conflict at the row level. If that is your workload, split the document or handle it with <a href="https://milanjovanovic.tech/blog/solving-race-conditions-with-ef-core-optimistic-locking"><strong>optimistic concurrency</strong></a>.</li>
</ul>
<p>My heuristic: JSON columns hold data that lives and dies with its row, varies in shape, and is queried mostly through its owner.
Addresses-as-snapshots, settings, metadata, denormalized read models.
Everything else stays relational.</p>
<h2>Summary</h2>
<p><code>ToJson</code> turns owned types into JSON documents inside a relational row, and it keeps the two things hand-rolled serialization throws away: LINQ translation into the document and change tracking with partial updates.</p>
<p>Map the payload as owned types, query it like any navigation, and when a document property becomes hot, index it through a computed column (SQL Server) or an expression/GIN index (PostgreSQL).</p>
<p>The discipline is in the boundary.
JSON columns are for row-scoped, shape-flexible data.
The moment something needs a foreign key, independent querying at scale, or schema enforcement, it has outgrown the document and earned its table.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Mapping Enums in EF Core: Strings, Ints, and Native Postgres Enums]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-enum-mapping</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-enum-mapping</guid>
            <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core stores enums as ints by default, which breaks the day someone reorders the enum. Storing them as strings survives refactoring, but it silently changes…]]></description>
            <content:encoded><![CDATA[<p>EF Core gives you three ways to store an enum: an integer column by default, the member name with <code>HasConversion&lt;string&gt;()</code>, and a native PostgreSQL enum type through Npgsql.
Strings survive a reordered enum, but they cost space and make comparisons and <code>ORDER BY</code> alphabetical.
Native Postgres enums keep compact storage and declared-order sorting, with harder schema evolution.
For most business apps, strings with a max length are the safe default.</p>
<p>By default, EF Core stores your <code>OrderStatus</code> enum as an <code>int</code>.
Everything works, until a teammate alphabetizes the enum members, or inserts a new one in the middle, and every historical row silently means something else.</p>
<p>No error, no migration warning.
<code>Shipped</code> becomes <code>Cancelled</code> in place.</p>
<p>Storing enums as strings removes that failure mode, but it is not free: it changes storage size, index behavior, and, the part almost nobody expects, how <code>ORDER BY</code> and comparisons translate to SQL.
On PostgreSQL there is a third option that gets you most of both.
Let's walk through all three.</p>
<h2>Option 1: Ints (the Default)</h2>
<p>With no configuration, an enum property maps to the provider's integer type:</p>
<pre><code class="language-csharp">public enum OrderStatus
{
    Draft = 0,
    Submitted = 1,
    Shipped = 2,
    Cancelled = 3
}

public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }
}
</code></pre>
<p>Pros:</p>
<ul>
<li>4 bytes per value, cheap to index and compare.</li>
<li><code>OrderBy(o =&gt; o.Status)</code> sorts in numeric declaration order, which often matches workflow order.</li>
</ul>
<p>Cons:</p>
<ul>
<li>The mapping lives only in your C# source. The database has <code>2</code>, and nothing stops an unrelated <code>2</code> from arriving via raw SQL.</li>
<li>Reordering or inserting members renumbers everything after the change. This is the silent data corruption scenario.</li>
<li>Every debugging session involves a mental lookup table, and every report writer needs a copy of your enum.</li>
</ul>
<p>If you keep ints, make the numbering <strong>explicit and append-only</strong>, exactly like the example above.
Never rely on implicit values, and treat the numbers as a public contract.</p>
<h2>Option 2: Strings</h2>
<p>One line converts storage to the member name:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.Property(o =&gt; o.Status)
            .HasConversion&lt;string&gt;()
            .HasMaxLength(50);
    }
}
</code></pre>
<p>Or model-wide, so no enum ever slips through as an int, using the approach from <a href="https://milanjovanovic.tech/blog/ef-core-custom-conventions"><strong>custom model conventions</strong></a>:</p>
<pre><code class="language-csharp">protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties&lt;Enum&gt;()
        .HaveConversion&lt;string&gt;()
        .HaveMaxLength(50);
}
</code></pre>
<p>Always set the max length.
Without it you get <code>nvarchar(max)</code> or <code>text</code>, which is wasteful and, on SQL Server, hostile to indexing.</p>
<p>What you gain:</p>
<ul>
<li><strong>Reorder-proof.</strong> The stored value is <code>'Shipped'</code>. Renumbering the C# enum changes nothing in the database.</li>
<li><strong>Self-describing data.</strong> Queries in psql or SSMS, log output, and reports all read naturally.</li>
</ul>
<p>What you pay, and this is the part that surprises people:</p>
<ul>
<li><strong>Comparisons translate to string comparisons.</strong> <code>Where(o =&gt; o.Status &gt; OrderStatus.Submitted)</code> becomes <code>WHERE Status &gt; 'Submitted'</code>, an alphabetical comparison that has nothing to do with your workflow. EF Core translates the operator faithfully; the semantics changed underneath it.</li>
<li><strong><code>ORDER BY</code> is alphabetical.</strong> <code>Cancelled, Draft, Shipped, Submitted</code>. If a UI sorts by status, you now need an explicit ranking, either a switch expression translated in the query or a lookup table.</li>
<li><strong>Storage and index size grow.</strong> <code>'Submitted'</code> is 9 characters versus 4 bytes. On a 100-million-row table with an index on status, that is real space, though compression usually blunts it.</li>
</ul>
<p>The comparison change is worth internalizing: after the conversion, only <code>==</code> and <code>!=</code> mean what they meant before.
Range checks and sorting need redesign.
This is a general property of <a href="https://milanjovanovic.tech/blog/value-conversions-ef-core"><strong>value conversions</strong></a>, the conversion applies to values, and operators run in the store type.</p>
<p>A rename is also no longer free.
Rename <code>Submitted</code> to <code>Placed</code> in C# and every existing <code>'Submitted'</code> row fails to materialize.
Ship the rename together with a data migration:</p>
<pre><code class="language-sql">UPDATE &quot;Orders&quot; SET &quot;Status&quot; = 'Placed' WHERE &quot;Status&quot; = 'Submitted';
</code></pre>
<h2>Option 3: Native PostgreSQL Enums</h2>
<p>PostgreSQL has first-class enum types, and Npgsql maps CLR enums onto them.
You get compact storage (4 bytes), readable query output, database-side validation of allowed values, and sorting by the enum's declared order.</p>
<p>Register the enum in two places, the data source and the model:</p>
<pre><code class="language-csharp">var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.MapEnum&lt;OrderStatus&gt;();
var dataSource = dataSourceBuilder.Build();

builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(dataSource, npgsql =&gt; npgsql.MapEnum&lt;OrderStatus&gt;()));
</code></pre>
<p>Since Npgsql 9, <code>MapEnum</code> inside <code>UseNpgsql</code> also registers the type with the model, and the migration creates it:</p>
<pre><code class="language-sql">CREATE TYPE order_status AS ENUM ('draft', 'submitted', 'shipped', 'cancelled');
</code></pre>
<p>The catch is schema evolution.
Adding a value is easy (<code>ALTER TYPE order_status ADD VALUE 'refunded'</code>), but it cannot run inside a transaction on older Postgres versions, which can complicate <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>migration</strong></a> tooling.
Removing or renaming a value means creating a new type and rewriting the column.
Native enums are the right call for genuinely stable sets, and a poor one for anything still churning.</p>
<p>If you are already on Postgres for other reasons (I made the broader case in <strong>PostgreSQL vs SQL Server for .NET developers</strong>), native enums are worth a look for your most stable, most queried status columns.</p>
<h2>Which One Should You Pick?</h2>
<ul>
<li><strong>Default: strings with a max length</strong>, applied as a model-wide convention. Business apps refactor enums far more often than they hit enum-column storage limits, and self-describing data pays for itself in every incident investigation.</li>
<li><strong>Ints</strong> when the table is huge, the enum is stable and explicitly numbered, and the column is heavily indexed. Also when range semantics (<code>&gt;=</code>) genuinely map to the numeric order and you want them translated.</li>
<li><strong>Native Postgres enums</strong> for stable vocabularies on Postgres-committed teams: order status in a mature domain, ISO-like code sets, severity levels.</li>
</ul>
<p>Whatever you choose, be careful with <code>[Flags]</code> enums in mapped properties.
String conversion stores combinations as comma-separated names like <code>'Draft, Submitted'</code>, which are painful to query, and native Postgres enums cannot represent combinations at all.
If you must persist flags, keep the int mapping, or model the flags as separate boolean columns or a <a href="https://milanjovanovic.tech/blog/ef-core-json-columns"><strong>JSON column</strong></a> instead.</p>
<h2>Summary</h2>
<p>The default int mapping is a refactoring landmine: reorder the enum and the data silently changes meaning.
<code>HasConversion&lt;string&gt;()</code>, ideally as a model-wide convention, defuses it, but remember what you traded away: comparisons and <code>ORDER BY</code> now operate on names, not on your declaration order, so only equality survives the conversion unchanged.</p>
<p>On PostgreSQL, native enum types recover compact storage, declared-order sorting, and database-side validation, at the cost of heavier schema evolution.</p>
<p>Pick per column, but pick consciously.
The worst outcome is the accidental one: an implicit int mapping nobody chose, waiting for the first well-intentioned cleanup of the enum file.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Composite Primary Keys in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-composite-keys</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-composite-keys</guid>
            <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Composite primary keys look like a simple HasKey call, but they change how Find works, how relationships are configured, and how your indexes behave.]]></description>
            <content:encoded><![CDATA[<p>A composite primary key in EF Core is a primary key made of two or more columns, declared with <code>HasKey</code> and an anonymous type or with the <code>[PrimaryKey]</code> attribute in EF Core 7+.
It is the right shape for join tables and for child rows that are always reached through their parent.
Everywhere else, a surrogate key with a unique index gives you the same guarantees with less friction.</p>
<p>Every EF Core tutorial shows entities with a single <code>Id</code> property.
Then you model a join table or a child entity that has no natural single-column identity, and suddenly you need a composite key.</p>
<p>The configuration is one line.
The behavior changes it drags in are not.
<code>Find</code> takes multiple arguments in a specific order, relationships need multi-column foreign keys, and the order of key columns silently decides what your primary key index can do.</p>
<p>Here is how composite keys actually behave in EF Core, and when you should skip them for a surrogate key.</p>
<h2>Configuring a Composite Key</h2>
<p>Composite keys cannot be configured with the <code>[Key]</code> attribute on individual properties.
You need either the Fluent API or the <code>[PrimaryKey]</code> attribute (EF Core 7+).</p>
<p>Here is the classic example, an order line that is identified by the order and the product:</p>
<pre><code class="language-csharp">public class OrderItem
{
    public Guid OrderId { get; set; }
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }

    public Order Order { get; set; } = null!;
    public Product Product { get; set; } = null!;
}
</code></pre>
<p>The Fluent API configuration:</p>
<pre><code class="language-csharp">public class OrderItemConfiguration : IEntityTypeConfiguration&lt;OrderItem&gt;
{
    public void Configure(EntityTypeBuilder&lt;OrderItem&gt; builder)
    {
        builder.HasKey(oi =&gt; new { oi.OrderId, oi.ProductId });
    }
}
</code></pre>
<p>Or with the attribute, where the order of names matters just as much:</p>
<pre><code class="language-csharp">[PrimaryKey(nameof(OrderId), nameof(ProductId))]
public class OrderItem
{
    // ...
}
</code></pre>
<p>The order you declare the columns in is the order of the primary key index.
<code>(OrderId, ProductId)</code> means the database can seek efficiently on <code>OrderId</code> alone, but a query filtering only on <code>ProductId</code> scans.
Put the column you filter by most often first.
I covered how index column order affects query plans in <strong>PostgreSQL indexes for .NET developers</strong>.</p>
<h2>How Find Changes</h2>
<p><code>FindAsync</code> is built around primary keys, so with a composite key it takes multiple values:</p>
<pre><code class="language-csharp">var item = await context.OrderItems.FindAsync(orderId, productId);
</code></pre>
<p>Two things to watch:</p>
<ul>
<li>The values must be passed <strong>in the declaration order</strong> from <code>HasKey</code>. Swap them and you get a runtime failure or a silent miss, and the compiler cannot help you because both are <code>Guid</code>.</li>
<li><code>FindAsync</code> checks the change tracker before querying the database, same as with single keys. That is usually a free cache hit, but it can return stale data in long-lived contexts.</li>
</ul>
<p>I dig into that behavior in <a href="https://milanjovanovic.tech/blog/ef-core-find-vs-firstordefault"><strong>Find vs FirstOrDefault in EF Core</strong></a>.</p>
<p>If the argument order makes you nervous, a <code>FirstOrDefaultAsync</code> with named predicates is more explicit:</p>
<pre><code class="language-csharp">var item = await context.OrderItems
    .FirstOrDefaultAsync(oi =&gt; oi.OrderId == orderId &amp;&amp; oi.ProductId == productId);
</code></pre>
<h2>Relationships Against a Composite Key</h2>
<p>Once an entity has a composite key, any entity referencing it needs a <strong>composite foreign key</strong> with matching column types and order.</p>
<pre><code class="language-csharp">public class Shipment
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public Guid ProductId { get; set; }

    public OrderItem OrderItem { get; set; } = null!;
}

public class ShipmentConfiguration : IEntityTypeConfiguration&lt;Shipment&gt;
{
    public void Configure(EntityTypeBuilder&lt;Shipment&gt; builder)
    {
        builder.HasOne(s =&gt; s.OrderItem)
            .WithMany()
            .HasForeignKey(s =&gt; new { s.OrderId, s.ProductId });
    }
}
</code></pre>
<p>This is where composite keys start to spread.
Every referencing table carries both columns, every join condition has two predicates, and every new relationship is a chance to get the column order wrong.
EF Core validates the shapes at model building time, which helps, but the extra ceremony is real.
The patterns for configuring these correctly are the same ones I covered in <a href="https://milanjovanovic.tech/blog/entity-relationships-ef-core"><strong>entity relationships in EF Core</strong></a>.</p>
<h2>No Value Generation</h2>
<p>Single-column integer keys get identity values from the database.
Composite keys get nothing.
You must set every key component yourself before <code>SaveChanges</code>, or the insert fails.</p>
<p>That is fine for join tables where both values are existing foreign keys.
It is painful for anything else, because you have just signed up for client-side key management.
If you find yourself inventing one of the key parts (a sequence number, a timestamp), that is a strong sign you want a surrogate key instead, or database-generated ids like the ones I compared in <a href="https://milanjovanovic.tech/blog/ef-core-identity-sequence-hilo"><strong>identity vs sequence vs HiLo key generation</strong></a>.</p>
<h2>Composite Keys in Many-to-Many Join Entities</h2>
<p>The most legitimate home for a composite key is an explicit join entity.
EF Core creates exactly this shape when you let it scaffold a skip navigation, and you can take control of it when the join table carries payload:</p>
<pre><code class="language-csharp">public class StudentCourse
{
    public Guid StudentId { get; set; }
    public Guid CourseId { get; set; }
    public DateTime EnrolledAtUtc { get; set; }
    public decimal? Grade { get; set; }
}

modelBuilder.Entity&lt;Student&gt;()
    .HasMany(s =&gt; s.Courses)
    .WithMany(c =&gt; c.Students)
    .UsingEntity&lt;StudentCourse&gt;(
        j =&gt; j.HasOne&lt;Course&gt;().WithMany().HasForeignKey(sc =&gt; sc.CourseId),
        j =&gt; j.HasOne&lt;Student&gt;().WithMany().HasForeignKey(sc =&gt; sc.StudentId),
        j =&gt; j.HasKey(sc =&gt; new { sc.StudentId, sc.CourseId }));
</code></pre>
<p>Here the composite key is doing double duty: it is the identity <strong>and</strong> a uniqueness constraint that prevents duplicate enrollments.
No surrogate key can give you the second part for free.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-composite-keys/join-entity-composite-key.png" alt="Entity relationship diagram of a StudentCourse join entity whose composite primary key is made of the StudentId and CourseId foreign keys, plus payload columns for enrollment date and grade">
<h2>When Does a Surrogate Key Win?</h2>
<p>My rule of thumb after years of maintaining both:</p>
<ul>
<li><strong>Composite key</strong>: join tables, child tables always accessed through the parent, tables nothing else references.</li>
<li><strong>Surrogate key plus unique index</strong>: everything else.</li>
</ul>
<p>The surrogate-plus-unique-index pattern gives you the same duplicate protection without the downsides:</p>
<pre><code class="language-csharp">// OrderItem gains a surrogate key property:
// public Guid Id { get; set; }

builder.HasKey(oi =&gt; oi.Id);

builder.HasIndex(oi =&gt; new { oi.OrderId, oi.ProductId })
    .IsUnique();
</code></pre>
<p>Reasons the surrogate wins more often than you would expect:</p>
<ul>
<li><strong>Referencing gets simpler.</strong> Foreign keys are one column, joins are one predicate, and no downstream table repeats your key structure.</li>
<li><strong>The key never changes.</strong> Natural keys have a habit of becoming not-so-natural. A product merge or an order renumbering with a composite natural key is a multi-table <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>migration</strong></a> you do not want to write.</li>
<li><strong>APIs and URLs stay clean.</strong> <code>/order-items/{id}</code> beats encoding two guids into a route.</li>
<li><strong>Tooling assumes single keys.</strong> Generic repositories, <code>FindAsync</code> wrappers, and audit patterns all get uglier with composite keys.</li>
</ul>
<p>The cost is one extra column and one extra index.
For a high-volume join table with no inbound references, that cost buys you nothing, which is why join tables stay composite.</p>
<h2>Summary</h2>
<p>Composite keys in EF Core are a one-line configuration with a long tail of consequences.
<code>HasKey</code> with an anonymous type (or <code>[PrimaryKey]</code>) defines them, <code>FindAsync</code> needs values in declaration order, relationships need matching multi-column foreign keys, and value generation is off the table entirely.</p>
<p>Use them where they model reality: join entities and parent-scoped children where the pair of foreign keys <strong>is</strong> the identity, and the key doubles as a uniqueness constraint.
For anything that other tables reference or that surfaces in an API, a surrogate key with a unique index gives you the same guarantees with far less friction.</p>
<p>Column order is the detail people miss.
Whether it is the primary key or the backing unique index, lead with the column you filter by most.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Value Conversions in EF Core Explained]]></title>
            <link>https://milanjovanovic.tech/blog/value-conversions-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/value-conversions-ef-core</guid>
            <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Value conversions translate between a domain type and its database column: enums stored as strings, strongly typed IDs stored as Guids, value objects stored as…]]></description>
            <content:encoded><![CDATA[<p>A <strong>value conversion</strong> is a pair of transformations EF Core applies to a property: one converts the .NET value to the database value on write, the other converts it back on read.
That lets a domain type differ from its database representation, which is especially useful for enums and strongly typed IDs.
The conversion must preserve query semantics, and mutable reference types also need a value comparer for correct change tracking.</p>
<h2>What Are Value Conversions?</h2>
<p>EF Core value conversions let you transform a property's value when it's stored in the database and when it's read back into your entity. You define a pair of expressions: one for writing and one for reading. EF Core applies them transparently.</p>
<p>This is useful when your domain model uses types that don't map directly to database columns - enums, strongly typed IDs, <a href="https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals">value objects</a>, or custom types.</p>
<h2>HasConversion Basics</h2>
<p>The simplest way to configure a value conversion is with <code>HasConversion</code> in your entity configuration:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.Property(o =&gt; o.Status)
            .HasConversion(
                status =&gt; status.ToString(),        // Write: enum → string
                value =&gt; Enum.Parse&lt;OrderStatus&gt;(value)); // Read: string → enum
    }
}
</code></pre>
<p>The first lambda converts the .NET value to the database value. The second converts it back. EF Core calls these automatically during <code>SaveChangesAsync</code> and queries.</p>
<img src="https://milanjovanovic.tech/blogs/articles/value-conversions-ef-core/value-conversion-flow.png" alt="An OrderStatus enum on the entity is converted to a string when written to the text column, and parsed back to the enum when read from the database">
<h2>Enum-to-String Conversions</h2>
<p>Storing enums as strings is one of the most common use cases. It makes your database more readable and avoids breaking changes when you reorder enum members.</p>
<p>EF Core provides a built-in converter for this:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.Status)
    .HasConversion(new EnumToStringConverter&lt;OrderStatus&gt;())
    .HasMaxLength(50);
</code></pre>
<p>Without this, EF Core stores enums as integers by default. That works, but you end up with <code>0</code>, <code>1</code>, <code>2</code> in your database instead of <code>Draft</code>, <code>Confirmed</code>, <code>Shipped</code>.</p>
<h2>Built-in Converters</h2>
<p>EF Core ships with several built-in converters in the <code>Microsoft.EntityFrameworkCore.Storage.ValueConversion</code> namespace:</p>
<ul>
<li><code>BoolToStringConverter</code> - stores a <code>bool</code> as configurable strings like &quot;Y&quot;/&quot;N&quot;</li>
<li><code>BoolToZeroOneConverter</code> - stores a <code>bool</code> as <code>0</code> or <code>1</code></li>
<li><code>DateTimeToTicksConverter</code> - stores a <code>DateTime</code> as a <code>long</code> tick count</li>
<li><code>EnumToStringConverter&lt;TEnum&gt;</code> - stores an enum as its member name</li>
<li><code>EnumToNumberConverter&lt;TEnum, TNumber&gt;</code> - stores an enum as a numeric type of your choice</li>
<li><code>GuidToStringConverter</code> - stores a <code>Guid</code> as a string</li>
<li><code>TimeSpanToTicksConverter</code> - stores a <code>TimeSpan</code> as a <code>long</code> tick count</li>
</ul>
<p>You can use any of these directly with <code>HasConversion</code>:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.IsActive)
    .HasConversion(new BoolToStringConverter(&quot;No&quot;, &quot;Yes&quot;));
</code></pre>
<h2>Custom Converters</h2>
<p>For more complex scenarios, create a custom <code>ValueConverter&lt;TModel, TProvider&gt;</code>:</p>
<pre><code class="language-csharp">public sealed record Email(string Value);

public class EmailConverter : ValueConverter&lt;Email, string&gt;
{
    public EmailConverter()
        : base(
            email =&gt; email.Value,
            value =&gt; new Email(value))
    {
    }
}
</code></pre>
<p>Then apply it:</p>
<pre><code class="language-csharp">builder.Property(u =&gt; u.Email)
    .HasConversion(new EmailConverter())
    .HasMaxLength(255);
</code></pre>
<p>For value objects with a single property, this is the cleanest approach. You keep your domain model expressive without polluting the database schema.</p>
<h2>Strongly Typed IDs</h2>
<p>Strongly typed IDs prevent you from accidentally passing an <code>OrderId</code> where a <code>CustomerId</code> is expected. Value conversions make them work with EF Core:</p>
<pre><code class="language-csharp">public readonly record struct OrderId(Guid Value);

public class OrderIdConverter : ValueConverter&lt;OrderId, Guid&gt;
{
    public OrderIdConverter()
        : base(
            id =&gt; id.Value,
            value =&gt; new OrderId(value))
    {
    }
}
</code></pre>
<p>Configure in your <code>DbContext</code>:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.Id)
    .HasConversion(new OrderIdConverter());
</code></pre>
<p>Or apply it globally using conventions (EF Core 6+):</p>
<pre><code class="language-csharp">protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties&lt;OrderId&gt;()
        .HaveConversion&lt;OrderIdConverter&gt;();
}
</code></pre>
<h2>Value Comparers</h2>
<p>EF Core uses value comparers to determine if a property's value has changed. For reference types, the default comparer uses <code>ReferenceEquals</code>, which means the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> might not detect changes to mutable objects.</p>
<p>You need a custom <code>ValueComparer</code> when your converted type is a class:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.Tags)
    .HasConversion(
        tags =&gt; string.Join(',', tags),
        value =&gt; value.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList())
    .Metadata.SetValueComparer(
        new ValueComparer&lt;List&lt;string&gt;&gt;(
            (a, b) =&gt; a.SequenceEqual(b),
            c =&gt; c.Aggregate(0, (h, v) =&gt; HashCode.Combine(h, v.GetHashCode())),
            c =&gt; c.ToList()));
</code></pre>
<p>The three expressions define equality, hash code, and snapshot. Without a proper comparer, EF Core either misses changes or generates unnecessary updates.</p>
<h2>JSON Columns</h2>
<p>EF Core supports mapping object graphs to JSON columns through owned types and, in EF Core 10, complex types.
This is model-level JSON mapping rather than a value converter, so nested members remain available to translated queries:</p>
<pre><code class="language-csharp">builder.OwnsOne(o =&gt; o.ShippingAddress, address =&gt;
{
    address.ToJson();
});
</code></pre>
<p>For simpler cases, you can use a manual JSON conversion:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.Metadata)
    .HasConversion(
        meta =&gt; JsonSerializer.Serialize(meta, JsonSerializerOptions.Default),
        json =&gt; JsonSerializer.Deserialize&lt;OrderMetadata&gt;(
            json, JsonSerializerOptions.Default)!)
    .HasColumnType(&quot;jsonb&quot;);
</code></pre>
<p>The converter approach is fine for blobs of semi-structured data, but the serialized string is opaque to EF Core.
It cannot translate queries against individual properties, which is exactly what the model-level JSON mapping above gives you.</p>
<h2>Limitations</h2>
<p>Value conversions have a few important constraints:</p>
<ul>
<li><strong>Null handling</strong>: Null values are never passed through the converter. EF Core handles nulls separately. A nullable property stores <code>NULL</code> in the database without invoking the converter.</li>
<li><strong>Navigations</strong>: Converters apply to scalar properties, never to navigation properties or entity collections. Use <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd">owned types</a> or JSON columns for those. A primitive collection like <code>List&lt;string&gt;</code> can be converted (as the tags example above shows), but then it needs a value comparer.</li>
<li><strong>Querying</strong>: the database only sees the converted value. Filtering on equality works because EF Core converts your parameters too, but operations that need the original type's semantics don't. <code>Where(o =&gt; o.Status &gt; OrderStatus.Confirmed)</code> on a string-stored enum compares alphabetically, not by enum order.</li>
<li><strong>Sorting</strong>: same problem. <code>OrderBy(o =&gt; o.Status)</code> sorts by the stored string, so <code>Cancelled</code> comes before <code>Draft</code>.</li>
</ul>
<p>For value objects with <strong>multiple</strong> properties, a value converter to a single column doesn't fit.
Use <a href="https://milanjovanovic.tech/blog/complex-types-ef-core"><strong>complex types</strong></a> (EF Core 8+) or owned entities so each property gets its own column.</p>
<h2>Applying Conversions Globally</h2>
<p>Instead of configuring each property individually, use <code>ConfigureConventions</code> to apply conversions across all entities:</p>
<pre><code class="language-csharp">protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties&lt;DateTime&gt;()
        .HaveConversion&lt;DateTimeToUtcConverter&gt;();

    configurationBuilder.Properties&lt;OrderStatus&gt;()
        .HaveConversion&lt;EnumToStringConverter&lt;OrderStatus&gt;&gt;()
        .HaveMaxLength(50);
}
</code></pre>
<p><code>DateTimeToUtcConverter</code> is a small custom converter that normalizes every <code>DateTime</code> to UTC:</p>
<pre><code class="language-csharp">public class DateTimeToUtcConverter : ValueConverter&lt;DateTime, DateTime&gt;
{
    public DateTimeToUtcConverter()
        : base(
            value =&gt; value.ToUniversalTime(),
            value =&gt; DateTime.SpecifyKind(value, DateTimeKind.Utc))
    {
    }
}
</code></pre>
<p>This keeps your entity configurations clean and ensures consistency across the entire model.</p>
<h2>Summary</h2>
<p>Use <code>HasConversion</code> when a domain value has a stable scalar representation in the database.
Add a value comparer for mutable reference types so the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> can snapshot and compare them correctly.
Apply repeated conversions through <code>ConfigureConventions</code>, and use provider JSON mapping rather than a converter when nested members must remain queryable.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Table Per Hierarchy vs Table Per Type in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/tph-vs-tpt-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/tph-vs-tpt-ef-core</guid>
            <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[TPH, TPT, and TPC map inheritance to different table and join shapes. Compare their constraints, generated SQL, and performance trade-offs before choosing a…]]></description>
            <content:encoded><![CDATA[<p><strong>TPH</strong> (Table Per Hierarchy) maps a whole class hierarchy to one table with a discriminator column, and it is EF Core's default.
<strong>TPT</strong> (Table Per Type) gives the base class and each subclass its own table, joined by foreign key.
TPH keeps polymorphic queries to one table scan, while TPT normalizes the schema and can enforce NOT NULL on subclass columns.
Start with TPH unless you have a strong reason not to.</p>
<p>Object inheritance has no single natural relational representation.
TPH, TPT, and TPC distribute columns and joins differently, so the choice affects query shape long after the mapping code is written.
Decide from the reads you need and the constraints you value, then verify the generated SQL.</p>
<h2>Inheritance Mapping in EF Core</h2>
<p>When your domain model uses inheritance - a base <code>Payment</code> class with <code>CreditCardPayment</code> and <code>BankTransferPayment</code> subclasses - EF Core needs a strategy to map this to relational tables. The two main approaches are Table Per Hierarchy (TPH) and Table Per Type (TPT).</p>
<p>The wrong strategy becomes expensive to change once the hierarchy contains production data.
The choice affects <a href="https://milanjovanovic.tech/blog/ef-core-performance-guide">query performance</a>, schema complexity, and which constraints the database can express.</p>
<img src="https://milanjovanovic.tech/blogs/articles/tph-vs-tpt-ef-core/inheritance-mapping.png" alt="The same Payment hierarchy mapped three ways: TPH into one table with a discriminator, TPT into a base table plus subclass tables joined by foreign key, and TPC into three standalone concrete tables">
<h2>Table Per Hierarchy (TPH)</h2>
<p>TPH stores all types in a single table with a discriminator column that indicates the type:</p>
<pre><code class="language-csharp">public abstract class Payment
{
    public Guid Id { get; set; }
    public decimal Amount { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Currency { get; set; } = &quot;USD&quot;;
}

public class CreditCardPayment : Payment
{
    public string CardNumber { get; set; } = string.Empty;
    public string CardHolderName { get; set; } = string.Empty;
    public string ExpiryDate { get; set; } = string.Empty;
}

public class BankTransferPayment : Payment
{
    public string BankName { get; set; } = string.Empty;
    public string AccountNumber { get; set; } = string.Empty;
    public string RoutingNumber { get; set; } = string.Empty;
}

public class CryptoPayment : Payment
{
    public string WalletAddress { get; set; } = string.Empty;
    public string Network { get; set; } = string.Empty;
}
</code></pre>
<p>TPH is the default in EF Core. One <code>Payments</code> table stores everything:</p>
<ul>
<li>A credit card payment row fills <code>Amount</code>, <code>Currency</code>, <code>Discriminator = 'CreditCard'</code>, <code>CardNumber</code>, and <code>CardHolderName</code>, while <code>BankName</code>, <code>AccountNumber</code>, and <code>WalletAddress</code> are NULL.</li>
<li>A bank transfer row fills <code>Amount</code>, <code>Currency</code>, <code>Discriminator = 'BankTransfer'</code>, <code>BankName</code>, and <code>AccountNumber</code>, while all the credit card and crypto columns are NULL.</li>
</ul>
<p>Every subclass-specific column exists on every row, and rows of other types leave them NULL.</p>
<p>Configure the discriminator:</p>
<pre><code class="language-csharp">public class PaymentConfiguration : IEntityTypeConfiguration&lt;Payment&gt;
{
    public void Configure(EntityTypeBuilder&lt;Payment&gt; builder)
    {
        builder.ToTable(&quot;Payments&quot;);

        builder.HasDiscriminator&lt;string&gt;(&quot;PaymentType&quot;)
            .HasValue&lt;CreditCardPayment&gt;(&quot;CreditCard&quot;)
            .HasValue&lt;BankTransferPayment&gt;(&quot;BankTransfer&quot;)
            .HasValue&lt;CryptoPayment&gt;(&quot;Crypto&quot;);

        builder.Property(&quot;PaymentType&quot;)
            .HasMaxLength(50);
    }
}
</code></pre>
<h2>Table Per Type (TPT)</h2>
<p>TPT uses a separate table for each type. The base class gets one table, each subclass gets another table with a foreign key back to the base:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity&lt;Payment&gt;().ToTable(&quot;Payments&quot;);
    modelBuilder.Entity&lt;CreditCardPayment&gt;().ToTable(&quot;CreditCardPayments&quot;);
    modelBuilder.Entity&lt;BankTransferPayment&gt;().ToTable(&quot;BankTransferPayments&quot;);
    modelBuilder.Entity&lt;CryptoPayment&gt;().ToTable(&quot;CryptoPayments&quot;);
}
</code></pre>
<p>On EF Core 7+, you can also state the intent explicitly with <code>modelBuilder.Entity&lt;Payment&gt;().UseTptMappingStrategy();</code>, but the per-type <code>ToTable</code> calls alone are enough to trigger TPT.</p>
<p>This creates four tables:</p>
<ul>
<li><code>Payments</code> (Id, Amount, Currency, CreatedAt)</li>
<li><code>CreditCardPayments</code> (Id → FK to Payments, CardNumber, CardHolderName, ExpiryDate)</li>
<li><code>BankTransferPayments</code> (Id → FK to Payments, BankName, AccountNumber, RoutingNumber)</li>
<li><code>CryptoPayments</code> (Id → FK to Payments, WalletAddress, Network)</li>
</ul>
<h2>Performance Comparison</h2>
<p>This is where the two strategies diverge significantly.</p>
<p><strong>Querying all payments (TPH)</strong>:</p>
<pre><code class="language-sql">SELECT * FROM Payments
</code></pre>
<p>One table scan. Fast.</p>
<p><strong>Querying all payments (TPT)</strong>:</p>
<pre><code class="language-sql">SELECT p.*, cc.*, bt.*, cr.*
FROM Payments p
LEFT JOIN CreditCardPayments cc ON p.Id = cc.Id
LEFT JOIN BankTransferPayments bt ON p.Id = bt.Id
LEFT JOIN CryptoPayments cr ON p.Id = cr.Id
</code></pre>
<p>Multiple LEFT JOINs. Gets slower with each type added.</p>
<p><strong>Querying a specific type (TPH)</strong>:</p>
<pre><code class="language-csharp">var creditCardPayments = await dbContext.Set&lt;CreditCardPayment&gt;()
    .Where(p =&gt; p.Amount &gt; 100)
    .ToListAsync();
</code></pre>
<pre><code class="language-sql">SELECT * FROM Payments WHERE PaymentType = 'CreditCard' AND Amount &gt; 100
</code></pre>
<p>Fast - just a filter on the discriminator column.</p>
<p><strong>Querying a specific type (TPT)</strong>:</p>
<pre><code class="language-sql">SELECT p.*, cc.*
FROM Payments p
INNER JOIN CreditCardPayments cc ON p.Id = cc.Id
WHERE p.Amount &gt; 100
</code></pre>
<p>Still needs a JOIN, but only one.</p>
<h2>Inserting Data</h2>
<p><strong>TPH</strong>: Single INSERT to one table.</p>
<pre><code class="language-csharp">dbContext.Set&lt;CreditCardPayment&gt;().Add(new CreditCardPayment
{
    Amount = 100,
    Currency = &quot;USD&quot;,
    CardNumber = &quot;4111111111111111&quot;,
    CardHolderName = &quot;John Doe&quot;,
    ExpiryDate = &quot;12/28&quot;
});
await dbContext.SaveChangesAsync();
</code></pre>
<p><strong>TPT</strong>: Two INSERTs - one to the base table, one to the subclass table (in a transaction).</p>
<p>The INSERT overhead matters at high write volumes. TPH is consistently better for writes.</p>
<h2>Data Integrity</h2>
<p><strong>TPH disadvantage</strong>: Subclass-specific columns must be nullable. You can't enforce that <code>CardNumber</code> is required at the database level because <code>BankTransferPayment</code> rows don't have it. You need application-level validation.</p>
<p><strong>TPT advantage</strong>: Each subclass table can enforce its own NOT NULL constraints. <code>CreditCardPayments.CardNumber</code> can be NOT NULL.</p>
<pre><code class="language-csharp">// TPT allows proper constraints
modelBuilder.Entity&lt;CreditCardPayment&gt;(b =&gt;
{
    b.Property(p =&gt; p.CardNumber).IsRequired().HasMaxLength(19);
    b.Property(p =&gt; p.CardHolderName).IsRequired().HasMaxLength(100);
});
</code></pre>
<h2>Table Per Concrete Type (TPC)</h2>
<p>EF Core 7 introduced TPC as a third option. Each concrete type gets its own table with all columns - no foreign keys between them:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Payment&gt;().UseTpcMappingStrategy();
modelBuilder.Entity&lt;CreditCardPayment&gt;().ToTable(&quot;CreditCardPayments&quot;);
modelBuilder.Entity&lt;BankTransferPayment&gt;().ToTable(&quot;BankTransferPayments&quot;);
modelBuilder.Entity&lt;CryptoPayment&gt;().ToTable(&quot;CryptoPayments&quot;);
</code></pre>
<p>TPC queries use UNION ALL instead of JOINs:</p>
<pre><code class="language-sql">SELECT * FROM CreditCardPayments
UNION ALL
SELECT * FROM BankTransferPayments
UNION ALL
SELECT * FROM CryptoPayments
</code></pre>
<p>TPC is good when you rarely query across all types and mostly query specific subtypes.</p>
<p>One caveat: TPC works best with client-generated keys like <code>Guid</code>.
A plain identity column cannot guarantee uniqueness across separate tables, so EF Core generates integer keys from a single shared sequence instead.</p>
<h2>Side-by-Side Comparison</h2>
<p>Here's how the three strategies compare across the factors that matter:</p>
<table><thead><tr><th></th><th>TPH</th><th>TPT</th><th>TPC</th></tr></thead><tbody><tr><td>Tables</td><td>One table for the whole hierarchy</td><td>Base table plus one table per subclass</td><td>One standalone table per concrete type</td></tr><tr><td>Querying all types</td><td>One table scan, the fastest</td><td>A LEFT JOIN per subclass, the slowest</td><td>UNION ALL across the tables</td></tr><tr><td>Querying a single type</td><td>Filter on the discriminator column</td><td>One JOIN to the base table</td><td>One dedicated table, the fastest</td></tr><tr><td>Inserting a row</td><td>One INSERT</td><td>Two INSERTs, base plus subclass</td><td>One INSERT</td></tr><tr><td>Subclass constraints</td><td>Subclass columns must be nullable</td><td>NOT NULL per subclass table</td><td>NOT NULL per concrete table</td></tr><tr><td>Sparse data</td><td>Carries NULLs for the other types' columns</td><td>Only the columns each type needs</td><td>Only the columns each type needs</td></tr><tr><td>Adding a new type</td><td>A migration for the new columns, the least invasive change</td><td>A migration for the new subclass table</td><td>A migration for the new concrete table</td></tr><tr><td>EF Core support</td><td>The default strategy</td><td>Per-type ToTable calls</td><td>Added in EF Core 7</td></tr><tr><td>Best for</td><td>Most hierarchies, and polymorphic queries in particular</td><td>Many subclass columns that would be NULL, or database-level constraints</td><td>Concrete types queried independently</td></tr></tbody></table>
<h2>Which Strategy Should You Choose?</h2>
<p><strong>My recommendation</strong>: Start with TPH unless you have a strong reason not to. The performance advantage is significant, and the nullable column issue is manageable with proper validation.</p>
<p>Use TPT when:</p>
<ul>
<li>You have many subclass-specific columns and most would be NULL in TPH</li>
<li>Database-level constraints on subclass properties are critical</li>
<li>You rarely query across all types</li>
</ul>
<p>Use TPC when:</p>
<ul>
<li>Each concrete type is mostly queried independently</li>
<li>You need strong constraints without JOINs</li>
</ul>
<h2>Querying Gotchas Worth Knowing</h2>
<p>A few things that surprise people in production:</p>
<p><strong><code>OfType&lt;T&gt;()</code> translates to a discriminator filter</strong> with TPH, so this stays a single-table query:</p>
<pre><code class="language-csharp">var cardPayments = await dbContext.Payments
    .OfType&lt;CreditCardPayment&gt;()
    .Where(p =&gt; p.Amount &gt; 100)
    .ToListAsync();
</code></pre>
<p><strong>Global query filters apply to the whole hierarchy.</strong> You can only define a <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core">query filter</a> on the root type, and it applies to every subclass. You can't filter just one payment type globally.</p>
<p><strong>The discriminator column has no index by default.</strong> If you frequently query a rare subtype in a huge table, add an index on the discriminator (or a filtered index for that discriminator value). This is the same class of problem as any other <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes">query performance mistake</a>: measure first, then index.</p>
<h2>Switching Strategies Later</h2>
<p>Changing the mapping strategy is a schema migration:</p>
<pre><code class="language-csharp">// To switch from TPH to TPT:
modelBuilder.Entity&lt;CreditCardPayment&gt;().ToTable(&quot;CreditCardPayments&quot;);
modelBuilder.Entity&lt;BankTransferPayment&gt;().ToTable(&quot;BankTransferPayments&quot;);

// dotnet ef migrations add SwitchToTpt
</code></pre>
<p>Here's the critical part: <strong>the generated migration only changes the schema</strong>.
It creates the new subclass tables and drops the subclass columns from the old table, but it does not copy your existing data across.</p>
<p>On a production table, you need to add custom SQL to the migration that moves the data before the old columns are dropped.
Follow the usual <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices">migration best practices</a>: review the generated migration, add the data-copy step, and test it against a restored production backup first.</p>
<h2>Summary</h2>
<p>TPH is the simplest starting point and usually gives polymorphic queries the least join overhead.
TPT trades additional joins for normalized subclass tables, while TPC duplicates base columns to keep concrete-type reads independent.
Choose from the actual query mix and required database constraints, then benchmark the generated SQL before the schema becomes expensive to change.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Shadow Properties in EF Core Explained]]></title>
            <link>https://milanjovanovic.tech/blog/shadow-properties-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/shadow-properties-ef-core</guid>
            <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Shadow properties exist in the EF Core model but not in your entity classes. They are useful for audit fields, foreign keys, and metadata you want in the…]]></description>
            <content:encoded><![CDATA[<p><strong>Shadow properties</strong> are properties defined in the EF Core model and mapped to database columns, but they do not exist on the CLR entity class.
EF Core tracks their values in the change tracker, so you can query and update them without putting them on the entity.
Foreign keys, audit metadata, and infrastructure state can live there instead of in the domain object's public API, which keeps entities focused.</p>
<p>The tradeoff: query and update access is less obvious unless the convention is consistent.</p>
<h2>What Are Shadow Properties?</h2>
<p>Shadow properties are properties that exist in the EF Core model - mapped to database columns - but don't exist as CLR properties on your entity class. They're tracked by the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a> and included in queries, but your domain model doesn't know about them.</p>
<p>This keeps your entity classes clean. Infrastructure concerns like <code>CreatedAt</code>, <code>UpdatedAt</code>, or hidden foreign keys stay in the EF Core configuration layer where they belong.</p>
<img src="https://milanjovanovic.tech/blogs/articles/shadow-properties-ef-core/shadow-property-layers.png" alt="A shadow property such as CreatedAt exists in the EF Core model and as a database column, but has no matching property on the Order entity class">
<h2>Defining Shadow Properties</h2>
<p>Define a shadow property in your entity configuration:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.Property&lt;DateTime&gt;(&quot;CreatedAt&quot;);
        builder.Property&lt;DateTime&gt;(&quot;UpdatedAt&quot;);
        builder.Property&lt;string&gt;(&quot;CreatedBy&quot;).HasMaxLength(100);
    }
}
</code></pre>
<p>These properties don't exist on the <code>Order</code> class, but EF Core creates the corresponding database columns:</p>
<pre><code class="language-sql">CREATE TABLE &quot;Orders&quot; (
    &quot;Id&quot; uuid NOT NULL,
    &quot;Status&quot; varchar(50) NOT NULL,
    &quot;CreatedAt&quot; timestamptz NOT NULL,
    &quot;UpdatedAt&quot; timestamptz NOT NULL,
    &quot;CreatedBy&quot; varchar(100),
    CONSTRAINT &quot;PK_Orders&quot; PRIMARY KEY (&quot;Id&quot;)
);
</code></pre>
<p>Note that <code>CreatedBy</code> is nullable.
A shadow property with a reference type is optional unless you add <code>IsRequired()</code>, because there is no nullable reference type annotation to infer from.</p>
<p>Your <code>Order</code> entity stays focused on domain logic without audit infrastructure leaking in.</p>
<h2>When to Use Shadow Properties</h2>
<p>Shadow properties are most useful for:</p>
<ul>
<li><strong>Audit fields</strong> - <code>CreatedAt</code>, <code>UpdatedAt</code>, <code>CreatedBy</code> that every entity needs but aren't part of the domain</li>
<li><strong>Foreign keys</strong> - EF Core automatically creates shadow foreign keys for navigation properties</li>
<li><strong>Soft delete flags</strong> - <code>IsDeleted</code> columns used by <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core">query filters</a> but hidden from the entity</li>
<li><strong>Concurrency tokens</strong> - <code>RowVersion</code> or <code>xmin</code> columns for optimistic concurrency</li>
<li><strong>Tenant IDs</strong> - multi-tenancy discriminators that don't belong in the domain model</li>
</ul>
<h2>Automatic Shadow Properties</h2>
<p>EF Core creates shadow properties automatically for foreign keys when you define a navigation property without a corresponding foreign key property:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public Customer Customer { get; set; } // Navigation property
    // No CustomerId property defined
}
</code></pre>
<p>EF Core creates a shadow property named <code>CustomerId</code> of type <code>Guid</code>. You can see this in your <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices">migrations</a>:</p>
<pre><code class="language-sql">&quot;CustomerId&quot; uuid NOT NULL
</code></pre>
<p>You can configure the shadow foreign key explicitly:</p>
<pre><code class="language-csharp">builder.HasOne(o =&gt; o.Customer)
    .WithMany(c =&gt; c.Orders)
    .HasForeignKey(&quot;CustomerId&quot;);
</code></pre>
<h2>Accessing Shadow Properties</h2>
<p>Since shadow properties don't exist on the entity, you use <code>EF.Property&lt;T&gt;()</code> to access them in LINQ queries:</p>
<pre><code class="language-csharp">var recentOrders = await context.Orders
    .OrderByDescending(o =&gt; EF.Property&lt;DateTime&gt;(o, &quot;CreatedAt&quot;))
    .Take(10)
    .ToListAsync();
</code></pre>
<pre><code class="language-csharp">var ordersCreatedToday = await context.Orders
    .Where(o =&gt; EF.Property&lt;DateTime&gt;(o, &quot;CreatedAt&quot;) &gt;= DateTime.UtcNow.Date)
    .ToListAsync();
</code></pre>
<p>For reading or writing shadow properties on a specific entity, use the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core">change tracker</a>:</p>
<pre><code class="language-csharp">var entry = context.Entry(order);

// Read
var createdAt = entry.Property&lt;DateTime&gt;(&quot;CreatedAt&quot;).CurrentValue;

// Write
entry.Property&lt;DateTime&gt;(&quot;UpdatedAt&quot;).CurrentValue = DateTime.UtcNow;
</code></pre>
<h2>Audit Fields With Shadow Properties</h2>
<p>A common pattern is setting audit shadow properties automatically in <code>SaveChangesAsync</code>. This is where shadow properties really shine:</p>
<pre><code class="language-csharp">public class AppDbContext : DbContext
{
    public override async Task&lt;int&gt; SaveChangesAsync(
        CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;

        foreach (var entry in ChangeTracker.Entries())
        {
            if (entry.Metadata.FindProperty(&quot;CreatedAt&quot;) is null)
            {
                continue;
            }

            if (entry.State == EntityState.Added)
            {
                entry.Property(&quot;CreatedAt&quot;).CurrentValue = now;
                entry.Property(&quot;UpdatedAt&quot;).CurrentValue = now;
            }

            if (entry.State == EntityState.Modified)
            {
                entry.Property(&quot;UpdatedAt&quot;).CurrentValue = now;
            }
        }

        return await base.SaveChangesAsync(ct);
    }
}
</code></pre>
<p>Every entity gets <code>CreatedAt</code> and <code>UpdatedAt</code> without any of them knowing those fields exist. You could also use an <a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors">EF Core interceptor</a> for this.</p>
<h2>Applying Shadow Properties to All Entities</h2>
<p>Instead of configuring shadow properties on each entity individually, apply them in <code>OnModelCreating</code>:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        if (entityType.IsOwned())
        {
            continue;
        }

        modelBuilder.Entity(entityType.ClrType)
            .Property&lt;DateTime&gt;(&quot;CreatedAt&quot;)
            .HasDefaultValueSql(&quot;now()&quot;);

        modelBuilder.Entity(entityType.ClrType)
            .Property&lt;DateTime&gt;(&quot;UpdatedAt&quot;)
            .HasDefaultValueSql(&quot;now()&quot;);
    }
}
</code></pre>
<p>This ensures consistency across all entities. I skip <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd">owned types</a> because they're stored as part of their parent entity.</p>
<h2>Shadow Properties With Query Filters</h2>
<p>Shadow properties work well with <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core">global query filters</a> for multi-tenancy or soft delete:</p>
<pre><code class="language-csharp">// Define shadow property
builder.Property&lt;bool&gt;(&quot;IsDeleted&quot;).HasDefaultValue(false);

// Apply query filter
builder.HasQueryFilter(o =&gt; !EF.Property&lt;bool&gt;(o, &quot;IsDeleted&quot;));
</code></pre>
<p>Now deleted entities are filtered out of all queries automatically, and the <code>IsDeleted</code> flag isn't part of your domain model.</p>
<p>For soft delete, intercept the delete operation:</p>
<pre><code class="language-csharp">foreach (var entry in ChangeTracker.Entries())
{
    if (entry.State == EntityState.Deleted &amp;&amp;
        entry.Metadata.FindProperty(&quot;IsDeleted&quot;) is not null)
    {
        entry.State = EntityState.Modified;
        entry.Property(&quot;IsDeleted&quot;).CurrentValue = true;
    }
}
</code></pre>
<h2>Indexing Shadow Properties</h2>
<p>You can create indexes on shadow properties for query performance:</p>
<pre><code class="language-csharp">builder.HasIndex(&quot;CreatedAt&quot;);
builder.HasIndex(&quot;IsDeleted&quot;);

// Composite index
builder.HasIndex(&quot;IsDeleted&quot;, &quot;CreatedAt&quot;);
</code></pre>
<h2>Shadow Properties vs Regular Properties</h2>
<p>When should you use a shadow property instead of a regular property?</p>
<ul>
<li><strong>Audit timestamps</strong>: shadow property, unless your domain logic actually reads <code>CreatedAt</code> (for example, to enforce a cancellation window).</li>
<li><strong>Foreign keys</strong>: shadow by default. Promote to a regular property if you filter or join by the FK often - <code>EF.Property&lt;Guid&gt;(o, &quot;CustomerId&quot;)</code> everywhere gets old fast.</li>
<li><strong>Soft delete flags</strong>: shadow property, unless the domain has behavior attached to deletion (restore workflows, &quot;deleted by&quot; rules).</li>
<li><strong>Concurrency tokens</strong>: shadow property, unless domain logic compares versions explicitly.</li>
<li><strong>Domain-meaningful data</strong>: always a regular property. If the business talks about it, it belongs on the class.</li>
</ul>
<p>The rule is simple: if the property is infrastructure or persistence-only, make it a shadow property. If the domain model needs it, make it a regular property.</p>
<p>There's a middle ground worth knowing: <strong>backing fields</strong>.
If the domain needs the value internally but you don't want a public setter, map the column to a private field instead of using a shadow property.</p>
<h2>Gotchas to Watch Out For</h2>
<p>A few things that trip people up with shadow properties:</p>
<p><strong>Detached entities lose shadow values.</strong> Shadow property values live in the change tracker, not on the object. If you serialize an entity, send it to a client, and re-attach it later, the shadow values are gone. EF Core will treat them as unset.</p>
<p><strong>String-based access means no compiler safety.</strong> A typo in <code>EF.Property&lt;DateTime&gt;(o, &quot;CraetedAt&quot;)</code> fails at runtime, not at compile time. Keep the property names in constants if you access them in more than one place.</p>
<p><strong>They don't show up in projections automatically.</strong> If you need a shadow property value in a DTO, you must select it explicitly with <code>EF.Property&lt;T&gt;()</code> in the projection.</p>
<h2>Summary</h2>
<p>Shadow properties are useful for persistence metadata that should not become part of the domain API.
Define them through one convention, use constants for string-based access, and project them explicitly when callers need the value.
If the business gives a field meaning, make it a regular property instead of hiding it in the change tracker.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Owned Types in EF Core for DDD Value Objects]]></title>
            <link>https://milanjovanovic.tech/blog/owned-types-ef-core-ddd</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/owned-types-ef-core-ddd</guid>
            <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Value objects don't have identity. EF Core owned types let you persist them as part of the parent entity - no separate table, no separate ID.]]></description>
            <content:encoded><![CDATA[<p>Owned types are entity types that belong to a parent entity and carry no identity of their own in your domain model.
Configure one with <code>OwnsOne</code> and EF Core stores its properties as extra columns in the parent table, while <code>OwnsMany</code> stores a collection of them in a separate table.
That is how a DDD value object like <code>Money</code> or <code>Address</code> persists without an artificial ID.</p>
<p>A value object belongs to an aggregate because of what it represents, not because it has its own database identity.
Flattening it into the entity weakens the domain model, while giving it an artificial ID changes its semantics.</p>
<h2>Why Value Objects Need Special Mapping</h2>
<p><a href="https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals"><strong>Value objects</strong></a> are a core <strong>DDD</strong> building block. They have no identity - two <code>Money</code> objects with the same amount and currency are equal. But EF Core needs to store them in a relational database.</p>
<p>You have three options:</p>
<ol>
<li><strong>Owned types</strong> - stored as columns in the parent table (EF Core 2.0+)</li>
<li><a href="https://milanjovanovic.tech/blog/complex-types-ef-core"><strong>Complex types</strong></a> - similar but with stricter restrictions (EF Core 8+)</li>
<li><a href="https://milanjovanovic.tech/blog/value-conversions-ef-core"><strong>Value conversions</strong></a> - single-property value objects stored as one column</li>
</ol>
<p>Owned types are the most flexible approach.</p>
<img src="https://milanjovanovic.tech/blogs/articles/owned-types-ef-core-ddd/owned-types-storage.png" alt="An Order aggregate owning a Money value object stored as columns in the Orders table via OwnsOne, and a LineItems collection stored in a separate OrderLineItems table via OwnsMany">
<h2>Basic Owned Type</h2>
<p>Define the value object:</p>
<pre><code class="language-csharp">public sealed record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    private Money() { }

    public Money(decimal amount, string currency)
    {
        if (amount &lt; 0)
            throw new ArgumentException(&quot;Amount cannot be negative.&quot;, nameof(amount));
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException(&quot;Currency is required.&quot;, nameof(currency));

        Amount = amount;
        Currency = currency;
    }

    public static Money Zero(string currency) =&gt; new(0, currency);
}
</code></pre>
<p>Configure as owned:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.HasKey(x =&gt; x.Id);

        builder.OwnsOne(x =&gt; x.TotalAmount, money =&gt;
        {
            money.Property(m =&gt; m.Amount)
                .HasColumnName(&quot;TotalAmount&quot;)
                .HasPrecision(18, 2);

            money.Property(m =&gt; m.Currency)
                .HasColumnName(&quot;TotalCurrency&quot;)
                .HasMaxLength(3);
        });
    }
}
</code></pre>
<p>This stores <code>Money</code> as two columns in the <code>Orders</code> table:</p>
<pre><code class="language-sql">CREATE TABLE &quot;Orders&quot; (
    &quot;Id&quot; uuid NOT NULL,
    &quot;TotalAmount&quot; numeric(18,2) NOT NULL,
    &quot;TotalCurrency&quot; varchar(3) NOT NULL
);
</code></pre>
<p>No separate table. No separate ID. The value object lives inside its parent.</p>
<h2>Address Value Object</h2>
<p>A common example with multiple properties:</p>
<pre><code class="language-csharp">public sealed record Address
{
    public string Street { get; init; }
    public string City { get; init; }
    public string State { get; init; }
    public string ZipCode { get; init; }
    public string Country { get; init; }

    private Address() { }

    public Address(
        string street, string city, string state,
        string zipCode, string country)
    {
        Street = street;
        City = city;
        State = state;
        ZipCode = zipCode;
        Country = country;
    }
}
</code></pre>
<p>Configure:</p>
<pre><code class="language-csharp">builder.OwnsOne(x =&gt; x.ShippingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street)
        .HasColumnName(&quot;ShippingStreet&quot;)
        .HasMaxLength(200);

    address.Property(a =&gt; a.City)
        .HasColumnName(&quot;ShippingCity&quot;)
        .HasMaxLength(100);

    address.Property(a =&gt; a.State)
        .HasColumnName(&quot;ShippingState&quot;)
        .HasMaxLength(50);

    address.Property(a =&gt; a.ZipCode)
        .HasColumnName(&quot;ShippingZipCode&quot;)
        .HasMaxLength(20);

    address.Property(a =&gt; a.Country)
        .HasColumnName(&quot;ShippingCountry&quot;)
        .HasMaxLength(100);
});
</code></pre>
<h2>Multiple Owned Types of the Same Type</h2>
<p>An order might have both a shipping and billing address:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; private set; }
    public Address ShippingAddress { get; private set; }
    public Address BillingAddress { get; private set; }
}
</code></pre>
<p>Configure each one separately:</p>
<pre><code class="language-csharp">builder.OwnsOne(x =&gt; x.ShippingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street).HasColumnName(&quot;ShippingStreet&quot;);
    address.Property(a =&gt; a.City).HasColumnName(&quot;ShippingCity&quot;);
    // ...
});

builder.OwnsOne(x =&gt; x.BillingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street).HasColumnName(&quot;BillingStreet&quot;);
    address.Property(a =&gt; a.City).HasColumnName(&quot;BillingCity&quot;);
    // ...
});
</code></pre>
<p>Result:</p>
<pre><code class="language-sql">CREATE TABLE &quot;Orders&quot; (
    &quot;Id&quot; uuid NOT NULL,
    &quot;ShippingStreet&quot; varchar(200),
    &quot;ShippingCity&quot; varchar(100),
    &quot;BillingStreet&quot; varchar(200),
    &quot;BillingCity&quot; varchar(100),
    -- ...
);
</code></pre>
<h2>Owned Collections (Separate Table)</h2>
<p>For collections of value objects, use <code>OwnsMany</code>:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; private set; }
    private readonly List&lt;LineItem&gt; _lineItems = [];
    public IReadOnlyCollection&lt;LineItem&gt; LineItems =&gt; _lineItems;
}

public sealed record LineItem
{
    public Guid ProductId { get; init; }
    public int Quantity { get; init; }
    public Money UnitPrice { get; init; }
}
</code></pre>
<pre><code class="language-csharp">builder.OwnsMany(x =&gt; x.LineItems, lineItem =&gt;
{
    lineItem.ToTable(&quot;OrderLineItems&quot;);

    lineItem.WithOwner().HasForeignKey(&quot;OrderId&quot;);

    lineItem.Property(li =&gt; li.ProductId);
    lineItem.Property(li =&gt; li.Quantity);

    lineItem.OwnsOne(li =&gt; li.UnitPrice, money =&gt;
    {
        money.Property(m =&gt; m.Amount)
            .HasColumnName(&quot;UnitPrice&quot;)
            .HasPrecision(18, 2);
        money.Property(m =&gt; m.Currency)
            .HasColumnName(&quot;Currency&quot;)
            .HasMaxLength(3);
    });
});
</code></pre>
<p><code>OwnsMany</code> creates a separate table because you can't flatten a collection into columns.</p>
<h2>Nullable Owned Types</h2>
<p>With nullable reference types enabled, a non-nullable owned navigation is required by default (EF Core 6+).
To make a value object optional, declare the property as nullable (<code>Address?</code>) or configure the navigation explicitly:</p>
<pre><code class="language-csharp">builder.OwnsOne(x =&gt; x.ShippingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street).HasColumnName(&quot;ShippingStreet&quot;);
    // ...
});

builder.Navigation(x =&gt; x.ShippingAddress).IsRequired(false);
</code></pre>
<p>When <code>ShippingAddress</code> is null, all its columns will be null in the database.</p>
<h2>Querying Owned Types</h2>
<p>Access owned types in LINQ queries:</p>
<pre><code class="language-csharp">// Filter by owned type property
var expensiveOrders = await _db.Orders
    .Where(o =&gt; o.TotalAmount.Amount &gt; 1000)
    .ToListAsync();

// Project owned type properties
var orderSummaries = await _db.Orders
    .Select(o =&gt; new
    {
        o.Id,
        Total = o.TotalAmount.Amount,
        Currency = o.TotalAmount.Currency,
        City = o.ShippingAddress.City
    })
    .ToListAsync();
</code></pre>
<p>EF Core translates these to SQL column access - no joins needed.</p>
<h2>Owned Types vs Complex Types vs Value Conversions</h2>
<p>How the three approaches compare (as of EF Core 8):</p>
<ul>
<li><strong>Multiple properties</strong>: owned types and complex types support them; value conversions handle a single property only</li>
<li><strong>Nullability</strong>: owned types and value conversions can be optional; EF Core 8 complex types cannot (EF Core 10 lifted this)</li>
<li><strong>Collections</strong>: only owned types support them, via <code>OwnsMany</code></li>
<li><strong>Nesting</strong>: owned types and complex types can nest other value objects; conversions can't</li>
<li><strong>Table placement</strong>: owned types can move to a separate table; complex types always share the parent table</li>
<li><strong>Hidden key</strong>: owned types carry a shadow key under the hood; complex types have none</li>
</ul>
<p>Use <strong>value conversions</strong> for single-property value objects like <code>Email</code> or <code>PhoneNumber</code>. Use <strong>owned types</strong> for multi-property value objects like <code>Money</code> or <code>Address</code>. Use <strong>complex types</strong> when you want the same behavior without shadow keys.</p>
<p>I walk through a complete aggregate persisted this way in <strong>value objects with EF Core</strong>.</p>
<h2>One Gotcha: Owned Types Are Still Entities</h2>
<p>Under the hood, EF Core treats an owned type as an entity with a hidden shadow key tied to its owner.
That leaks in a few places:</p>
<ul>
<li>Two owned instances with identical values are <strong>not</strong> interchangeable to EF Core the way true value objects are - replacing one is an update to the owner's row</li>
<li><code>OwnsMany</code> rows are keyed by the owner plus a synthetic ID, so reordering a collection can generate more SQL than you expect</li>
<li>Sharing the same value object <strong>instance</strong> between two owners throws, because an owned instance can only belong to one owner</li>
</ul>
<p>None of these are blockers, but they explain the occasional surprising <code>UPDATE</code> statement in your logs.</p>
<h2>Summary</h2>
<p>Use <code>OwnsOne</code> or <code>OwnsMany</code> when a value cannot exist independently from its aggregate owner.
Configure nullability, column names, and table placement explicitly so persistence details do not blur the value object's semantics.
On EF Core 10, also consider complex types when you want value semantics without the hidden identity of an owned entity.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Configuring Entity Relationships in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/entity-relationships-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/entity-relationships-ef-core</guid>
            <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[One-to-one, one-to-many, many-to-many - EF Core supports them all. Here is how to configure entity relationships properly with Fluent API and conventions.]]></description>
            <content:encoded><![CDATA[<p>EF Core infers simple relationships by convention, and the Fluent API configures the rest: <code>HasMany().WithOne()</code> for one-to-many, <code>HasOne().WithOne().HasForeignKey&lt;TDependent&gt;()</code> for one-to-one, and <code>HasMany().WithMany()</code> for many-to-many.
Ambiguity around which side holds the foreign key or what a delete does becomes a schema decision whether you intended it or not.
This article walks through each relationship type, owned types, indexes, and delete behaviors.</p>
<h2>Why Fluent API Over Conventions?</h2>
<p>An entity relationship in EF Core is a link between two entity types, made up of a foreign key on the dependent side, the navigation properties you use to traverse it, and a delete behavior.</p>
<p><a href="https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started"><strong>EF Core</strong></a> can infer relationships from conventions, but Fluent API gives you explicit control.
The <code>IEntityTypeConfiguration&lt;T&gt;</code> pattern keeps that configuration explicit and separated by entity:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.ToTable(&quot;orders&quot;);
        builder.HasKey(o =&gt; o.Id);

        // Relationships defined here
    }
}
</code></pre>
<p>Register all configurations:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(ApplicationDbContext).Assembly);
}
</code></pre>
<p>The relationships this article configures, and how they connect, look like this:</p>
<img src="https://milanjovanovic.tech/blogs/articles/entity-relationships-ef-core/relationship-types.png" alt="Entity relationship diagram showing a one-to-many between Order and LineItem, a one-to-one between User and UserProfile, a many-to-many between Student and Course through an Enrollment join entity, and a self-referencing Employee manages Employee relationship">
<p>The same self-reference can represent an arbitrary tree; <a href="https://milanjovanovic.tech/blog/ef-core-hierarchical-data"><strong>hierarchical data in EF Core</strong></a> covers recursive reads, deletes, and larger-tree alternatives.</p>
<h2>One-to-Many</h2>
<p>The most common relationship. An order has many line items:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public string CustomerName { get; set; }
    public DateTime CreatedAt { get; set; }
    public List&lt;LineItem&gt; LineItems { get; set; } = [];
}

public class LineItem
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }      // FK
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public Order Order { get; set; }        // Navigation
}
</code></pre>
<p>Configure with Fluent API:</p>
<pre><code class="language-csharp">public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        builder.HasMany(o =&gt; o.LineItems)
            .WithOne(li =&gt; li.Order)
            .HasForeignKey(li =&gt; li.OrderId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}
</code></pre>
<p><code>Cascade</code> means deleting an order also deletes its line items. Be intentional about this - sometimes you want <code>Restrict</code> or <code>SetNull</code> instead.</p>
<h3>Without Navigation on the Child</h3>
<p>If you don't want a navigation property from <code>LineItem</code> back to <code>Order</code>:</p>
<pre><code class="language-csharp">public class LineItem
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }  // FK only, no nav property
    public string ProductName { get; set; }
}

// Configuration
builder.HasMany(o =&gt; o.LineItems)
    .WithOne()
    .HasForeignKey(li =&gt; li.OrderId);
</code></pre>
<h2>One-to-One</h2>
<p>A user has one profile:</p>
<pre><code class="language-csharp">public class User
{
    public Guid Id { get; set; }
    public string Email { get; set; }
    public UserProfile? Profile { get; set; }
}

public class UserProfile
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string DisplayName { get; set; }
    public string? Bio { get; set; }
    public User User { get; set; }
}
</code></pre>
<pre><code class="language-csharp">public class UserConfiguration : IEntityTypeConfiguration&lt;User&gt;
{
    public void Configure(EntityTypeBuilder&lt;User&gt; builder)
    {
        builder.HasOne(u =&gt; u.Profile)
            .WithOne(p =&gt; p.User)
            .HasForeignKey&lt;UserProfile&gt;(p =&gt; p.UserId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}
</code></pre>
<p>You must specify which side holds the foreign key with <code>HasForeignKey&lt;UserProfile&gt;</code>.</p>
<h2>Many-to-Many</h2>
<p>A student enrolls in many courses. A course has many students:</p>
<pre><code class="language-csharp">public class Student
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public List&lt;Course&gt; Courses { get; set; } = [];
}

public class Course
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public List&lt;Student&gt; Students { get; set; } = [];
}
</code></pre>
<p>EF Core 5+ creates the join table automatically:</p>
<pre><code class="language-csharp">builder.HasMany(s =&gt; s.Courses)
    .WithMany(c =&gt; c.Students)
    .UsingEntity(j =&gt; j.ToTable(&quot;student_courses&quot;));
</code></pre>
<h3>Many-to-Many With Payload</h3>
<p>When the join table needs extra columns (like enrollment date):</p>
<pre><code class="language-csharp">public class Enrollment
{
    public Guid StudentId { get; set; }
    public Guid CourseId { get; set; }
    public DateTime EnrolledAt { get; set; }
    public Grade? Grade { get; set; }
    public Student Student { get; set; }
    public Course Course { get; set; }
}

public enum Grade
{
    A, B, C, D, F
}
</code></pre>
<pre><code class="language-csharp">public class EnrollmentConfiguration
    : IEntityTypeConfiguration&lt;Enrollment&gt;
{
    public void Configure(EntityTypeBuilder&lt;Enrollment&gt; builder)
    {
        builder.ToTable(&quot;enrollments&quot;);

        builder.HasKey(e =&gt; new { e.StudentId, e.CourseId });

        builder.HasOne(e =&gt; e.Student)
            .WithMany(s =&gt; s.Enrollments)
            .HasForeignKey(e =&gt; e.StudentId);

        builder.HasOne(e =&gt; e.Course)
            .WithMany(c =&gt; c.Enrollments)
            .HasForeignKey(e =&gt; e.CourseId);
    }
}
</code></pre>
<p>Now <code>Student</code> and <code>Course</code> reference <code>Enrollment</code> instead of each other directly:</p>
<pre><code class="language-csharp">public class Student
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public List&lt;Enrollment&gt; Enrollments { get; set; } = [];
}
</code></pre>
<h2>Self-Referencing Relationship</h2>
<p>An employee has a manager (who is also an employee):</p>
<pre><code class="language-csharp">public class Employee
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public Guid? ManagerId { get; set; }
    public Employee? Manager { get; set; }
    public List&lt;Employee&gt; DirectReports { get; set; } = [];
}
</code></pre>
<pre><code class="language-csharp">builder.HasOne(e =&gt; e.Manager)
    .WithMany(e =&gt; e.DirectReports)
    .HasForeignKey(e =&gt; e.ManagerId)
    .OnDelete(DeleteBehavior.Restrict);
</code></pre>
<p>Use <code>Restrict</code> here - you don't want cascading deletes up the org chart.</p>
<h2>Owned Types (Value Objects)</h2>
<p>For <a href="https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals"><strong>value objects</strong></a>, use owned types:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public Address ShippingAddress { get; set; }
    public Money TotalAmount { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string ZipCode { get; set; }
    public string Country { get; set; }
}

public sealed record Money(decimal Amount, string Currency);
</code></pre>
<pre><code class="language-csharp">builder.OwnsOne(o =&gt; o.ShippingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street).HasColumnName(&quot;shipping_street&quot;);
    address.Property(a =&gt; a.City).HasColumnName(&quot;shipping_city&quot;);
    address.Property(a =&gt; a.ZipCode).HasColumnName(&quot;shipping_zip&quot;);
    address.Property(a =&gt; a.Country).HasColumnName(&quot;shipping_country&quot;);
});

builder.OwnsOne(o =&gt; o.TotalAmount, money =&gt;
{
    money.Property(m =&gt; m.Amount).HasColumnName(&quot;total_amount&quot;);
    money.Property(m =&gt; m.Currency).HasColumnName(&quot;total_currency&quot;);
});
</code></pre>
<p>Owned types are stored in the same table as the parent entity - no joins needed.
I cover them in depth (including collections and DDD usage) in <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd"><strong>owned types for DDD value objects</strong></a>.</p>
<h2>Loading Related Data</h2>
<p>Configuring a relationship is half the story; the other half is how you load it.
Your options are eager loading with <code>Include</code>, explicit loading, and lazy loading - each with different query patterns and pitfalls.
I compare them in <a href="https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core"><strong>lazy vs eager vs explicit loading</strong></a>.</p>
<p>One tip that belongs here: when you <code>Include</code> multiple collections, watch out for cartesian explosion, and reach for <a href="https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting"><strong>query splitting</strong></a> when result sets multiply.</p>
<h2>Indexes</h2>
<p>EF Core creates an index on foreign key columns by convention, so <code>OrderId</code> is already covered.
Add indexes for the columns your queries filter and sort on:</p>
<pre><code class="language-csharp">builder.HasIndex(o =&gt; o.CreatedAt);
builder.HasIndex(o =&gt; o.CustomerName);

// Unique index
builder.HasIndex(u =&gt; u.Email).IsUnique();

// Composite index
builder.HasIndex(e =&gt; new { e.CourseId, e.EnrolledAt });
</code></pre>
<h2>Delete Behaviors</h2>
<ul>
<li><strong><code>Cascade</code></strong>: deleting the parent also deletes its children</li>
<li><strong><code>Restrict</code></strong>: deleting a parent with children throws an exception</li>
<li><strong><code>SetNull</code></strong>: deleting the parent sets the FK to null on the children (requires a nullable FK)</li>
<li><strong><code>ClientSetNull</code></strong>: like SetNull, but only for children the context is currently tracking; untracked children cause a database FK violation</li>
<li><strong><code>NoAction</code></strong>: the database decides (engine-dependent)</li>
</ul>
<p>The defaults are <code>Cascade</code> for required relationships and <code>ClientSetNull</code> for optional ones.
Be explicit about what you want - especially in PostgreSQL and SQL Server, where accidental cascades on big tables hurt.</p>
<h2>Summary</h2>
<p>Use conventions for unambiguous relationships and Fluent API where the foreign key, principal side, or delete behavior needs to be explicit.
Model a many-to-many join as an entity as soon as the relationship carries data of its own.
Review the generated constraints and indexes because the relationship is not complete until the database enforces the same intent.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Complex Types in EF Core 8: What You Need to Know]]></title>
            <link>https://milanjovanovic.tech/blog/complex-types-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/complex-types-ef-core</guid>
            <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Complex types map value objects with no identity into the owner's table. In EF Core 8 they were always required, and EF Core 10 added optional complex…]]></description>
            <content:encoded><![CDATA[<p><strong>Complex types</strong> in EF Core 8 map value objects, types with no identity of their own, directly into the owner's table as regular columns.
They have no key and no navigation properties, so EF treats them as values rather than entities.
In EF Core 8 a complex property is always required, and EF Core 10 added optional complex properties plus JSON mapping with collection support.</p>
<p>Value objects should be modeled by their value, not forced to pretend they have an identity.
Here is how to configure, query, and update them, and what changed in EF Core 9 and 10.</p>
<h2>What Are Complex Types?</h2>
<p>Complex types model value objects, which are types defined by their properties rather than an identity.
In the original EF Core 8 implementation, unlike <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd"><strong>owned types</strong></a>, complex types:</p>
<ul>
<li>Cannot be null</li>
<li>Cannot have a primary key</li>
<li>Cannot reference other entities (no navigation properties)</li>
<li>Always live inside the parent entity's table</li>
</ul>
<p>They map cleanly to the DDD concept of a <a href="https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals"><strong>value object</strong></a>.</p>
<h2>Defining Complex Types</h2>
<pre><code class="language-csharp">public sealed record Address(
    string Street,
    string City,
    string State,
    string ZipCode,
    string Country);

public sealed record Money(decimal Amount, string Currency);

public class Order
{
    public Guid Id { get; private set; }
    public Address ShippingAddress { get; set; } = null!;
    public Money TotalAmount { get; set; } = null!;
    public DateTime CreatedAt { get; private set; }
}
</code></pre>
<h2>Configuration</h2>
<h3>Fluent API</h3>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Order&gt;(builder =&gt;
{
    builder.HasKey(o =&gt; o.Id);

    builder.ComplexProperty(o =&gt; o.ShippingAddress, address =&gt;
    {
        address.Property(a =&gt; a.Street)
            .HasColumnName(&quot;shipping_street&quot;)
            .HasMaxLength(200);

        address.Property(a =&gt; a.City)
            .HasColumnName(&quot;shipping_city&quot;)
            .HasMaxLength(100);

        address.Property(a =&gt; a.State)
            .HasColumnName(&quot;shipping_state&quot;)
            .HasMaxLength(100);

        address.Property(a =&gt; a.ZipCode)
            .HasColumnName(&quot;shipping_zip&quot;)
            .HasMaxLength(20);

        address.Property(a =&gt; a.Country)
            .HasColumnName(&quot;shipping_country&quot;)
            .HasMaxLength(100);
    });

    builder.ComplexProperty(o =&gt; o.TotalAmount, money =&gt;
    {
        money.Property(m =&gt; m.Amount)
            .HasColumnName(&quot;total_amount&quot;)
            .HasPrecision(18, 2);

        money.Property(m =&gt; m.Currency)
            .HasColumnName(&quot;total_currency&quot;)
            .HasMaxLength(3);
    });
});
</code></pre>
<h3>Generated Table</h3>
<pre><code class="language-sql">CREATE TABLE orders (
    id UUID PRIMARY KEY,
    shipping_street VARCHAR(200) NOT NULL,
    shipping_city VARCHAR(100) NOT NULL,
    shipping_state VARCHAR(100) NOT NULL,
    shipping_zip VARCHAR(20) NOT NULL,
    shipping_country VARCHAR(100) NOT NULL,
    total_amount DECIMAL(18,2) NOT NULL,
    total_currency VARCHAR(3) NOT NULL,
    created_at TIMESTAMP NOT NULL
);
</code></pre>
<p>The columns are <strong>NOT NULL</strong> because complex types cannot be null.</p>
<img src="https://milanjovanovic.tech/blogs/articles/complex-types-ef-core/complex-type-flattening.png" alt="An Order entity holding Address and Money value objects maps to a single orders table where the value object properties are flattened into shipping_ and total_ columns on one flat row, with no joins.">
<h2>Nested Complex Types</h2>
<p>Complex types can contain other complex types:</p>
<pre><code class="language-csharp">public sealed record Coordinates(double Latitude, double Longitude);

public sealed record Address(
    string Street,
    string City,
    string State,
    string ZipCode,
    string Country,
    Coordinates Location);
</code></pre>
<pre><code class="language-csharp">builder.ComplexProperty(o =&gt; o.ShippingAddress, address =&gt;
{
    address.Property(a =&gt; a.Street).HasColumnName(&quot;shipping_street&quot;);
    address.Property(a =&gt; a.City).HasColumnName(&quot;shipping_city&quot;);
    // ...

    address.ComplexProperty(a =&gt; a.Location, location =&gt;
    {
        location.Property(l =&gt; l.Latitude).HasColumnName(&quot;shipping_lat&quot;);
        location.Property(l =&gt; l.Longitude).HasColumnName(&quot;shipping_lng&quot;);
    });
});
</code></pre>
<p>All properties are still flattened into the same table.</p>
<h2>Querying</h2>
<p>You can filter by complex type properties just like regular columns:</p>
<pre><code class="language-csharp">// Find orders shipping to a specific city
var orders = await db.Orders
    .Where(o =&gt; o.ShippingAddress.City == &quot;London&quot;)
    .ToListAsync();

// Find orders over a certain amount
var largeOrders = await db.Orders
    .Where(o =&gt; o.TotalAmount.Amount &gt; 1000 &amp;&amp;
                o.TotalAmount.Currency == &quot;USD&quot;)
    .ToListAsync();
</code></pre>
<p>EF Core translates this to standard SQL:</p>
<pre><code class="language-sql">SELECT * FROM orders
WHERE shipping_city = 'London';

SELECT * FROM orders
WHERE total_amount &gt; 1000 AND total_currency = 'USD';
</code></pre>
<h2>Updating Complex Types</h2>
<p>Replace the entire value object (the records here are immutable):</p>
<pre><code class="language-csharp">// ✅ Replace the whole value object
order.ShippingAddress = new Address(
    &quot;456 Oak Ave&quot;,
    order.ShippingAddress.City,
    order.ShippingAddress.State,
    order.ShippingAddress.ZipCode,
    order.ShippingAddress.Country,
    order.ShippingAddress.Location);

await db.SaveChangesAsync();
</code></pre>
<p>With records, use the <code>with</code> expression:</p>
<pre><code class="language-csharp">order.ShippingAddress = order.ShippingAddress with { Street = &quot;456 Oak Ave&quot; };
await db.SaveChangesAsync();
</code></pre>
<p>EF Core detects which properties changed and only updates those columns.</p>
<h2>Complex Types vs Owned Types</h2>
<p>Here's how the two compare, feature by feature (in EF Core 8):</p>
<ul>
<li><strong>Nullability</strong>: complex types can never be null; owned types can be optional</li>
<li><strong>Identity</strong>: complex types have no key at all; owned types carry a hidden shadow key</li>
<li><strong>Navigation properties</strong>: complex types can't reference entities; owned types can</li>
<li><strong>Separate table</strong>: complex types are always inlined into the parent table; owned types can move to their own table with <code>ToTable()</code></li>
<li><strong>Collections</strong>: complex types don't support collections; owned types do via <code>OwnsMany</code></li>
<li><strong>Version support</strong>: complex types need EF Core 8+; owned types have existed since EF Core 2.0</li>
<li><strong>Semantics</strong>: complex types are true value types; owned types are &quot;entities pretending to be values&quot;</li>
</ul>
<p>I go deeper on the owned-type approach in <strong>value objects with EF Core</strong>.</p>
<h3>When to Choose Complex Types</h3>
<ul>
<li>You want <strong>value semantics</strong> with no identity or navigation</li>
<li>You want the value flattened into the owner's table or, on EF Core 10, mapped to a JSON column</li>
<li>You are on EF Core 8 or later and the version supports the nullability and collection shape you need</li>
</ul>
<h3>When to Choose Owned Types</h3>
<ul>
<li>You need navigation properties to other entities</li>
<li>You want to store the value in a <strong>separate table</strong></li>
<li>You need an owned collection mapped to its own table</li>
<li>You are on EF Core 2-7</li>
</ul>
<h2>Multiple Entities With the Same Complex Type</h2>
<pre><code class="language-csharp">public sealed record FullName(string First, string Last);

public class Customer
{
    public Guid Id { get; set; }
    public FullName Name { get; set; } = null!;
    public Address BillingAddress { get; set; } = null!;
}

public class Supplier
{
    public Guid Id { get; set; }
    public FullName ContactName { get; set; } = null!;
    public Address WarehouseAddress { get; set; } = null!;
}
</code></pre>
<p>Configure each independently:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Customer&gt;(builder =&gt;
{
    builder.ComplexProperty(c =&gt; c.Name, name =&gt;
    {
        name.Property(n =&gt; n.First).HasColumnName(&quot;first_name&quot;);
        name.Property(n =&gt; n.Last).HasColumnName(&quot;last_name&quot;);
    });
    builder.ComplexProperty(c =&gt; c.BillingAddress, addr =&gt;
    {
        addr.Property(a =&gt; a.Street).HasColumnName(&quot;billing_street&quot;);
        addr.Property(a =&gt; a.City).HasColumnName(&quot;billing_city&quot;);
    });
});

modelBuilder.Entity&lt;Supplier&gt;(builder =&gt;
{
    builder.ComplexProperty(s =&gt; s.ContactName, name =&gt;
    {
        name.Property(n =&gt; n.First).HasColumnName(&quot;contact_first_name&quot;);
        name.Property(n =&gt; n.Last).HasColumnName(&quot;contact_last_name&quot;);
    });
    builder.ComplexProperty(s =&gt; s.WarehouseAddress, addr =&gt;
    {
        addr.Property(a =&gt; a.Street).HasColumnName(&quot;warehouse_street&quot;);
        addr.Property(a =&gt; a.City).HasColumnName(&quot;warehouse_city&quot;);
    });
});
</code></pre>
<h2>Limitations</h2>
<p>Complex types in EF Core 8 have restrictions:</p>
<ol>
<li><strong>Cannot be null</strong> - use owned types if nullability is needed</li>
<li><strong>No collections</strong> - use <code>OwnsMany</code> for lists of value objects</li>
<li><strong>No lazy loading</strong> - they're always loaded with the parent</li>
<li><strong>No separate table</strong> - always in the same table as the entity</li>
<li><strong>Equality not used by EF Core</strong> - change detection is property-by-property, not structural equality</li>
</ol>
<h2>What Changed in EF Core 9 and 10</h2>
<p>Complex types were a v1 feature in EF Core 8, and the team has been closing the gaps since:</p>
<ul>
<li><strong>EF Core 10</strong> added support for <strong>optional (nullable) complex properties</strong> when the complex type contains at least one required property</li>
<li><strong>EF Core 10</strong> made complex types the primary mechanism for <strong>mapping to JSON columns</strong>, which previously required owned types with <code>ToJson()</code></li>
<li>Complex type <strong>collections</strong> are supported when mapped to JSON</li>
</ul>
<p>If you're on .NET 10, the &quot;use owned types because complex types can't do X&quot; cases have mostly disappeared.
Check the EF Core release notes for your exact version before designing around an EF Core 8 limitation.</p>
<h2>Summary</h2>
<p>Complex types express values with no identity or navigation and keep their members queryable through LINQ.
EF Core 8 maps required values into the owner's table; EF Core 10 adds optional complex properties and JSON mapping that can contain collections.
Use owned types when you need an entity relationship, a separate table, or support on an older EF Core version.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Seeding Data in EF Core: Strategies and Best Practices]]></title>
            <link>https://milanjovanovic.tech/blog/seeding-data-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/seeding-data-ef-core</guid>
            <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core offers several approaches for seeding data - from HasData in model configuration to custom initialization logic and SQL scripts.]]></description>
            <content:encoded><![CDATA[<p>EF Core gives you several seeding mechanisms, and the right one depends on the data.
Use <code>HasData</code> for small, static lookup tables with fixed primary keys.
Reach for <code>UseSeeding</code> and <code>UseAsyncSeeding</code> (EF Core 9+) or a custom initializer when the data needs domain logic and relationships.
For large reference datasets, run raw SQL inside a migration.</p>
<p>Reference data, development fixtures, and production bootstrap data have different lifecycles.
Using one seeding mechanism for all three creates non-deterministic migrations or lets sample data leak into production.</p>
<h2>Why Seed Data?</h2>
<p><strong>Seeding</strong> is inserting a known set of starting rows into the database so the application has the data it needs to run.
Every application needs some initial data: lookup tables, default roles, configuration records, test data for development.
Without a consistent seeding strategy, developers end up with manual SQL scripts scattered across the team or databases in unpredictable states.</p>
<p>EF Core provides built-in seeding through <code>HasData</code>, but that's just one option. The right strategy depends on what you're seeding and when.</p>
<img src="https://milanjovanovic.tech/blogs/articles/seeding-data-ef-core/seeding-strategy-decision.png" alt="A decision tree choosing a seeding strategy: HasData for static lookups with fixed keys, UseSeeding or a custom initializer when domain logic and relationships are needed, and raw SQL in a migration for large reference datasets">
<h2>HasData: Built-in Model Seeding</h2>
<p>The <code>HasData</code> method in your entity configuration tells EF Core to include seed data in <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices">migrations</a>:</p>
<pre><code class="language-csharp">public class OrderStatusConfiguration : IEntityTypeConfiguration&lt;OrderStatus&gt;
{
    public void Configure(EntityTypeBuilder&lt;OrderStatus&gt; builder)
    {
        builder.HasKey(x =&gt; x.Id);

        builder.Property(x =&gt; x.Name).HasMaxLength(50);

        builder.HasData(
            new OrderStatus { Id = 1, Name = &quot;Draft&quot; },
            new OrderStatus { Id = 2, Name = &quot;Confirmed&quot; },
            new OrderStatus { Id = 3, Name = &quot;Shipped&quot; },
            new OrderStatus { Id = 4, Name = &quot;Delivered&quot; },
            new OrderStatus { Id = 5, Name = &quot;Cancelled&quot; });
    }
}
</code></pre>
<p>When you create a migration, EF Core generates <code>InsertData</code> operations:</p>
<pre><code class="language-csharp">migrationBuilder.InsertData(
    table: &quot;OrderStatuses&quot;,
    columns: new[] { &quot;Id&quot;, &quot;Name&quot; },
    values: new object[,]
    {
        { 1, &quot;Draft&quot; },
        { 2, &quot;Confirmed&quot; },
        { 3, &quot;Shipped&quot; },
        { 4, &quot;Delivered&quot; },
        { 5, &quot;Cancelled&quot; }
    });
</code></pre>
<h3>HasData Limitations</h3>
<p><code>HasData</code> has strict rules:</p>
<ul>
<li><strong>Primary keys are required</strong> - you must specify the key value for every seeded entity. No auto-generated keys.</li>
<li><strong>No navigation properties</strong> - you can't set related entities directly. Use foreign key values instead.</li>
<li><strong>Tracked by migrations</strong> - any change to seed data generates a new migration.</li>
<li><strong>No access to services or configuration</strong> - the values are baked into the model, so you can't read from IConfiguration or hash a password with an injected service.</li>
</ul>
<p>That last one bites people seeding an admin user: you can't call your password hasher from <code>HasData</code>.
That's a job for custom seeding code.</p>
<p>For <a href="https://milanjovanovic.tech/blog/entity-relationships-ef-core">entity relationships</a>, seed related entities separately:</p>
<pre><code class="language-csharp">builder.HasData(
    new Role { Id = 1, Name = &quot;Admin&quot; });

// In a separate configuration
permissionBuilder.HasData(
    new Permission { Id = 1, Name = &quot;users.read&quot;, RoleId = 1 },
    new Permission { Id = 2, Name = &quot;users.write&quot;, RoleId = 1 });
</code></pre>
<h2>UseSeeding and UseAsyncSeeding (EF Core 9+)</h2>
<p>EF Core 9 added a first-class hook for custom seed logic: <code>UseSeeding</code> and <code>UseAsyncSeeding</code> on the options builder.</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options
        .UseNpgsql(connectionString)
        .UseAsyncSeeding(async (context, _, ct) =&gt;
        {
            var hasRoles = await context.Set&lt;Role&gt;().AnyAsync(ct);
            if (hasRoles)
            {
                return;
            }

            context.Set&lt;Role&gt;().AddRange(
                new Role(&quot;Admin&quot;),
                new Role(&quot;User&quot;));

            await context.SaveChangesAsync(ct);
        }));
</code></pre>
<p>The seeding delegate runs as part of <code>EnsureCreated</code>, <code>Migrate</code>, and <code>dotnet ef database update</code>.
Unlike <code>HasData</code>, you get a live <code>DbContext</code>: navigation properties, domain methods, and conditional logic all work.</p>
<p>Two things to keep in mind:</p>
<ul>
<li>Implement <strong>both</strong> <code>UseSeeding</code> and <code>UseAsyncSeeding</code> if you mix sync and async database creation paths (EF only calls the one matching the API used).</li>
<li>The delegate runs every time migrations are applied, so the logic must be idempotent (more on that below).</li>
</ul>
<h2>Custom Initialization Logic</h2>
<p>For more complex seeding, run custom code after your <code>DbContext</code> is configured. I typically create a <code>DbInitializer</code> class:</p>
<pre><code class="language-csharp">public static class DbInitializer
{
    public static async Task SeedAsync(AppDbContext context)
    {
        if (await context.Roles.AnyAsync())
        {
            return; // Already seeded
        }

        var adminRole = new Role(&quot;Admin&quot;);
        adminRole.AddPermission(&quot;users.read&quot;);
        adminRole.AddPermission(&quot;users.write&quot;);
        adminRole.AddPermission(&quot;orders.manage&quot;);

        var userRole = new Role(&quot;User&quot;);
        userRole.AddPermission(&quot;users.read&quot;);

        context.Roles.AddRange(adminRole, userRole);

        await context.SaveChangesAsync();
    }
}
</code></pre>
<p>Call it during application startup:</p>
<pre><code class="language-csharp">var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
    await DbInitializer.SeedAsync(context);
}

app.Run();
</code></pre>
<p>This approach lets you use navigation properties, domain logic, and computed values. It also works well with <a href="https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals">DDD patterns</a> where entities have private setters and factory methods.</p>
<h2>Idempotent Seeding</h2>
<p>Seed logic must be idempotent - running it multiple times should produce the same result. There are several patterns for this:</p>
<h3>Check-Before-Insert</h3>
<pre><code class="language-csharp">public static async Task SeedCurrenciesAsync(AppDbContext context)
{
    var existing = await context.Currencies
        .Select(c =&gt; c.Code)
        .ToHashSetAsync();

    var currencies = new List&lt;Currency&gt;
    {
        new(&quot;USD&quot;, &quot;US Dollar&quot;),
        new(&quot;EUR&quot;, &quot;Euro&quot;),
        new(&quot;GBP&quot;, &quot;British Pound&quot;)
    };

    var newCurrencies = currencies
        .Where(c =&gt; !existing.Contains(c.Code))
        .ToList();

    if (newCurrencies.Count &gt; 0)
    {
        context.Currencies.AddRange(newCurrencies);
        await context.SaveChangesAsync();
    }
}
</code></pre>
<h3>Upsert Pattern</h3>
<p>For data that might change between deployments:</p>
<pre><code class="language-csharp">public static async Task SeedConfigAsync(AppDbContext context)
{
    var configs = new Dictionary&lt;string, string&gt;
    {
        [&quot;MaxRetryCount&quot;] = &quot;3&quot;,
        [&quot;SessionTimeout&quot;] = &quot;30&quot;,
        [&quot;DefaultPageSize&quot;] = &quot;25&quot;
    };

    foreach (var (key, value) in configs)
    {
        var existing = await context.AppConfigs
            .FirstOrDefaultAsync(c =&gt; c.Key == key);

        if (existing is null)
        {
            context.AppConfigs.Add(new AppConfig(key, value));
        }
        else
        {
            existing.UpdateValue(value);
        }
    }

    await context.SaveChangesAsync();
}
</code></pre>
<h2>Migration-Based Seeding</h2>
<p>Sometimes you want seed data tied to a specific migration. Use the <code>Up</code> method of a migration to insert data:</p>
<pre><code class="language-csharp">public partial class AddDefaultCategories : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.InsertData(
            table: &quot;Categories&quot;,
            columns: new[] { &quot;Id&quot;, &quot;Name&quot;, &quot;SortOrder&quot; },
            values: new object[,]
            {
                { new Guid(&quot;018e5f3a-0001-7000-8000-000000000001&quot;), &quot;Electronics&quot;, 1 },
                { new Guid(&quot;018e5f3a-0001-7000-8000-000000000002&quot;), &quot;Clothing&quot;, 2 },
                { new Guid(&quot;018e5f3a-0001-7000-8000-000000000003&quot;), &quot;Books&quot;, 3 }
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DeleteData(
            table: &quot;Categories&quot;,
            keyColumn: &quot;Id&quot;,
            keyValues: new object[]
            {
                new Guid(&quot;018e5f3a-0001-7000-8000-000000000001&quot;),
                new Guid(&quot;018e5f3a-0001-7000-8000-000000000002&quot;),
                new Guid(&quot;018e5f3a-0001-7000-8000-000000000003&quot;)
            });
    }
}
</code></pre>
<p>This approach guarantees the data is inserted exactly once and in the right order relative to schema changes.</p>
<h2>SQL Scripts</h2>
<p>For large reference datasets or data that requires database-specific features, use raw SQL in migrations:</p>
<pre><code class="language-csharp">public partial class SeedCountries : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            File.ReadAllText(&quot;Migrations/Scripts/seed_countries.sql&quot;));
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(&quot;DELETE FROM Countries;&quot;);
    }
}
</code></pre>
<p>The SQL script approach gives you full control. You can use database-specific syntax, <code>MERGE</code> statements for upserts, and handle thousands of rows efficiently.</p>
<p>One caveat with <code>File.ReadAllText</code>: the path resolves against the working directory when the migration runs, which differs between <code>dotnet ef database update</code> and applying migrations at startup.
Embed the script as an assembly resource instead if you apply migrations from multiple places:</p>
<pre><code class="language-csharp">migrationBuilder.Sql(
    ResourceHelper.ReadEmbedded(&quot;Migrations.Scripts.seed_countries.sql&quot;));
</code></pre>
<p>Migration-based seeding also plays well with <a href="https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core">zero-downtime deployments</a>, because the data ships atomically with the schema change that needs it.</p>
<h2>Environment-Specific Seeding</h2>
<p>Development needs test data. Production doesn't. Separate them:</p>
<pre><code class="language-csharp">using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();

    // Always seed reference data
    await DbInitializer.SeedReferenceDataAsync(context);

    // Only seed test data in development
    if (app.Environment.IsDevelopment())
    {
        await DbInitializer.SeedTestDataAsync(context);
    }
}
</code></pre>
<p>Keep reference data (currencies, countries, roles) separate from test data (fake users, sample orders). Reference data goes to all environments. Test data stays in development.</p>
<p>For realistic test data at volume, use <a href="https://github.com/bchavez/Bogus"><strong>Bogus</strong></a> instead of hand-writing entities:</p>
<pre><code class="language-csharp">var userFaker = new Faker&lt;User&gt;()
    .CustomInstantiator(f =&gt; new User(
        f.Name.FirstName(),
        f.Name.LastName(),
        f.Internet.Email()));

var users = userFaker.Generate(500);

context.Users.AddRange(users);
await context.SaveChangesAsync();
</code></pre>
<p>Five hundred believable users in four lines beats copy-pasting <code>new User(...)</code> blocks.</p>
<h2>Summary</h2>
<p>Use <code>HasData</code> only for deterministic model-managed data with stable keys.
Use <code>UseSeeding</code> or an explicit initializer for idempotent bootstrap logic, and migrations or reviewed scripts when the data must move with a schema version.
Keep development fixtures separate so sample data can never become part of a production deployment by accident.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[How to Use EF Core With Multiple Databases]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-multiple-databases</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-multiple-databases</guid>
            <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In most applications a single database is enough. But when you need to split data across databases - for scaling, multi-tenancy, or module isolation - EF Core…]]></description>
            <content:encoded><![CDATA[<p>To use EF Core with multiple databases, define a separate <code>DbContext</code> class per database and register each one in dependency injection with its own connection string.
The hard part is not registering two connection strings; it is keeping models, migrations, and transaction expectations separate.
Each database needs an explicit <code>DbContext</code> boundary so EF Core never guesses which store owns an entity or migration.</p>
<h2>Why Multiple Databases?</h2>
<p>A single <code>DbContext</code> pointing to a single database works for most applications. But eventually you might need to split things up. Common reasons include:</p>
<ul>
<li><strong>Module isolation</strong> in a modular monolith - each module owns its data</li>
<li><strong>Read/write separation</strong> - queries go to a read replica</li>
<li><strong>Multi-tenancy</strong> - each tenant has a separate database</li>
<li><strong>Legacy integration</strong> - your app needs data from an existing database</li>
</ul>
<p>EF Core handles all of these with multiple <code>DbContext</code> classes, each configured with its own connection string.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-multiple-databases/multiple-contexts.png" alt="One ASP.NET Core app resolving three DbContexts from dependency injection, each pointing at its own database: OrdersDbContext to the orders database, CatalogDbContext to the catalog database, and OrdersReadDbContext to a read replica">
<h2>Defining Multiple DbContexts</h2>
<p>Start by creating separate <code>DbContext</code> classes for each database:</p>
<pre><code class="language-csharp">public class OrdersDbContext : DbContext
{
    public OrdersDbContext(DbContextOptions&lt;OrdersDbContext&gt; options)
        : base(options) { }

    public DbSet&lt;Order&gt; Orders { get; set; }
    public DbSet&lt;OrderLineItem&gt; OrderLineItems { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(OrdersDbContext).Assembly,
            t =&gt; t.Namespace?.Contains(&quot;Orders&quot;) == true);
    }
}

public class CatalogDbContext : DbContext
{
    public CatalogDbContext(DbContextOptions&lt;CatalogDbContext&gt; options)
        : base(options) { }

    public DbSet&lt;Product&gt; Products { get; set; }
    public DbSet&lt;Category&gt; Categories { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(CatalogDbContext).Assembly,
            t =&gt; t.Namespace?.Contains(&quot;Catalog&quot;) == true);
    }
}
</code></pre>
<p>Each context only knows about its own entities. This enforces clear boundaries between modules.</p>
<h2>Connection String Management</h2>
<p>Store connection strings in <code>appsettings.json</code>:</p>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;OrdersDb&quot;: &quot;Host=localhost;Database=orders;Username=app;Password=secret&quot;,
    &quot;CatalogDb&quot;: &quot;Host=localhost;Database=catalog;Username=app;Password=secret&quot;
  }
}
</code></pre>
<p>Then register each context in DI with its own connection string:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;OrdersDbContext&gt;(options =&gt;
    options.UseNpgsql(
        builder.Configuration.GetConnectionString(&quot;OrdersDb&quot;)));

builder.Services.AddDbContext&lt;CatalogDbContext&gt;(options =&gt;
    options.UseNpgsql(
        builder.Configuration.GetConnectionString(&quot;CatalogDb&quot;)));
</code></pre>
<p>The generic <code>DbContextOptions&lt;T&gt;</code> parameter is what makes this work. Each context receives its own options instance. For more tips on configuring your <code>DbContext</code>, see my article on <a href="https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices"><strong>DbContext configuration best practices</strong></a>.</p>
<p>I also covered the same-database variant of this setup in <a href="https://milanjovanovic.tech/blog/using-multiple-ef-core-dbcontext-in-single-application"><strong>Using Multiple EF Core DbContexts in a Single Application</strong></a> - the registration story is identical, only the connection strings differ.</p>
<h2>Running Migrations Per Context</h2>
<p>With multiple contexts, you must specify which context a migration belongs to. Use the <code>--context</code> flag:</p>
<pre><code class="language-bash"># Create a migration for OrdersDbContext
dotnet ef migrations add InitialOrders \
    --context OrdersDbContext \
    --output-dir Migrations/Orders

# Create a migration for CatalogDbContext
dotnet ef migrations add InitialCatalog \
    --context CatalogDbContext \
    --output-dir Migrations/Catalog
</code></pre>
<p>Apply them separately:</p>
<pre><code class="language-bash">dotnet ef database update --context OrdersDbContext
dotnet ef database update --context CatalogDbContext
</code></pre>
<p>Keep migrations in separate folders to avoid confusion, and name each folder after its module or context.
For more deployment strategies, see <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>.</p>
<h2>Using Multiple Contexts in a Service</h2>
<p>Inject both contexts when a service needs data from multiple databases:</p>
<pre><code class="language-csharp">public class OrderSummaryService
{
    private readonly OrdersDbContext _ordersDb;
    private readonly CatalogDbContext _catalogDb;

    public OrderSummaryService(
        OrdersDbContext ordersDb,
        CatalogDbContext catalogDb)
    {
        _ordersDb = ordersDb;
        _catalogDb = catalogDb;
    }

    public async Task&lt;OrderSummaryDto?&gt; GetOrderSummary(Guid orderId)
    {
        var order = await _ordersDb.Orders
            .AsNoTracking()
            .Include(o =&gt; o.LineItems)
            .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

        if (order is null)
        {
            return null;
        }

        var productIds = order.LineItems
            .Select(li =&gt; li.ProductId)
            .ToList();

        var products = await _catalogDb.Products
            .AsNoTracking()
            .Where(p =&gt; productIds.Contains(p.Id))
            .ToDictionaryAsync(p =&gt; p.Id);

        return new OrderSummaryDto
        {
            OrderId = order.Id,
            Items = order.LineItems.Select(li =&gt; new LineItemDto
            {
                ProductName = products[li.ProductId].Name,
                Quantity = li.Quantity,
                Price = li.Price
            }).ToList()
        };
    }
}
</code></pre>
<p>Note that you <strong>cannot join across contexts</strong> in a single LINQ query. Each context only knows about its own database. You have to load data separately and combine in memory.</p>
<h2>Read/Write Separation</h2>
<p>A common pattern is routing read queries to a replica:</p>
<pre><code class="language-csharp">public class OrdersReadDbContext : DbContext
{
    public OrdersReadDbContext(
        DbContextOptions&lt;OrdersReadDbContext&gt; options)
        : base(options) { }

    public DbSet&lt;Order&gt; Orders { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(OrdersDbContext).Assembly,
            t =&gt; t.Namespace?.Contains(&quot;Orders&quot;) == true);
    }
}
</code></pre>
<p>Register it with the read replica connection string:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;OrdersReadDbContext&gt;(options =&gt;
    options.UseNpgsql(
            builder.Configuration.GetConnectionString(&quot;OrdersReadReplica&quot;))
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
</code></pre>
<p>Setting <code>NoTracking</code> as default makes sense for read-only contexts since you'll never call <code>SaveChanges</code> on them.</p>
<p>One gotcha with read replicas: <strong>replication lag</strong>.
A write followed immediately by a read from the replica can return stale data.
Route &quot;read your own writes&quot; queries (like fetching the entity you just created) to the primary, and reserve the replica for queries that tolerate slightly stale data.</p>
<h2>Shared Entity Types Across Contexts</h2>
<p>Sometimes two contexts need the same entity - for example, both <code>Orders</code> and <code>Catalog</code> reference a <code>Product</code>. Don't share entity classes directly. Instead, each context should have its own representation:</p>
<pre><code class="language-csharp">// In the Orders module
public class OrderProduct
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

// In the Catalog module
public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public Guid CategoryId { get; set; }
}
</code></pre>
<p>This keeps each module independent. If the Catalog module adds a column, the Orders module isn't affected.</p>
<h2>Same Database, Multiple Contexts</h2>
<p>You don't need multiple physical databases to benefit from multiple contexts.
In a modular monolith, a common setup is one database with a schema per module, and one <code>DbContext</code> per schema:</p>
<pre><code class="language-csharp">protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasDefaultSchema(&quot;orders&quot;);
}
</code></pre>
<p>You get the module boundaries and independent migrations without the operational cost of extra databases.
I cover the tradeoffs in <a href="https://milanjovanovic.tech/blog/modular-monolith-data-isolation"><strong>modular monolith data isolation</strong></a>.</p>
<h2>Cross-Database Transactions</h2>
<p>EF Core doesn't support distributed transactions across databases out of the box.
<code>TransactionScope</code> with two connections requires a distributed transaction coordinator, which only works on Windows with SQL Server and has no place in cloud-native systems.</p>
<p>If you need atomicity across two databases, consider:</p>
<ul>
<li>The <strong>outbox pattern</strong> - write to a local outbox table, then process asynchronously</li>
<li>Eventual consistency with domain events</li>
<li>A <a href="https://milanjovanovic.tech/blog/saga-pattern-dotnet"><strong>saga or process manager</strong></a> for complex workflows</li>
</ul>
<h2>Summary</h2>
<p>Give each database its own <code>DbContext</code>, options, migration history, and model ownership.
Queries do not join across those boundaries, and a local EF transaction cannot make independent databases atomic.
Coordinate cross-database workflows with idempotent messages, an outbox, or a saga instead of hiding the boundary.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Connection Resiliency and Retry Logic]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-connection-resiliency</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-connection-resiliency</guid>
            <pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Cloud databases drop connections. EF Core's built-in retry logic handles transient failures automatically.]]></description>
            <content:encoded><![CDATA[<p>EF Core connection resiliency is the built-in retry logic you enable with <code>EnableRetryOnFailure</code> in the provider options, which installs an execution strategy that retries transient failures with exponential backoff.
Short network interruptions, failovers, and throttling are temporary, so a retry usually succeeds.
Explicit transactions are the exception: wrap them in <code>CreateExecutionStrategy</code> so a retry cannot replay half the work.</p>
<p>A database operation can fail even when the query and data are valid.
EF Core execution strategies handle the retry loop when you define the correct transactional boundary.</p>
<h2>Transient Failures Are Normal</h2>
<p>In cloud environments, database connections fail. Load balancers rotate. SQL Azure throttles requests. Network hiccups happen. These are <strong>transient failures</strong> - they succeed if you retry.</p>
<p>Without retry logic, a brief network blip causes 500 errors for your users. EF Core's execution strategy solves this by automatically retrying failed operations.</p>
<h2>Enabling Retry Logic</h2>
<h3>SQL Server / Azure SQL</h3>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
{
    options.UseSqlServer(connectionString, sqlOptions =&gt;
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorNumbersToAdd: null);
    });
});
</code></pre>
<h3>PostgreSQL (Npgsql)</h3>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
{
    options.UseNpgsql(connectionString, npgsqlOptions =&gt;
    {
        npgsqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(30),
            errorCodesToAdd: null);
    });
});
</code></pre>
<p>Both use exponential backoff with jitter by default.</p>
<h2>What Gets Retried?</h2>
<p>EF Core retries these operations:</p>
<ul>
<li><code>SaveChangesAsync()</code></li>
<li><code>ToListAsync()</code>, <code>FirstOrDefaultAsync()</code>, etc.</li>
<li>Any LINQ query execution</li>
</ul>
<p>It retries only <strong>transient</strong> SQL errors. Constraint violations, syntax errors, and other non-transient failures are not retried.</p>
<h3>SQL Server Transient Error Numbers</h3>
<p>The default transient error list for SQL Server includes, among others:</p>
<ul>
<li><strong>-2</strong>: Timeout</li>
<li><strong>20</strong>: Instance error</li>
<li><strong>64</strong>: Connection error</li>
<li><strong>233</strong>: Connection closed</li>
<li><strong>10053</strong>: Transport error</li>
<li><strong>10054</strong>: Connection reset</li>
<li><strong>10060</strong>: Connection timeout</li>
<li><strong>40143</strong>: Throttled (Azure SQL)</li>
<li><strong>40197</strong>: Service error (Azure SQL)</li>
<li><strong>40501</strong>: Service busy (Azure SQL)</li>
<li><strong>40613</strong>: Database unavailable (Azure SQL)</li>
<li><strong>49918</strong>: Not enough resources (Azure SQL)</li>
<li><strong>49919</strong>: Too many requests (Azure SQL)</li>
<li><strong>49920</strong>: Too many requests (Azure SQL)</li>
</ul>
<p>You can add custom error numbers:</p>
<pre><code class="language-csharp">sqlOptions.EnableRetryOnFailure(
    maxRetryCount: 5,
    maxRetryDelay: TimeSpan.FromSeconds(30),
    errorNumbersToAdd: [4060, 18401]);
</code></pre>
<h2>The Transaction Problem</h2>
<p>Here's the critical pitfall. Retries don't work with manual transactions:</p>
<pre><code class="language-csharp">// ❌ This throws InvalidOperationException with retry enabled
using var transaction = await _db.Database.BeginTransactionAsync();

order.Status = OrderStatus.Confirmed;
await _db.SaveChangesAsync();

payment.Status = PaymentStatus.Captured;
await _db.SaveChangesAsync();

await transaction.CommitAsync();
</code></pre>
<p>Why? If the first <code>SaveChangesAsync</code> succeeds but the second fails, EF Core can't retry the second without replaying the first. The retry strategy doesn't know about your transaction boundaries.</p>
<h3>The Fix: CreateExecutionStrategy</h3>
<pre><code class="language-csharp">var strategy = _db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =&gt;
{
    using var transaction = await _db.Database.BeginTransactionAsync();

    order.Status = OrderStatus.Confirmed;
    await _db.SaveChangesAsync();

    payment.Status = PaymentStatus.Captured;
    await _db.SaveChangesAsync();

    await transaction.CommitAsync();
});
</code></pre>
<p><code>ExecuteAsync</code> wraps the entire operation - including the transaction - as a single retriable unit. If any step fails with a transient error, the whole block is retried from the beginning.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-connection-resiliency/execution-strategy-retry.png" alt="The execution strategy wraps a begin-transaction, SaveChanges, commit block: a transient error retries the whole block with exponential backoff, while success exits">
<h2>Custom Execution Strategy</h2>
<p>For fine-grained control, create a custom strategy:</p>
<pre><code class="language-csharp">public class CustomRetryStrategy : SqlServerRetryingExecutionStrategy
{
    private readonly ILogger&lt;CustomRetryStrategy&gt; _logger;

    public CustomRetryStrategy(
        ExecutionStrategyDependencies dependencies,
        int maxRetryCount,
        TimeSpan maxRetryDelay,
        ILogger&lt;CustomRetryStrategy&gt; logger)
        : base(dependencies, maxRetryCount, maxRetryDelay, null)
    {
        _logger = logger;
    }

    protected override bool ShouldRetryOn(Exception exception)
    {
        var shouldRetry = base.ShouldRetryOn(exception);

        if (shouldRetry)
        {
            _logger.LogWarning(exception,
                &quot;Transient database error. Retrying...&quot;);
        }

        return shouldRetry;
    }

    protected override TimeSpan? GetNextDelay(Exception lastException)
    {
        var delay = base.GetNextDelay(lastException);

        if (delay.HasValue)
        {
            _logger.LogWarning(
                &quot;Retrying in {Delay}ms after error: {Message}&quot;,
                delay.Value.TotalMilliseconds,
                lastException.Message);
        }

        return delay;
    }
}
</code></pre>
<p>Register it (note the <code>AddDbContext</code> overload that exposes the service provider, so we can resolve the logger):</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;ApplicationDbContext&gt;((sp, options) =&gt;
{
    options.UseSqlServer(connectionString, sqlOptions =&gt;
    {
        sqlOptions.ExecutionStrategy(dependencies =&gt;
            new CustomRetryStrategy(
                dependencies,
                maxRetryCount: 5,
                maxRetryDelay: TimeSpan.FromSeconds(30),
                sp.GetRequiredService&lt;ILogger&lt;CustomRetryStrategy&gt;&gt;()));
    });
});
</code></pre>
<h2>Retry With Polly</h2>
<p>For more advanced policies, combine EF Core with <a href="https://milanjovanovic.tech/blog/building-resilient-cloud-applications-with-dotnet"><strong>Polly</strong></a>.
With Polly v8, that means a resilience pipeline:</p>
<pre><code class="language-csharp">var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder()
            .Handle&lt;SqlException&gt;(ex =&gt; ex.IsTransient)
            .Handle&lt;TimeoutException&gt;(),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        Delay = TimeSpan.FromSeconds(1),
        OnRetry = args =&gt;
        {
            logger.LogWarning(
                &quot;Retry {Attempt} after {Delay}ms: {Error}&quot;,
                args.AttemptNumber,
                args.RetryDelay.TotalMilliseconds,
                args.Outcome.Exception?.Message);

            return ValueTask.CompletedTask;
        }
    })
    .Build();

await pipeline.ExecuteAsync(async ct =&gt;
{
    await _db.SaveChangesAsync(ct);
});
</code></pre>
<p>But in most cases, EF Core's built-in retry is sufficient - it already knows which provider errors are transient.
Use Polly when you need circuit breakers or more complex <strong>resilience patterns</strong>, and avoid stacking Polly retries on top of <code>EnableRetryOnFailure</code> (you'd multiply the attempts).</p>
<h2>Idempotency Matters</h2>
<p>Retries mean your operation might execute more than once. Make sure your operations are idempotent.</p>
<p>Here's the subtle failure mode: the <code>INSERT</code> commits on the server, but the connection drops before the acknowledgment reaches your app.
EF Core sees a transient error and retries.
With database-generated keys, you get a duplicate row; with a client-generated key, the retry fails on the primary key violation.</p>
<pre><code class="language-csharp">// ❌ Not idempotent - a retry can re-execute an insert that already committed
var order = new Order { Id = Guid.NewGuid(), Total = 100 };
_db.Orders.Add(order);
await _db.SaveChangesAsync();

// ✅ Idempotent - upsert pattern
var order = await _db.Orders.FindAsync(orderId);
if (order is null)
{
    order = new Order { Id = orderId, Total = 100 };
    _db.Orders.Add(order);
}
else
{
    order.Total = 100;
}
await _db.SaveChangesAsync();
</code></pre>
<p>Or use the <a href="https://milanjovanovic.tech/blog/implementing-the-inbox-pattern-for-reliable-message-consumption"><strong>Inbox Pattern</strong></a> for message consumers.</p>
<h2>Health Check Integration</h2>
<p>Monitor connection health alongside retry:</p>
<pre><code class="language-csharp">builder.Services.AddHealthChecks()
    .AddSqlServer(
        connectionString,
        name: &quot;sql-server&quot;,
        timeout: TimeSpan.FromSeconds(5),
        tags: [&quot;db&quot;, &quot;ready&quot;]);
</code></pre>
<h2>Summary</h2>
<p>Enable the provider's retry strategy for transient database failures.
When you open an explicit transaction, execute the entire transaction through <code>CreateExecutionStrategy</code> so a retry cannot replay only half the work.
Assume the final commit can be ambiguous and give externally visible operations an idempotency strategy.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Zero-Downtime Database Migrations With EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core</guid>
            <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Deploying database changes without downtime requires careful planning. The expand-contract pattern, additive-only migrations, and backwards-compatible changes…]]></description>
            <content:encoded><![CDATA[<p>Zero-downtime migrations come from the expand-contract pattern: add the new structure, migrate data and code onto it, then drop the old structure once nothing uses it.
Each phase deploys and reverses independently, so old and new code always find a compatible schema.</p>
<p>Application code and database schema do not switch versions at exactly the same instant.
During a rolling deployment, old and new instances must both work against the same intermediate schema.</p>
<h2>The Deployment Problem</h2>
<p>In a typical deployment, you update the database schema and deploy new application code at the same time. If the migration takes 30 seconds but the deployment takes 2 minutes, there's a window where the old code runs against the new schema - or the new code runs against the old schema.</p>
<p>Either scenario can cause errors. Renaming a column breaks the old code. Removing a column breaks queries that reference it. This is why naive migrations cause downtime.</p>
<h2>The Expand-Contract Pattern</h2>
<p>The <strong>expand-contract</strong> pattern splits every breaking change into three safe steps:</p>
<ol>
<li><strong>Expand</strong> - add the new structure alongside the old one</li>
<li><strong>Migrate</strong> - move data and update code to use the new structure</li>
<li><strong>Contract</strong> - remove the old structure once nothing depends on it</li>
</ol>
<p>Each step is deployed independently. At no point does old code break, because the old structure is still present during the expand and migrate phases.</p>
<img src="https://milanjovanovic.tech/blogs/articles/zero-downtime-migrations-ef-core/expand-contract.png" alt="The expand-contract pattern in three phases: expand adds the new column alongside the old, migrate copies data and switches code over, and contract drops the old column once nothing uses it">
<h2>Additive-Only Migrations</h2>
<p>The safest migrations only <strong>add</strong> things:</p>
<pre><code class="language-csharp">// ✅ Safe - additive changes
migrationBuilder.AddColumn&lt;string&gt;(
    name: &quot;PhoneNumber&quot;,
    table: &quot;Customers&quot;,
    nullable: true); // Must be nullable or have a default

migrationBuilder.CreateTable(
    name: &quot;CustomerPreferences&quot;,
    columns: table =&gt; new
    {
        Id = table.Column&lt;Guid&gt;(),
        CustomerId = table.Column&lt;Guid&gt;(),
        Theme = table.Column&lt;string&gt;(defaultValue: &quot;light&quot;)
    });

migrationBuilder.CreateIndex(
    name: &quot;IX_Orders_CustomerId&quot;,
    table: &quot;Orders&quot;,
    column: &quot;CustomerId&quot;);
</code></pre>
<p>Adding nullable columns and new tables is normally compatible with old code because it ignores the new structures.
Index creation is logically additive but can still block writes or consume substantial resources, so use the provider's online or concurrent option and test it on production-scale data.</p>
<pre><code class="language-csharp">// ❌ Dangerous - breaking changes
migrationBuilder.DropColumn(name: &quot;Phone&quot;, table: &quot;Customers&quot;);
migrationBuilder.RenameColumn(
    name: &quot;Name&quot;, table: &quot;Products&quot;, newName: &quot;Title&quot;);
migrationBuilder.AlterColumn&lt;string&gt;(
    name: &quot;Email&quot;, table: &quot;Customers&quot;, nullable: false);
</code></pre>
<p>Dropping columns, renaming columns, and making nullable columns required are all breaking changes. They need the expand-contract pattern.</p>
<h2>How Do You Rename a Column Without Downtime?</h2>
<p>Renaming a column is one of the most common breaking changes. Here's how to do it safely with EF Core across three deployments.</p>
<h3>Step 1: Expand - Add the New Column</h3>
<pre><code class="language-csharp">public partial class AddTitleColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn&lt;string&gt;(
            name: &quot;Title&quot;,
            table: &quot;Products&quot;,
            nullable: true);

        migrationBuilder.Sql(
            @&quot;UPDATE &quot;&quot;Products&quot;&quot; SET &quot;&quot;Title&quot;&quot; = &quot;&quot;Name&quot;&quot;&quot;);
    }
}
</code></pre>
<p>Deploy this migration. Both <code>Name</code> and <code>Title</code> columns exist. Old code uses <code>Name</code>, new code doesn't exist yet.</p>
<h3>Step 2: Migrate - Write to Both, Read From New</h3>
<p>Update your <code>DbContext</code> configuration to map the entity to the new column:</p>
<pre><code class="language-csharp">public class ProductConfiguration : IEntityTypeConfiguration&lt;Product&gt;
{
    public void Configure(EntityTypeBuilder&lt;Product&gt; builder)
    {
        builder.Property(p =&gt; p.Title)
            .HasColumnName(&quot;Title&quot;);

        // Ignore the old property in the model
        builder.Ignore(p =&gt; p.Name);
    }
}
</code></pre>
<p>If other services still write to the old column, add a trigger or application-level sync to keep both columns in sync:</p>
<pre><code class="language-csharp">public partial class SyncTitleAndName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(@&quot;
            CREATE OR REPLACE FUNCTION sync_product_title()
            RETURNS TRIGGER AS $$
            BEGIN
                IF TG_OP = 'INSERT' THEN
                    IF NEW.&quot;&quot;Title&quot;&quot; IS NULL THEN
                        NEW.&quot;&quot;Title&quot;&quot; = NEW.&quot;&quot;Name&quot;&quot;;
                    ELSIF NEW.&quot;&quot;Name&quot;&quot; IS NULL THEN
                        NEW.&quot;&quot;Name&quot;&quot; = NEW.&quot;&quot;Title&quot;&quot;;
                    END IF;
                ELSIF NEW.&quot;&quot;Title&quot;&quot; IS DISTINCT FROM OLD.&quot;&quot;Title&quot;&quot; THEN
                    NEW.&quot;&quot;Name&quot;&quot; = NEW.&quot;&quot;Title&quot;&quot;;
                ELSIF NEW.&quot;&quot;Name&quot;&quot; IS DISTINCT FROM OLD.&quot;&quot;Name&quot;&quot; THEN
                    NEW.&quot;&quot;Title&quot;&quot; = NEW.&quot;&quot;Name&quot;&quot;;
                END IF;
                RETURN NEW;
            END;
            $$ LANGUAGE plpgsql;

            CREATE TRIGGER trg_sync_product_title
            BEFORE INSERT OR UPDATE ON &quot;&quot;Products&quot;&quot;
            FOR EACH ROW EXECUTE FUNCTION sync_product_title();
        &quot;);
    }
}
</code></pre>
<p>The trigger must fire on <code>INSERT OR UPDATE</code>, not just updates.
Old code inserts rows with only <code>Name</code> set, new code inserts rows with only <code>Title</code> set, and an update-only trigger would leave the other column NULL - exactly the inconsistency this whole dance exists to prevent.</p>
<p>Deploy. All code works - old code reads/writes <code>Name</code>, new code reads/writes <code>Title</code>, and the trigger keeps them in sync.</p>
<h3>Step 3: Contract - Remove the Old Column</h3>
<p>Once no running code references <code>Name</code>, clean up:</p>
<pre><code class="language-csharp">public partial class DropNameColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @&quot;DROP TRIGGER IF EXISTS trg_sync_product_title
              ON &quot;&quot;Products&quot;&quot;&quot;);

        migrationBuilder.DropColumn(
            name: &quot;Name&quot;,
            table: &quot;Products&quot;);
    }
}
</code></pre>
<p>Three deployments, zero downtime.</p>
<h2>Non-Nullable Column Strategy</h2>
<p>Adding a required column to an existing table breaks if there's existing data. The safe approach:</p>
<pre><code class="language-csharp">// Step 1: Add as nullable with a default
migrationBuilder.AddColumn&lt;string&gt;(
    name: &quot;Region&quot;,
    table: &quot;Customers&quot;,
    nullable: true,
    defaultValue: &quot;US&quot;);

// Step 2: Backfill existing rows
migrationBuilder.Sql(
    @&quot;UPDATE &quot;&quot;Customers&quot;&quot; SET &quot;&quot;Region&quot;&quot; = 'US' WHERE &quot;&quot;Region&quot;&quot; IS NULL&quot;);

// Step 3: In a LATER migration, make it required
migrationBuilder.AlterColumn&lt;string&gt;(
    name: &quot;Region&quot;,
    table: &quot;Customers&quot;,
    nullable: false,
    defaultValue: &quot;US&quot;);
</code></pre>
<p>Split steps 1-2 and step 3 into separate deployments. The application code should handle null values during the transition period.</p>
<h2>Index Creation Without Blocking Writes</h2>
<p>On large tables, a regular PostgreSQL <code>CREATE INDEX</code> allows reads but blocks writes on the target table. PostgreSQL also supports concurrent index creation:</p>
<pre><code class="language-csharp">public partial class AddOrderStatusIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @&quot;CREATE INDEX CONCURRENTLY &quot;&quot;IX_Orders_Status&quot;&quot;
              ON &quot;&quot;Orders&quot;&quot; (&quot;&quot;Status&quot;&quot;);&quot;,
            suppressTransaction: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(
            @&quot;DROP INDEX CONCURRENTLY IF EXISTS &quot;&quot;IX_Orders_Status&quot;&quot;;&quot;,
            suppressTransaction: true);
    }
}
</code></pre>
<p><code>CREATE INDEX CONCURRENTLY</code> builds the index without blocking writes.
The concurrent create and drop commands both require <code>suppressTransaction: true</code> because PostgreSQL refuses to run either one inside a transaction block.
The deployment runner must also execute the generated script as written instead of wrapping the entire file in its own transaction.
The build still performs extra scans, consumes I/O, and may wait for old transactions to finish.</p>
<p>Keep the concurrent build in a dedicated migration with no later schema operations.
It cannot be atomic with EF's migrations-history insert, and a failed build can leave an invalid index that still consumes write overhead.
Inspect and remove that invalid index before retrying rather than adding <code>IF NOT EXISTS</code> to the create command.</p>
<p>I cover more deployment strategies in <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>.</p>
<h2>Applying Migrations in Production</h2>
<p>Don't call <code>Database.Migrate()</code> at startup in production. It holds a lock, runs synchronously, and can fail in ways that leave the database in a partial state.</p>
<p>Instead, run migrations as a <strong>separate deployment step</strong>:</p>
<pre><code class="language-bash"># Run migrations from CI/CD pipeline
dotnet ef database update --connection &quot;$CONNECTION_STRING&quot;
</code></pre>
<p>Or use a dedicated migration runner:</p>
<pre><code class="language-csharp">// In a console app or init container (host is the built IHost)
using var scope = host.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();

var pending = await context.Database.GetPendingMigrationsAsync();
if (pending.Any())
{
    await context.Database.MigrateAsync();
}
</code></pre>
<p>Run migrations <strong>before</strong> deploying new application code. The expand phase ensures backwards compatibility, so the old code keeps working while migrations run.</p>
<h2>Migration Checklist</h2>
<p>Before deploying a migration to production, verify:</p>
<ul>
<li>Can the old application code run against the new schema?</li>
<li>Can the new application code run against the old schema?</li>
<li>Are all new columns nullable or have defaults?</li>
<li>Are destructive changes (drops, renames) in a separate contract migration?</li>
<li>Have large table operations been tested for lock duration?</li>
</ul>
<h2>Summary</h2>
<p>Expand the schema so old and new application versions can run together, migrate reads and writes, and contract only after the old path is gone.
Backfill required data in bounded batches and treat index builds or table rewrites as operational work even when the schema change is additive.
Run migrations from the deployment pipeline and verify both forward compatibility and rollback behavior before production.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[How to Roll Back an EF Core Migration]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-migration-rollback</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-migration-rollback</guid>
            <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Rolling back an EF Core migration is two commands, but only in the right order. Delete the migration file first and EF Core loses the ability to generate the…]]></description>
            <content:encoded><![CDATA[<p>To roll back an applied EF Core migration, run <code>dotnet ef database update &lt;previous-migration&gt;</code> to revert the schema, then <code>dotnet ef migrations remove</code> to delete the migration file and rewind the model snapshot.
The order is not negotiable, because the database revert needs the <code>Down</code> method that lives in the file you are about to delete.
A migration that was never applied needs <code>migrations remove</code> alone.</p>
<p>Every EF Core migration has a <code>Down</code> method, so rollbacks should be trivial.
And they are, right up until someone deletes the migration file before reverting the database.</p>
<p>Now the history table lists a migration your project no longer has, <code>database update</code> cannot generate the revert SQL, and the model snapshot disagrees with both.
The fix at that point is manual surgery.</p>
<p>The rollback commands are simple.
What matters is the order, and which environment you are pointing at.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-migration-rollback/rollback-decision.png" alt="Decision flow for undoing a migration: a never-applied migration is removed with migrations remove, an applied local one is reverted with database update then removed, and a merged or deployed one is reversed with a new forward migration">
<h2>How Do You Undo the Last Applied Migration?</h2>
<p>Say your history looks like this:</p>
<pre><code class="language-bash">dotnet ef migrations list

# 20260620093011_AddOrders (applied)
# 20260701141518_AddOrderNotes (applied)   &lt;- want to undo this one
</code></pre>
<p><strong>Step 1: revert the database to the previous migration.</strong>
<code>database update</code> takes a target migration name and runs every <code>Down</code> between the current state and that target:</p>
<pre><code class="language-bash">dotnet ef database update AddOrders
</code></pre>
<p><strong>Step 2: remove the migration file.</strong>
Only after the database no longer contains the migration:</p>
<pre><code class="language-bash">dotnet ef migrations remove
</code></pre>
<p><code>migrations remove</code> deletes the newest migration file <strong>and rewinds the model snapshot</strong>, which is the part people forget exists.
Doing it by hand (deleting the <code>.cs</code> files) leaves the snapshot describing a model with the migration still in it, and your next migration comes out empty or wrong.
The snapshot is why the command exists; use it.</p>
<p>If you run the steps in the wrong order, <code>migrations remove</code> actually protects you: it refuses when the migration is applied, telling you to revert first.
The people who get hurt are the ones who delete files manually or sync a branch that no longer contains the migration.
Which brings us to git.</p>
<h2>The Git Branch Trap</h2>
<p>The most common way teams corrupt migration state has no EF command in it at all:</p>
<ol>
<li>You apply <code>AddOrderNotes</code> to your local database while working on a branch.</li>
<li>The branch dies. You switch back to <code>main</code>.</li>
<li>The migration file is gone, but your local database still has the schema change and the history row.</li>
</ol>
<p>EF Core now considers your database <strong>ahead</strong> of your project, and there is no file to generate <code>Down</code> SQL from.
Your options, in order of preference:</p>
<ul>
<li><strong>Recreate the database</strong> if it is disposable local dev. Fastest, cleanest.</li>
<li><strong>Check out the dead branch, revert, then switch.</strong> Run <code>dotnet ef database update AddOrders</code> while the migration file still exists in your working tree.</li>
<li><strong>Manual repair.</strong> Undo the schema change with hand-written SQL, then delete the history row:</li>
</ul>
<pre><code class="language-sql">DELETE FROM &quot;__EFMigrationsHistory&quot;
WHERE &quot;MigrationId&quot; = '20260701141518_AddOrderNotes';
</code></pre>
<p>The lesson: revert your local database <strong>before</strong> deleting or switching away from a branch with unmerged migrations.
Make it muscle memory, the same way you run the practices from <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>.</p>
<h2>Rolling Back Migrations That Were Never Applied</h2>
<p>If you added a migration and immediately regretted it (wrong name, model not ready), and it never ran anywhere:</p>
<pre><code class="language-bash">dotnet ef migrations remove
</code></pre>
<p>Done.
No database step, because there is nothing to revert.
This is also the correct response to noticing a bad migration during code review: remove it, fix the model, add a fresh one.
Never edit a generated migration's <code>Up</code> in place beyond documented customization, and never renumber or hand-rename them.</p>
<h2>Production: Scripts and Bundles, Not CLI Commands</h2>
<p><code>dotnet ef database update</code> against production means SDK, source code, and DDL credentials on whatever machine runs it.
Do not.
The two production-grade options mirror how you deploy forward migrations.</p>
<p><strong>Reviewed SQL scripts.</strong> Generate the revert SQL in CI, review it, run it through the same channel as any release script.
Note the argument order, from current back to target:</p>
<pre><code class="language-bash">dotnet ef migrations script AddOrderNotes AddOrders --output revert.sql
</code></pre>
<p><strong>Migration bundles.</strong> A bundle accepts a target migration and will migrate down to it:</p>
<pre><code class="language-bash">./efbundle AddOrders --connection &quot;$DB_CONNECTION&quot;
</code></pre>
<p>I covered why bundles beat startup migration, and how to build them in CI, in <a href="https://milanjovanovic.tech/blog/ef-core-migration-bundles"><strong>EF Core migration bundles</strong></a>.</p>
<p>Now the hard truth about production rollbacks: <strong><code>Down</code> methods are only safe for additive changes.</strong>
Reverting <code>AddColumn</code> runs <code>DropColumn</code>, and every value in that column is gone.
Reverting a table rename or a data migration can be outright impossible to express.
EF Core generates <code>Down</code> code mechanically; it has no idea whether the operation destroys data, and it will not warn you.</p>
<p>So before any production rollback:</p>
<ul>
<li>Read the generated SQL. All of it.</li>
<li>If any statement drops or rewrites data, prefer <strong>rolling forward</strong>: write a new migration that reverses the intent while preserving data, and deploy it like any release.</li>
<li>If you must roll back destructively, snapshot first (a backup, or <code>SELECT INTO</code> the affected columns).</li>
</ul>
<p>This is also why the expand-and-contract pattern from <a href="https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core"><strong>zero-downtime migrations</strong></a> is worth the ceremony: when every migration is individually additive, every rollback is individually safe, and the old application version keeps working against the new schema in both directions.
A rollback of code without a rollback of schema, the most common incident shape, becomes a non-event.</p>
<h2>Rolling Back Everything</h2>
<p>Two special targets are worth knowing.
Reverting all migrations (torching the schema EF manages, useful for local resets):</p>
<pre><code class="language-bash">dotnet ef database update 0
</code></pre>
<p>And listing what is applied versus pending when you are not sure where an environment stands:</p>
<pre><code class="language-bash">dotnet ef migrations list --connection &quot;$DB_CONNECTION&quot;
</code></pre>
<p>For local development, <code>database update 0</code> followed by <code>database update</code> is a poor man's rebuild; dropping and recreating the database is usually faster and also resets anything outside EF's control.
If your integration tests fight schema drift, that reset-per-run approach is the reliable one, as I argued in <strong>fixing flaky Postgres integration tests</strong>.</p>
<h2>The Rules That Keep Rollbacks Boring</h2>
<ul>
<li><strong>Applied migration, local database</strong>: <code>database update &lt;previous&gt;</code>, then <code>migrations remove</code>. In that order, always.</li>
<li><strong>Unapplied migration</strong>: <code>migrations remove</code> alone.</li>
<li><strong>Merged or deployed migration</strong>: it is immutable. Reverse it with a <strong>new forward migration</strong>, never by deleting history.</li>
<li><strong>Switching branches</strong>: revert the local database before abandoning a branch with unmerged migrations.</li>
<li><strong>Production</strong>: reviewed script or bundle with a target migration, backups before destructive <code>Down</code>s, and a strong bias toward roll-forward.</li>
</ul>
<p>The deeper the migration is in shared history, the less &quot;rollback&quot; means running <code>Down</code> and the more it means writing a new <code>Up</code> that undoes the intent.
That progression, and the full command reference, is laid out in <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>EF Core migrations: a detailed guide</strong></a>.</p>
<h2>Summary</h2>
<p>Rolling back a migration is <code>dotnet ef database update &lt;previous-migration&gt;</code> followed by <code>dotnet ef migrations remove</code>, and the order is not negotiable: the database revert needs the <code>Down</code> method that lives in the file you are about to delete.
<code>migrations remove</code> exists because the model snapshot must rewind with the file; deleting <code>.cs</code> files by hand corrupts the next migration.</p>
<p>Once a migration reaches a shared environment, stop thinking in terms of <code>Down</code> at all.
Deployed history is append-only: reverse mistakes with new forward migrations, keep changes additive so old code and old schema stay mutually compatible, and save destructive rollbacks for databases you can afford to lose.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Migration Bundles for CI/CD Deployments]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-migration-bundles</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-migration-bundles</guid>
            <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Calling Database.Migrate at startup means your schema changes run when the app boots, with the app pool racing itself and failures taking production down.]]></description>
            <content:encoded><![CDATA[<p>A migration bundle is a self-contained executable, produced by <code>dotnet ef migrations bundle</code>, that applies your pending migrations to a database.
Your pipeline runs it as a deploy step before the new code ships, so a broken migration is a failed build instead of a crash-looping app.
The machine that runs it needs no SDK and no project source, and the connection string is supplied at run time.</p>
<p>There are two moments a schema migration can run: when your pipeline deploys, or when your application boots.</p>
<p><code>Database.Migrate()</code> in <code>Program.cs</code> picks the second, and it is the default in a thousand tutorials.
It is also the option where a broken migration takes down production instead of failing a pipeline step, where three replicas race to alter the same table, and where your web app carries DDL permissions it should not have.</p>
<p>Migration bundles exist to make the first option as easy as the second.
One command produces a self-contained executable; your pipeline runs it before the new code ships.</p>
<h2>Why Migrate-on-Startup Bites</h2>
<p>The convenient version looks like this:</p>
<pre><code class="language-csharp">using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
    await db.Database.MigrateAsync();
}

app.Run();
</code></pre>
<p>Four operational risks follow:</p>
<ul>
<li><strong>Replica races.</strong> Kubernetes starts three pods; all three call <code>MigrateAsync</code> simultaneously. EF Core takes locks and usually survives, but &quot;usually&quot; is doing heavy lifting, and on some providers concurrent migrators can deadlock or half-apply.</li>
<li><strong>Failure lands in the wrong place.</strong> A migration that times out on a large table turns into a crash-looping app and an outage. The same failure in a pipeline step is a red build and a rollback, with the old version still serving.</li>
<li><strong>Permissions.</strong> The app's runtime identity now needs <code>ALTER</code>, <code>CREATE</code>, <code>DROP</code>. Least privilege dies at line one of <code>Program.cs</code>.</li>
<li><strong>Rolling deploys invert the order.</strong> During a rolling update, the new pod migrates the schema while old pods still run old queries against it. You need <a href="https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core"><strong>zero-downtime discipline</strong></a> either way, but startup migration removes your control over the timing.</li>
</ul>
<p>Startup migration is defensible in exactly one place: local development and single-instance internal tools.
Everywhere else, the schema change belongs to the deployment, not the process.</p>
<h2>Building a Bundle</h2>
<p>The tooling is part of <code>dotnet-ef</code>:</p>
<pre><code class="language-bash">dotnet tool install --global dotnet-ef

dotnet ef migrations bundle \
  --project src/MyApp.Infrastructure \
  --startup-project src/MyApp.Api \
  --self-contained -r linux-x64 \
  --output efbundle
</code></pre>
<p>The output is a single executable containing your compiled migrations, your model snapshot, and, with <code>--self-contained</code>, the .NET runtime itself (which makes it weigh tens of megabytes).
The pipeline agent or init container that runs it needs nothing installed.</p>
<p>Running it is equally boring, which is the point:</p>
<pre><code class="language-bash">./efbundle --connection &quot;Host=db;Database=myapp;Username=migrator;Password=$DB_PASSWORD&quot;
</code></pre>
<p>The bundle reads the migrations history table, applies only what is pending, and exits non-zero on failure.
It also accepts a target migration as an argument, which makes it usable for controlled <a href="https://milanjovanovic.tech/blog/ef-core-migration-rollback"><strong>rollbacks</strong></a>:</p>
<pre><code class="language-bash">./efbundle AddOrderTable --connection &quot;...&quot;
</code></pre>
<p>Note the connection string is supplied at run time, not baked in at build time.
One artifact promotes through dev, staging, and production, credentials come from the environment, and the migration runs under a dedicated <code>migrator</code> database role, not the app's identity.</p>
<h2>Wiring It into a Pipeline</h2>
<p>Here is a GitHub Actions workflow that builds the bundle once and executes it before the deploy step:</p>
<pre><code class="language-yaml">jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Build migration bundle
        run: |
          dotnet tool restore
          dotnet ef migrations bundle \
            --project src/MyApp.Infrastructure \
            --startup-project src/MyApp.Api \
            --self-contained -r linux-x64 \
            --output efbundle

      - uses: actions/upload-artifact@v4
        with:
          name: efbundle
          path: efbundle

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: efbundle

      - name: Apply migrations
        run: |
          chmod +x efbundle
          ./efbundle --connection &quot;${{ secrets.DB_CONNECTION }}&quot;

      - name: Deploy application
        run: ./deploy.sh
</code></pre>
<p>The ordering encodes the contract: <strong>schema first, code second</strong>, and only if the schema step succeeded.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-migration-bundles/pipeline-flow.png" alt="Pipeline flow where the CI build produces the migration bundle, running efbundle applies migrations, and only a zero exit code proceeds to deploy the new app while a non-zero exit stops the pipeline and leaves the old version serving">
<p>That contract is also what makes expand-and-contract migrations work, since the old code must keep running against the new schema for the duration of the rollout.</p>
<p>In containerized setups, the same bundle runs as a Kubernetes init container or a one-off job before the rollout.
Because it is self-contained, the migration image can be <code>FROM debian:stable-slim</code> with the bundle copied in, no SDK layer.</p>
<p>If you orchestrate local development with Aspire, migrations fit a similar &quot;separate executor&quot; model there too; I showed that pattern in <strong>applying EF Core migrations with Aspire</strong>.</p>
<h2>Bundles vs SQL Scripts vs Migrate-on-Startup</h2>
<p>The other pipeline-friendly option is generating an idempotent SQL script:</p>
<pre><code class="language-bash">dotnet ef migrations script --idempotent --output migrations.sql
</code></pre>
<p>Scripts have one real advantage: a DBA can read, review, and hand-tune the exact SQL before it runs, and script execution slots into organizations where database changes go through a review gate.
The costs are tooling (you need <code>sqlcmd</code>/<code>psql</code> on the agent) and drift risk if someone edits the script after review.</p>
<p>My decision line:</p>
<ul>
<li><strong>Bundles</strong>: teams that own their database, automated pipelines, containers. Least ceremony, artifact matches what EF would do exactly.</li>
<li><strong>Idempotent scripts</strong>: regulated environments, mandatory DBA review, or migrations that need hand-tuned locking hints on giant tables.</li>
<li><strong>Migrate-on-startup</strong>: local dev only.</li>
</ul>
<p>Whichever you pick, the deeper habits, small reversible migrations, never editing an applied migration, reviewing generated SQL, come from <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>, and the fundamentals of the migration system itself are in <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>EF Core migrations: a detailed guide</strong></a>.</p>
<h2>Operational Details Worth Knowing</h2>
<ul>
<li><strong>The bundle must match the deploy.</strong> Build it in the same pipeline run as the app artifact, from the same commit. A bundle from yesterday's build applying against today's code is drift by construction.</li>
<li><strong>Timeouts.</strong> Long-running migrations on big tables inherit the command timeout in the connection string; set <code>Command Timeout=600</code> (or provider equivalent) explicitly for heavyweight releases.</li>
<li><strong>Concurrency guard.</strong> Pipelines can race too, if two deploys overlap. Serialize the deploy job per environment; the pipeline is the right place for that lock, not the database.</li>
<li><strong>EF 9+ warns loudly</strong> when the model has changes not covered by any migration, which catches the &quot;forgot to add a migration&quot; failure in CI instead of production. I covered that check in <a href="https://milanjovanovic.tech/blog/ef-core-pending-model-changes-error"><strong>fixing PendingModelChangesWarning</strong></a>.</li>
</ul>
<h2>Summary</h2>
<p>A migration bundle turns &quot;apply schema changes&quot; into a single self-contained executable your pipeline runs as a first-class deploy step.
Failures become red builds instead of crash-looping pods, the app sheds its DDL permissions, replicas stop racing, and one artifact promotes across environments with the connection string supplied at run time.</p>
<p><code>dotnet ef migrations bundle</code> in CI, <code>./efbundle --connection</code> before the deploy, schema before code.
Keep <code>Database.Migrate()</code> for localhost, where it belongs.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core and PostgreSQL: Getting Started Guide]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started</guid>
            <pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[PostgreSQL is a powerful open-source database with features like JSONB, arrays, and full-text search.]]></description>
            <content:encoded><![CDATA[<p>To use PostgreSQL with EF Core, install the <code>Npgsql.EntityFrameworkCore.PostgreSQL</code> package and call <code>UseNpgsql</code> with your connection string when you register the <code>DbContext</code>.
Migrations, LINQ queries, and change tracking work the same as with SQL Server.
Npgsql also exposes JSONB, arrays, and full-text search, while timestamp and naming conventions require deliberate choices.</p>
<p>PostgreSQL is not SQL Server with a different connection string.
A clean provider setup gives you those PostgreSQL capabilities without leaking database-specific details through the whole application.</p>
<h2>Why PostgreSQL With EF Core?</h2>
<p>PostgreSQL offers features that SQL Server doesn't - JSONB columns, native array types, full-text search, and range types. It's also free and runs everywhere. The Npgsql provider for EF Core gives you access to all of these features.</p>
<p><strong>Npgsql</strong> is the official PostgreSQL provider for EF Core, distributed as the <code>Npgsql.EntityFrameworkCore.PostgreSQL</code> NuGet package.
It is mature, actively maintained, and exposes PostgreSQL-specific functionality through EF Core's configuration and query APIs.</p>
<h2>Setting Up the Npgsql Provider</h2>
<p>If you don't have PostgreSQL running yet, Docker is the quickest way:</p>
<pre><code class="language-bash">docker run -d --name postgres \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  postgres:17
</code></pre>
<p>Install the NuGet package:</p>
<pre><code class="language-bash">dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
</code></pre>
<p>Configure your <a href="https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices"><strong>DbContext</strong></a>:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(builder.Configuration.GetConnectionString(&quot;Database&quot;)));
</code></pre>
<p>Connection string in <code>appsettings.json</code>:</p>
<pre><code class="language-json">{
  &quot;ConnectionStrings&quot;: {
    &quot;Database&quot;: &quot;Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=postgres&quot;
  }
}
</code></pre>
<p>That's all you need to get started. EF Core <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>migrations</strong></a> and queries work the same way as with SQL Server.</p>
<h2>Snake_case Naming Convention</h2>
<p>PostgreSQL conventions use <code>snake_case</code> for table and column names. By default, EF Core generates <code>PascalCase</code> names, which work but look out of place.</p>
<p>Use the naming conventions package:</p>
<pre><code class="language-bash">dotnet add package EFCore.NamingConventions
</code></pre>
<p>Configure it:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
           .UseSnakeCaseNamingConvention());
</code></pre>
<p>Now an entity like this:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public OrderStatus Status { get; set; }
    public decimal TotalAmount { get; set; }
}
</code></pre>
<p>Generates a table with <code>snake_case</code> columns:</p>
<pre><code class="language-sql">CREATE TABLE orders (
    id uuid NOT NULL,
    created_at timestamp with time zone NOT NULL,
    status integer NOT NULL,
    total_amount numeric NOT NULL,
    CONSTRAINT pk_orders PRIMARY KEY (id)
);
</code></pre>
<h2>JSONB Columns</h2>
<p>PostgreSQL's <code>jsonb</code> type stores JSON data in a binary format that supports indexing and querying. EF Core maps this natively with <a href="https://milanjovanovic.tech/blog/owned-types-ef-core-ddd"><strong>owned types</strong></a> (the Npgsql provider supports <code>ToJson</code> since version 8):</p>
<pre><code class="language-csharp">public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public ProductMetadata Metadata { get; set; }
}

public class ProductMetadata
{
    public string Brand { get; set; }
    public int? ReleaseYear { get; set; }
    public List&lt;string&gt; Tags { get; set; }
}
</code></pre>
<p>Stick to regular properties, primitive collections, and nested owned types inside the JSON type.
Dictionary properties are not supported in <code>ToJson</code> mappings.</p>
<p>Configure as JSON:</p>
<pre><code class="language-csharp">public void Configure(EntityTypeBuilder&lt;Product&gt; builder)
{
    builder.OwnsOne(p =&gt; p.Metadata, meta =&gt;
    {
        meta.ToJson();
    });
}
</code></pre>
<p>You can query into JSONB columns:</p>
<pre><code class="language-csharp">var products = await context.Products
    .Where(p =&gt; p.Metadata.Brand == &quot;Contoso&quot;)
    .ToListAsync();
</code></pre>
<p>EF Core translates this into a PostgreSQL JSON query. For more complex queries, you can use raw SQL with JSON operators.</p>
<h2>Array Types</h2>
<p>PostgreSQL natively supports array columns. Npgsql maps .NET arrays and lists directly:</p>
<pre><code class="language-csharp">public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string[] Tags { get; set; }
    public List&lt;int&gt; Ratings { get; set; }
}
</code></pre>
<p>No special configuration needed. EF Core generates:</p>
<pre><code class="language-sql">CREATE TABLE products (
    id uuid NOT NULL,
    name text NOT NULL,
    tags text[] NOT NULL,
    ratings integer[] NOT NULL
);
</code></pre>
<p>Query array columns with LINQ:</p>
<pre><code class="language-csharp">// Products that contain a specific tag
var products = await context.Products
    .Where(p =&gt; p.Tags.Contains(&quot;electronics&quot;))
    .ToListAsync();

// Products with any matching tag
var searchTags = new[] { &quot;electronics&quot;, &quot;sale&quot; };
var matching = await context.Products
    .Where(p =&gt; p.Tags.Any(t =&gt; searchTags.Contains(t)))
    .ToListAsync();
</code></pre>
<p>Arrays are great for simple lists of values where you don't need a separate table.</p>
<h2>Full-Text Search</h2>
<p>PostgreSQL has powerful built-in full-text search. Npgsql exposes it through EF Core:</p>
<pre><code class="language-csharp">var results = await context.Products
    .Where(p =&gt; EF.Functions.ToTsVector(&quot;english&quot;, p.Name + &quot; &quot; + p.Description)
        .Matches(EF.Functions.ToTsQuery(&quot;english&quot;, &quot;laptop &amp; gaming&quot;)))
    .ToListAsync();
</code></pre>
<p>For better performance, add a generated <code>tsvector</code> column:</p>
<pre><code class="language-csharp">public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public NpgsqlTsVector SearchVector { get; set; }
}
</code></pre>
<p>Configure it:</p>
<pre><code class="language-csharp">builder.Property(p =&gt; p.SearchVector)
    .HasComputedColumnSql(
        @&quot;to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, ''))&quot;,
        stored: true);

builder.HasIndex(p =&gt; p.SearchVector)
    .HasMethod(&quot;GIN&quot;);
</code></pre>
<p>Now queries use the precomputed index:</p>
<pre><code class="language-csharp">var results = await context.Products
    .Where(p =&gt; p.SearchVector.Matches(
        EF.Functions.ToTsQuery(&quot;english&quot;, &quot;laptop &amp; gaming&quot;)))
    .OrderByDescending(p =&gt; p.SearchVector.Rank(
        EF.Functions.ToTsQuery(&quot;english&quot;, &quot;laptop &amp; gaming&quot;)))
    .ToListAsync();
</code></pre>
<p>With an appropriate GIN index, PostgreSQL full-text search scales better than an unindexed <code>LIKE</code> or <code>ILIKE</code> scan on large datasets.</p>
<h2>PostgreSQL-Specific Features</h2>
<p>A few more features worth knowing:</p>
<h3>UUID Primary Keys</h3>
<p>PostgreSQL has native <code>uuid</code> support. Use <code>Guid</code> properties and they map to <code>uuid</code> columns:</p>
<pre><code class="language-csharp">builder.Property(o =&gt; o.Id)
    .HasDefaultValueSql(&quot;gen_random_uuid()&quot;);
</code></pre>
<h3>Enum Mapping</h3>
<p>Map .NET enums to PostgreSQL enum types:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString, npgsqlOptions =&gt;
        npgsqlOptions.MapEnum&lt;OrderStatus&gt;(&quot;order_status&quot;)));
</code></pre>
<h3>The UTC Timestamp Rule</h3>
<p>This is the one that surprises everyone migrating from SQL Server.
PostgreSQL's <code>timestamptz</code> stores instants in UTC, and since Npgsql 6, writing a <code>DateTime</code> with <code>Kind = Unspecified</code> or <code>Local</code> to a <code>timestamptz</code> column <strong>throws an exception</strong>.</p>
<p>Use <code>DateTime.UtcNow</code> for generated instants, or accept <code>DateTimeOffset</code> at the boundary and normalize it to offset zero before writing.
Npgsql maps <code>DateTimeOffset</code> to <code>timestamptz</code>, but rejects non-zero offsets because PostgreSQL stores the UTC instant rather than the original offset.</p>
<p>If you're porting a legacy codebase and can't fix every timestamp at once, there's an escape hatch that restores the old behavior:</p>
<pre><code class="language-csharp">// Opt back into pre-Npgsql-6 behavior (not recommended for new code)
AppContext.SetSwitch(&quot;Npgsql.EnableLegacyTimestampBehavior&quot;, true);
</code></pre>
<p>Treat that switch as a migration aid, not a solution.</p>
<h3>Stored Procedures and Functions</h3>
<p>PostgreSQL functions work well with EF Core too - I cover calling them (and when to use them) in <a href="https://milanjovanovic.tech/blog/using-stored-procedures-and-functions-with-ef-core-and-postgresql"><strong>stored procedures and functions with EF Core and PostgreSQL</strong></a>.</p>
<h3>Vector Search</h3>
<p>If you're building AI features, the <code>pgvector</code> extension turns PostgreSQL into a capable vector database.
See <a href="https://milanjovanovic.tech/blog/getting-started-with-pgvector-in-dotnet-for-simple-vector-search"><strong>getting started with pgvector in .NET</strong></a>.</p>
<h2>Summary</h2>
<p>Start with Npgsql and make naming and UTC conventions explicit at the model boundary.
Then use PostgreSQL features such as JSONB, arrays, full-text search, and native enums where they simplify the data model.
Provider-specific capabilities are an advantage when the choice is deliberate and isolated from unrelated application code.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Fixing "Cannot Write DateTime with Kind=Local" With Npgsql]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-postgresql-datetime-utc-error</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-postgresql-datetime-utc-error</guid>
            <pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Npgsql throws "Cannot write DateTime with Kind=Unspecified to PostgreSQL type timestamp with time zone" the first time a non-UTC DateTime reaches the database.]]></description>
            <content:encoded><![CDATA[<p>Npgsql refuses to write a <code>DateTime</code> whose <code>Kind</code> is <code>Local</code> or <code>Unspecified</code> into a <code>timestamp with time zone</code> column, because since version 6 that type maps strictly to UTC values.
A non-UTC <code>Kind</code> does not identify an absolute instant, so the driver reports an error rather than assuming a time zone.
The fix is to normalize instants to UTC at the boundary, not to enable the legacy timestamp switch.</p>
<p>Your app works on SQL Server, or on an older Npgsql version.
You point it at PostgreSQL with the current stack and the first save throws:</p>
<p>&quot;Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported.&quot;</p>
<p>Or the <code>Kind=Local</code> variant, same exception, different offender.
A search turns up a one-line fix, the <code>Npgsql.EnableLegacyTimestampBehavior</code> switch, at the top of every result.</p>
<p>Do not start there.
The exception is Npgsql refusing to guess what your ambiguous <code>DateTime</code> means, and the switch tells it to go back to guessing.
The real fix is making your <code>DateTime</code> values unambiguous, and it is not much more work.</p>
<h2>Why Npgsql 6+ Is Strict</h2>
<p>PostgreSQL has two timestamp types, and they mean different things:</p>
<ul>
<li><code>timestamp with time zone</code> (<code>timestamptz</code>): an <strong>absolute instant</strong>. Postgres normalizes it to UTC internally; no zone is stored.</li>
<li><code>timestamp without time zone</code> (<code>timestamp</code>): a <strong>wall-clock reading</strong> with no inherent zone. &quot;2026-07-03 09:00&quot; and good luck knowing where.</li>
</ul>
<p>.NET's <code>DateTime</code> carries a <code>Kind</code> flag: <code>Utc</code>, <code>Local</code>, or <code>Unspecified</code>.
Before Npgsql 6, the driver accepted ambiguous values and could let time-zone assumptions surface later as stored-data bugs.</p>
<p>Since Npgsql 6, the mapping is principled:</p>
<ul>
<li><code>DateTime</code> with <code>Kind=Utc</code> maps to <code>timestamptz</code>. This is the default mapping for <code>DateTime</code> properties in EF Core with Npgsql.</li>
<li><code>DateTime</code> with <code>Kind=Local</code> or <code>Unspecified</code> maps to <code>timestamp</code> only.</li>
<li>Writing a non-UTC <code>Kind</code> to a <code>timestamptz</code> column is an error, the one you are staring at, because interpreting it would require assuming a time zone.</li>
</ul>
<p>Reading is symmetric: <code>timestamptz</code> comes back as <code>Kind=Utc</code>.
The driver is enforcing a simple invariant: <strong>absolute instants cross the wire in UTC</strong>.</p>
<h2>Step 1: Find the Offending Value</h2>
<p>The exception tells you the Kind but not the property.
Typical sources, starting with the most common boundaries:</p>
<ul>
<li><strong><code>DateTime.Now</code></strong> anywhere in the codebase. It produces <code>Kind=Local</code>. Grep for it; every hit is a bug in a service that stores instants.</li>
<li><strong>JSON deserialization.</strong> A payload with <code>&quot;2026-07-03T09:00:00&quot;</code> (no offset) deserializes to <code>Kind=Unspecified</code>. API DTOs are the top source of <code>Unspecified</code> values.</li>
<li><strong>Database reads from other systems</strong>, CSV imports, and <code>DateTime.Parse</code> without <code>DateTimeStyles.AdjustToUniversal</code>.</li>
<li><strong>Values constructed with <code>new DateTime(...)</code></strong> without specifying a kind: <code>Unspecified</code> by default.</li>
<li><strong>Entities materialized by other ORMs or Dapper</strong> from <code>timestamp</code> columns.</li>
</ul>
<p>Temporarily enabling sensitive data logging in <a href="https://milanjovanovic.tech/blog/log-sql-queries-ef-core"><strong>EF Core query logging</strong></a> shows the parameter values, which usually pinpoints the property fast.</p>
<h2>Step 2: Normalize at the Boundary</h2>
<p>The durable fix is a rule: <strong>inside the application, instants are UTC</strong>.
Convert at the edges where non-UTC values enter.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-postgresql-datetime-utc-error/normalize-boundary.png" alt="Non-UTC DateTime sources (DateTime.Now with Kind Local, JSON input with Kind Unspecified, and new DateTime with Kind Unspecified) all pass through a single boundary conversion to UTC before being written to a timestamptz column">
<p><strong>Producing timestamps yourself</strong> is the easy case:</p>
<pre><code class="language-csharp">// wrong for stored instants
order.CreatedAt = DateTime.Now;

// right
order.CreatedAt = DateTime.UtcNow;
</code></pre>
<p>Better still, take time as a dependency with .NET 8's <code>TimeProvider</code> (<code>timeProvider.GetUtcNow()</code>), which also makes the code testable.</p>
<p><strong>API input</strong> deserves an explicit contract.
If clients send offsets (<code>2026-07-03T09:00:00+02:00</code>), bind to <code>DateTimeOffset</code> and convert:</p>
<pre><code class="language-csharp">public record CreateBookingRequest(DateTimeOffset StartsAt);

var booking = new Booking
{
    StartsAtUtc = request.StartsAt.UtcDateTime // Kind=Utc, unambiguous
};
</code></pre>
<p>If clients send zoneless wall-clock times, you must know the intended zone and say so in code:</p>
<pre><code class="language-csharp">var zone = TimeZoneInfo.FindSystemTimeZoneById(&quot;Europe/Oslo&quot;);

var utc = TimeZoneInfo.ConvertTimeToUtc(
    DateTime.SpecifyKind(request.StartsAt, DateTimeKind.Unspecified),
    zone);
</code></pre>
<p><strong>Values you already know are UTC</strong> but arrive as <code>Unspecified</code> (a UTC string without the <code>Z</code>, a read from a legacy <code>timestamp</code> column) just need their Kind stamped:</p>
<pre><code class="language-csharp">var utc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
</code></pre>
<p><code>SpecifyKind</code> does not convert; it asserts.
Only use it when the assertion is true.</p>
<h2>Step 3: Enforce It in the Model</h2>
<p>Boundary discipline decays as teams grow, so back it with a model-wide value converter that makes non-UTC values impossible to persist:</p>
<pre><code class="language-csharp">public class UtcDateTimeConverter : ValueConverter&lt;DateTime, DateTime&gt;
{
    public UtcDateTimeConverter()
        : base(
            v =&gt; v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
            v =&gt; DateTime.SpecifyKind(v, DateTimeKind.Utc))
    {
    }
}

// In your DbContext:
protected override void ConfigureConventions(
    ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties&lt;DateTime&gt;()
        .HaveConversion&lt;UtcDateTimeConverter&gt;();
}
</code></pre>
<p>One caveat to apply on purpose: <code>ToUniversalTime()</code> on an <code>Unspecified</code> value assumes the <strong>server's</strong> local zone, which is only correct if that assumption is.
If you would rather fail loudly than guess, throw in the converter for <code>Unspecified</code> instead of converting.
On containers pinned to UTC the distinction rarely bites, but decide, do not drift.</p>
<p>Registering converters model-wide like this is the same technique from <a href="https://milanjovanovic.tech/blog/ef-core-custom-conventions"><strong>custom model conventions in EF Core</strong></a>.</p>
<h2>What About the Legacy Switch?</h2>
<p>The escape hatch exists:</p>
<pre><code class="language-csharp">AppContext.SetSwitch(&quot;Npgsql.EnableLegacyTimestampBehavior&quot;, true);
</code></pre>
<p>It restores pre-6 behavior: no Kind validation, <code>timestamptz</code> values read back as <code>Kind=Local</code>, and every ambiguity silently accepted.
Legitimate use: a large legacy codebase mid-migration to Postgres, where you need the app running while you clean up call sites incrementally.
Set it, file the tech-debt issue, and remove it when the converter and boundary fixes land.</p>
<p>What it is not: a fix.
The exception was the only thing standing between you and timestamps that shift by your UTC offset depending on which server wrote them.</p>
<h2>Choosing Types Going Forward</h2>
<ul>
<li><strong>Instants</strong> (created-at, occurred-at, expires-at): <code>DateTime</code> in UTC or <code>DateTimeOffset</code>, column <code>timestamptz</code>. Both work with Npgsql; <code>DateTimeOffset</code> must have offset zero on write, and the offset is not stored. I compared the two types in <strong>DateTime vs DateTimeOffset in C#</strong>.</li>
<li><strong>Wall-clock values</strong> (a clinic's opening hour, a scheduled local delivery slot): <code>timestamp</code> column via <code>HasColumnType(&quot;timestamp without time zone&quot;)</code> with <code>Unspecified</code> Kind, plus a separate time zone id column. Forcing these to UTC destroys information; they are not instants.</li>
<li><strong>Dates and times alone</strong>: <code>DateOnly</code> and <code>TimeOnly</code> map cleanly to <code>date</code> and <code>time</code> with Npgsql, and dodge the whole Kind circus.</li>
</ul>
<p>If you are earlier in your Postgres journey, the setup fundamentals are in <a href="https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started"><strong>getting started with EF Core and PostgreSQL</strong></a>, and this error is worth solving properly before data accumulates, because rewriting mis-zoned historical timestamps later is somewhere between painful and impossible, the kind of surgery that needs a <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>carefully staged migration</strong></a>.</p>
<h2>Summary</h2>
<p>The exception is Npgsql enforcing a good invariant: <code>timestamptz</code> holds absolute instants, and absolute instants must arrive as UTC.
A <code>DateTime</code> with <code>Kind=Local</code> or <code>Unspecified</code> is a question, not an answer, and since version 6 the driver refuses to answer it for you.</p>
<p>Fix the sources: <code>UtcNow</code> (or <code>TimeProvider</code>) for generated timestamps, explicit offset or zone handling for external input, <code>SpecifyKind</code> only where UTC is already a fact.
Back it with a model-wide UTC converter so the rule enforces itself, and reserve the legacy switch for migration bridges with an expiry date.</p>
<p>Get the invariant in place early.
Every week it waits, more ambiguous timestamps land in your tables, and unlike code, stored data does not get fixed by a redeploy.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Migrations Best Practices in Production]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-migrations-best-practices</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-migrations-best-practices</guid>
            <pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core migrations are easy in development but tricky in production. Here are best practices for managing schema changes safely: idempotent scripts, migration…]]></description>
            <content:encoded><![CDATA[<p>Apply EF Core migrations in production from the deployment pipeline, using an idempotent SQL script or a migration bundle, and run that step before the application deploys.
Keep each migration to one logical change and review the generated SQL before it runs.
For changes that break old code, expand the schema first and contract it only after every deployed instance has moved to the new shape.</p>
<p>A migration is executable production code with permission to reshape your data.
Treating it as generated plumbing hides locking, data-loss, and deployment-order risks until the change reaches a real database.
Safe migrations are reviewed, scripted, tested, and deployed as deliberately as the application that depends on them.</p>
<h2>Migrations Are Production Code</h2>
<p>EF Core migrations work great on your local machine. You run <code>dotnet ef database update</code>, the schema changes, and life is good.</p>
<p>Production is different. You have:</p>
<ul>
<li>Multiple instances running the same database</li>
<li>Zero-downtime deployments to maintain</li>
<li>Rollback scenarios to plan for</li>
<li>Data to preserve (you can't just drop and recreate)</li>
<li>Team members creating conflicting migrations</li>
</ul>
<p>EF Core migrations need the same discipline as any other production code.
If you need a refresher on how migrations work mechanically, start with my <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>detailed EF Core migrations guide</strong></a> - this article focuses on running them safely in production.</p>
<h2>Creating Clean Migrations</h2>
<h3>One Migration Per Logical Change</h3>
<p>Don't combine unrelated schema changes. If you're adding a column to <code>Orders</code> <strong>and</strong> creating a new <code>Payments</code> table, make two separate migrations:</p>
<pre><code class="language-bash">dotnet ef migrations add AddShippingAddressToOrders
dotnet ef migrations add CreatePaymentsTable
</code></pre>
<p>Smaller migrations are easier to review, easier to roll back, and cause fewer merge conflicts.</p>
<h3>Review Generated SQL</h3>
<p>Always inspect what EF Core generates:</p>
<pre><code class="language-bash">dotnet ef migrations script --idempotent
</code></pre>
<p>The idempotent flag checks the migrations history table and skips migrations that completed successfully.
It does not make every statement independently retryable: a transaction-suppressed operation can succeed even if the migration later fails before its history row is recorded.</p>
<p>Review the output for:</p>
<ul>
<li>Unexpected <code>DROP</code> statements</li>
<li>Missing indexes</li>
<li>Data loss operations (column type changes, table drops)</li>
</ul>
<h3>Name Migrations Descriptively</h3>
<pre><code class="language-text">✗ Migration1, Update2, Fix3
✓ AddShippingAddressToOrders, CreatePaymentMethodsTable, AddIndexOnOrderStatus
</code></pre>
<p>Migration names should describe the change. Future you will thank present you.</p>
<h2>How Should You Apply Migrations in Production?</h2>
<h3>Option 1: SQL Scripts in CI/CD</h3>
<p>Generate an idempotent SQL script and run it as a deployment step:</p>
<pre><code class="language-bash">dotnet ef migrations script --idempotent -o migrations.sql
</code></pre>
<p>Then apply it (with <code>psql</code> for PostgreSQL, or the equivalent client for your database):</p>
<pre><code class="language-bash">psql &quot;$DATABASE_CONNECTION_STRING&quot; -f migrations.sql
</code></pre>
<p><strong>Pros:</strong> Full control, works with any deployment tool, can be reviewed before execution.
<strong>Cons:</strong> Manual pipeline setup.</p>
<h3>Option 2: Migration Bundles</h3>
<p>EF Core 6+ supports migration bundles - self-contained executables that apply migrations:</p>
<pre><code class="language-bash">dotnet ef migrations bundle --self-contained -o efbundle
</code></pre>
<p>Then in your deployment:</p>
<pre><code class="language-bash">./efbundle --connection &quot;Server=myserver;Database=mydb;...&quot;
</code></pre>
<p><strong>Pros:</strong> No SDK needed at deployment time, single file, idempotent by default.
<strong>Cons:</strong> Bundle needs to match your target runtime.</p>
<h3>Option 3: Migrate on Startup (Use With Caution)</h3>
<pre><code class="language-csharp">using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
await dbContext.Database.MigrateAsync();
</code></pre>
<p><strong>Pros:</strong> Simple, automatic.
<strong>Cons:</strong> Dangerous with multiple instances. Two instances starting simultaneously can conflict. Startup is slower. Failed migration blocks the application from starting.</p>
<p>I only recommend this for single-instance deployments or development environments.</p>
<h2>Handling Data Migrations</h2>
<p>Schema migrations change structure. Data migrations change content. Keep them separate.</p>
<p><strong>Don't do this in a migration:</strong></p>
<pre><code class="language-csharp">// Bad - mixing schema and complex data changes
migrationBuilder.Sql(@&quot;
    UPDATE Orders
    SET Status = CASE
        WHEN OldStatus = 1 THEN 'Draft'
        WHEN OldStatus = 2 THEN 'Confirmed'
        ELSE 'Unknown'
    END;
    ALTER TABLE Orders DROP COLUMN OldStatus;
&quot;);
</code></pre>
<p><strong>Do this instead:</strong></p>
<ol>
<li>Migration 1: Add new column</li>
<li>Deploy data migration script (or background job)</li>
<li>Migration 2: Drop old column</li>
</ol>
<p>This multi-step approach is safer because you can verify the data migration worked before dropping the old column.</p>
<h2>Zero-Downtime Migrations</h2>
<p>Adding a column? Easy - old code ignores it, new code uses it.</p>
<p>Removing a column? Dangerous - old code might still reference it during rolling deployments.</p>
<p>The safe pattern for breaking changes:</p>
<p><strong>Step 1: Expand</strong> - Add the new column/table. Deploy code that writes to both old and new.</p>
<p><strong>Step 2: Migrate</strong> - Backfill data from old to new.</p>
<p><strong>Step 3: Contract</strong> - Deploy code that only uses the new column. Then drop the old one.</p>
<p>This is called the <strong>expand-contract pattern</strong>. It ensures both the old and new versions of your application work at every step.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-migrations-best-practices/expand-contract.png" alt="Three-step expand-contract pattern: expand by adding the new column and writing to both, migrate by backfilling old to new, then contract by using the new column only and dropping the old one">
<p>I go deeper on this in <a href="https://milanjovanovic.tech/blog/zero-downtime-migrations-ef-core"><strong>zero-downtime migrations with EF Core</strong></a>, and there's a <a href="https://milanjovanovic.tech/blog/a-practical-demo-of-zero-downtime-migrations-using-password-hashing"><strong>practical demo using password hashing</strong></a> if you want to see the pattern applied end to end.</p>
<h2>Handling Merge Conflicts</h2>
<p>When two developers create migrations from the same base:</p>
<ol>
<li>Developer A creates <code>AddShippingAddress</code> (snapshot → A)</li>
<li>Developer B creates <code>AddPaymentMethod</code> (snapshot → B)</li>
<li>When merged, the model snapshot has conflicts</li>
</ol>
<p>Fix: after merging, recreate Developer B's migration:</p>
<pre><code class="language-bash"># Remove B's migration
dotnet ef migrations remove

# Add it back (now based on A's snapshot)
dotnet ef migrations add AddPaymentMethod
</code></pre>
<p><strong>Prevention:</strong> coordinate migrations. Only one migration branch should be in-flight at a time, or use a migration lock file.</p>
<h2>Seeding Reference Data</h2>
<p>Use migrations for reference data that your application requires:</p>
<pre><code class="language-csharp">migrationBuilder.InsertData(
    table: &quot;OrderStatuses&quot;,
    columns: new[] { &quot;Id&quot;, &quot;Name&quot; },
    values: new object[,]
    {
        { 1, &quot;Draft&quot; },
        { 2, &quot;Confirmed&quot; },
        { 3, &quot;Shipped&quot; },
        { 4, &quot;Delivered&quot; },
        { 5, &quot;Cancelled&quot; }
    });
</code></pre>
<p>For large datasets or dynamic data, use a separate seeding tool or script - not migrations.
I compare the options (including <code>UseSeeding</code> from EF Core 9) in <a href="https://milanjovanovic.tech/blog/seeding-data-ef-core"><strong>seeding data with EF Core</strong></a>.</p>
<h2>Index Management</h2>
<p>Always add indexes through migrations. EF Core creates some automatically, but you should be explicit about performance-critical ones:</p>
<pre><code class="language-csharp">migrationBuilder.CreateIndex(
    name: &quot;IX_Orders_CustomerId_Status&quot;,
    table: &quot;Orders&quot;,
    columns: new[] { &quot;CustomerId&quot;, &quot;Status&quot; });

migrationBuilder.CreateIndex(
    name: &quot;IX_Orders_CreatedAt&quot;,
    table: &quot;Orders&quot;,
    column: &quot;CreatedAt&quot;,
    descending: new[] { true });
</code></pre>
<p>Create indexes <strong>concurrently</strong> on PostgreSQL when a normal build would block writes:</p>
<pre><code class="language-csharp">migrationBuilder.Sql(
    @&quot;CREATE INDEX CONCURRENTLY &quot;&quot;IX_Orders_CreatedAt&quot;&quot;
      ON &quot;&quot;Orders&quot;&quot; (&quot;&quot;CreatedAt&quot;&quot; DESC);&quot;,
    suppressTransaction: true);
</code></pre>
<p>PostgreSQL rejects <code>CREATE INDEX CONCURRENTLY</code> inside a transaction, so <code>suppressTransaction: true</code> is required.
Put this operation in a dedicated migration and do not let the deployment runner wrap the generated script in an outer <code>BEGIN</code>/<code>COMMIT</code> block.
Concurrent creation performs extra scans and can wait on long-running transactions, so it avoids blocking writes rather than making the build free of operational impact.</p>
<p>Transaction suppression also makes the migration non-atomic.
If the build or a later command fails, PostgreSQL can leave an invalid or already-created index while EF has no migration-history row.
Inspect the index state and drop an invalid index before retrying; do not hide the mismatch with <code>IF NOT EXISTS</code>.</p>
<h2>My Recommended Pipeline</h2>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-migrations-best-practices/deploy-pipeline.png" alt="Recommended deployment pipeline: developer creates a migration locally, the PR includes the migration and reviewed SQL, CI applies it to a test database, the pipeline generates an idempotent script that runs against staging then production, and only then the application deploys">
<p>The key: <strong>migrations run before the application deploys</strong>. The database schema is always ahead of the application code.</p>
<h2>Summary</h2>
<p>Keep each migration focused, review the generated SQL, and test it against a realistic copy of the database.
Deploy idempotent scripts or bundles from the pipeline rather than letting every application instance race at startup.
For breaking changes, expand the schema first and remove the old shape only after all deployed code has moved away from it.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Fixing PendingModelChangesWarning in EF Core 9]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-pending-model-changes-error</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-pending-model-changes-error</guid>
            <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core 9 throws "The model for context has pending changes" when your model no longer matches the last migration. Sometimes you really did forget a migration.]]></description>
            <content:encoded><![CDATA[<p><code>PendingModelChangesWarning</code> means the model your code builds no longer matches the model snapshot recorded by your last migration, and EF Core 9 raises it as an error when migrations run.
If you really did change the model, the fix is the migration you forgot.
If the error survives an empty migration, the cause is usually dynamic values in <code>HasData</code> seed data, which produce a different model on every build.</p>
<p>You upgrade a project to EF Core 9, run it, and <code>MigrateAsync</code> throws:</p>
<p>&quot;The model for context 'AppDbContext' has pending changes. Add a new migration before updating the database.&quot;</p>
<p>You run <code>dotnet ef migrations add Whatever</code>, and the generated migration is empty, or contains nothing but updated seed rows.
You apply it, and next week the error is back.</p>
<p>Welcome to one of the most-reported EF Core 9 upgrade issues.
The check itself is a good idea: it catches genuinely missing migrations before they become production schema drift.
But its most common trigger is not a forgotten migration.
It is seed data that changes every time the model is built.</p>
<h2>What Does EF Core 9 Actually Check?</h2>
<p>Every <a href="https://milanjovanovic.tech/blog/efcore-migrations-a-detailed-guide"><strong>migration</strong></a> you add updates the <strong>model snapshot</strong> (<code>AppDbContextModelSnapshot.cs</code>), a C# record of the model as of that migration.
When migrations run, EF Core 9 compares the model your code builds right now against that snapshot.
Any difference means: the last migration does not describe your current model.</p>
<p>Before EF 9 this drift was silent, and <code>Migrate()</code> happily brought the database up to the last migration while your model quietly disagreed with it.
EF 9 promotes the mismatch from silent to fatal (when applying migrations at runtime) via <code>PendingModelChangesWarning</code>.</p>
<p>So the error has exactly two families of causes:</p>
<ol>
<li><strong>Real pending changes.</strong> You edited an entity or configuration and forgot to add a migration. The fix is the obvious one.</li>
<li><strong>A model that never stabilizes.</strong> Something in model building produces a different model on every run, so no migration can ever catch up. This is the sneaky one.</li>
</ol>
<h2>First, Diagnose Honestly</h2>
<p>Add a migration and read it:</p>
<pre><code class="language-bash">dotnet ef migrations add Probe
</code></pre>
<ul>
<li><strong>The migration has real operations</strong> (columns, indexes, tables): you had genuine drift. Rename it properly or keep it, apply it, done.</li>
<li><strong>The migration is empty or touches only <code>UpdateData</code> on seed rows with new timestamps or GUIDs</strong>: you have the dynamic-seed problem. Remove the probe (<code>dotnet ef migrations remove</code>) and read on.</li>
</ul>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-pending-model-changes-error/diagnosis.png" alt="Diagnosis flow: add a probe migration, then branch on its contents. Real columns, indexes, or tables mean genuine drift you keep and apply. An empty migration or one with only UpdateData on new timestamps and GUIDs means an unstable model from dynamic HasData values, fixed by hardcoding seeds or moving to UseSeeding">
<p>The <code>UpdateData</code> calls are the tell.
Look at what changed in them: a <code>CreatedAt</code> becoming a slightly later <code>CreatedAt</code>, or a key GUID becoming a different GUID.
That value is computed at model build time.</p>
<h2>The Sneaky Trigger: Dynamic Values in HasData</h2>
<p><code>HasData</code> seed data is <strong>part of the model</strong>.
This compiles, works in EF 8, and is a time bomb:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Role&gt;().HasData(
    new Role
    {
        Id = Guid.NewGuid(),                // new value every model build
        Name = &quot;Admin&quot;,
        CreatedAtUtc = DateTime.UtcNow      // new value every model build
    });
</code></pre>
<p>Every time EF Core builds the model, <code>Guid.NewGuid()</code> and <code>DateTime.UtcNow</code> produce fresh values.
The snapshot recorded yesterday's values.
The comparison can never succeed, so the pending-changes error is permanent, and every migration you add &quot;fixes&quot; it only until the next model build.</p>
<p>The same bug hides in subtler outfits:</p>
<ul>
<li><code>Environment.MachineName</code>, <code>Random</code>, or config-dependent values in seed rows.</li>
<li>Value converters or default values computed with non-deterministic expressions.</li>
<li>Seed entities whose constructor sets <code>CreatedAt = DateTime.UtcNow</code> internally, so the literal in <code>HasData</code> looks innocent.</li>
</ul>
<h2>Fix 1: Make Seed Values Constant</h2>
<p><code>HasData</code> was always designed for static, hardcoded data with explicit keys.
Give it exactly that:</p>
<pre><code class="language-csharp">modelBuilder.Entity&lt;Role&gt;().HasData(
    new Role
    {
        Id = Guid.Parse(&quot;8f3a2c1e-5b74-4d20-9c6f-1a2b3c4d5e6f&quot;),
        Name = &quot;Admin&quot;,
        CreatedAtUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
    });
</code></pre>
<p>Stable values, stable model, and the snapshot matches forever.
If typing GUIDs offends you, generate them once and paste them; the point is that they never change again.
This constraint is also why <code>HasData</code> should stay small: reference data like roles, statuses, and countries, not test fixtures.</p>
<h2>Fix 2: Move Seeding Out of the Model (EF 9's UseSeeding)</h2>
<p>EF Core 9 added the better tool for anything dynamic: seeding callbacks that run as part of <code>EnsureCreated</code>/<code>Migrate</code> flows but live <strong>outside</strong> the model, so nothing about them affects the snapshot:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options
        .UseNpgsql(connectionString)
        .UseAsyncSeeding(async (context, _, ct) =&gt;
        {
            var adminExists = await context.Set&lt;Role&gt;()
                .AnyAsync(r =&gt; r.Name == &quot;Admin&quot;, ct);

            if (!adminExists)
            {
                context.Set&lt;Role&gt;().Add(new Role
                {
                    Id = Guid.NewGuid(),          // fine here
                    Name = &quot;Admin&quot;,
                    CreatedAtUtc = DateTime.UtcNow // also fine
                });

                await context.SaveChangesAsync(ct);
            }
        })
        .UseSeeding((context, _) =&gt;
        {
            // synchronous twin, used by EnsureCreated and design-time tooling
        }));
</code></pre>
<p>Because the callback is ordinary code against the context, dynamic values, lookups, and conditional logic are all legal.
The tradeoff is that it runs where migrations run; if you apply migrations from a pipeline instead of the app (which you should, see <a href="https://milanjovanovic.tech/blog/ef-core-migration-bundles"><strong>migration bundles</strong></a>), run your seeder as an explicit step in that pipeline or at app startup as idempotent code.</p>
<p>I compared all the seeding options, <code>HasData</code>, seeding callbacks, and hand-rolled startup seeders, in <a href="https://milanjovanovic.tech/blog/seeding-data-ef-core"><strong>seeding data in EF Core</strong></a>.</p>
<h2>Fix 3 (Last Resort): Suppress the Warning</h2>
<p>If you are mid-upgrade and need the app running today:</p>
<pre><code class="language-csharp">options.ConfigureWarnings(w =&gt;
    w.Ignore(RelationalEventId.PendingModelChangesWarning));
</code></pre>
<p>Be honest about what this does: it turns the drift detector back off, EF 8 style.
The real missing-migration bug it exists to catch, someone edits an entity and ships without a migration, sails through silently again.
Suppress it as a bridge, fix the seed data, then remove the suppression.</p>
<p>A better long-term guard is failing CI when the model drifts.
One option is running <code>dotnet ef migrations has-pending-model-changes</code> in the pipeline:</p>
<pre><code class="language-bash">dotnet ef migrations has-pending-model-changes
</code></pre>
<p>The command exits non-zero when pending changes exist, so it fails the pipeline step on its own.
Or assert the same in a test through <code>context.Database.HasPendingModelChanges()</code>.
That converts the whole class of problem into a red pull request, which is where schema mistakes are cheapest, in line with <a href="https://milanjovanovic.tech/blog/ef-core-migrations-best-practices"><strong>EF Core migrations best practices</strong></a>.</p>
<h2>Other Legitimate Causes Worth Ruling Out</h2>
<p>If your seeds are static and the error persists, check for:</p>
<ul>
<li><strong>Provider or version switches.</strong> Building the model with a different provider (SQL Server locally, <a href="https://milanjovanovic.tech/blog/ef-core-postgresql-getting-started"><strong>PostgreSQL</strong></a> in CI) produces provider-specific model differences against a snapshot generated with the other one. One provider per snapshot lineage.</li>
<li><strong>Conditional model building.</strong> <code>if</code> statements in <code>OnModelCreating</code> keyed on environment or config make the model non-deterministic across machines. Push that variability out of the model.</li>
<li><strong>Manually edited snapshots.</strong> A merge conflict resolved by hand-editing <code>ModelSnapshot.cs</code> can leave it describing a model no code produces. Regenerate by removing and re-adding the latest migration in a clean state, per <a href="https://milanjovanovic.tech/blog/ef-core-migration-rollback"><strong>how to roll back an EF Core migration</strong></a>.</li>
</ul>
<h2>Summary</h2>
<p><code>PendingModelChangesWarning</code> is EF Core 9 refusing to migrate a database from a model that has drifted past its last migration.
When the drift is real, the fix is the migration you forgot.
When the error will not die and every probe migration just rewrites seed timestamps and GUIDs, the model itself is unstable, and dynamic values in <code>HasData</code> are the usual culprit.</p>
<p>Hardcode seed values, or better, move dynamic seeding into EF 9's <code>UseSeeding</code>/<code>UseAsyncSeeding</code> callbacks where it belongs.
Save the warning suppression for upgrade bridges, and let CI catch pending model changes so this error never gets another chance to page you.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Fixing "The Instance of Entity Type Cannot Be Tracked" in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-entity-already-tracked-error</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-entity-already-tracked-error</guid>
            <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The "instance of entity type cannot be tracked because another instance with the same key value is already being tracked" error means two objects with the same…]]></description>
            <content:encoded><![CDATA[<p>The &quot;instance of entity type cannot be tracked&quot; error means a <code>DbContext</code> was asked to track two different objects with the same entity type and key value.
EF Core allows only one tracked instance per key, so <code>Attach</code>, <code>Update</code>, or <code>Add</code> throws when an earlier query already tracked that row.
The right default is to copy the incoming values onto the tracked instance with <code>Entry(existing).CurrentValues.SetValues(incoming)</code>.</p>
<p>The full error reads: &quot;The instance of entity type 'Order' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.&quot;</p>
<p>It shows up when a controller loads an entity for validation and then calls <code>Update</code> with a mapped DTO.
Or when a test seeds data and the code under test attaches its own copy.
Or in a background job that processes the same entity twice in one batch.</p>
<p>The reflexive fix is to scatter <code>AsNoTracking</code> until the error goes away.
That works about as well as removing the battery from a smoke alarm.
Here is what the error actually means and the fixes that address it.</p>
<h2>What Does the Error Mean?</h2>
<p>A <code>DbContext</code> enforces <strong>identity resolution</strong>: for a given entity type and key value, it tracks at most one instance.
That is a feature.
It is what lets EF Core figure out which row to update and guarantees that two queries for order 42 in the same context hand you the same object.</p>
<p>The error is EF Core telling you a second instance with the same key tried to enter the change tracker.
Something earlier put instance A in the tracker, and now <code>Attach</code>, <code>Update</code>, or <code>Add</code> is bringing in instance B with the same key.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-entity-already-tracked-error/tracking-collision.png" alt="A loaded query tracks instance A with key 42, then a mapped DTO produces instance B with the same key 42, and calling Attach or Update on the change tracker throws the cannot be tracked error">
<p>The important mental shift: the problem is never the line that throws.
It is the earlier line that left instance A tracked.
I covered how tracking works under the hood in <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core"><strong>understanding the EF Core change tracker</strong></a>.</p>
<h2>The Classic Reproduction</h2>
<p>Almost every occurrence reduces to this shape:</p>
<pre><code class="language-csharp">public async Task UpdateProduct(Guid id, ProductDto dto)
{
    // Instance A enters the change tracker
    var product = await context.Products.FirstAsync(p =&gt; p.Id == id);

    if (product.IsArchived)
    {
        throw new InvalidOperationException(&quot;Archived products are read-only.&quot;);
    }

    // Instance B: a different object, same key. Throws.
    var updated = dto.ToEntity(id);
    context.Products.Update(updated);

    await context.SaveChangesAsync();
}
</code></pre>
<p><code>FirstAsync</code> tracked instance A.
<code>Update(updated)</code> tries to track instance B with the same key.
Collision.</p>
<p>Other common variants of the same disease:</p>
<ul>
<li>Repositories that load an aggregate for a check, then a service that attaches a mapper-produced copy.</li>
<li>Seeding an entity in an integration test with the same context the handler uses.</li>
<li>A long-lived context in a <a href="https://milanjovanovic.tech/blog/dbcontext-is-not-thread-safe-parallelizing-ef-core-queries-the-right-way"><strong>background job</strong></a> accumulating tracked entities across iterations until a duplicate arrives.</li>
</ul>
<h2>Fix 1: Update the Tracked Instance (the Right Default)</h2>
<p>If the context already tracks the entity, do not attach a second copy.
Copy the incoming values onto the tracked instance:</p>
<pre><code class="language-csharp">public async Task UpdateProduct(Guid id, ProductDto dto)
{
    var product = await context.Products.FirstAsync(p =&gt; p.Id == id);

    if (product.IsArchived)
    {
        throw new InvalidOperationException(&quot;Archived products are read-only.&quot;);
    }

    context.Entry(product).CurrentValues.SetValues(dto);

    await context.SaveChangesAsync();
}
</code></pre>
<p><code>CurrentValues.SetValues</code> copies matching scalar properties from any object (entity, DTO, anonymous type) onto the tracked entity.
Only properties that actually changed are marked modified, so the generated <code>UPDATE</code> touches only real changes.
No second instance, no collision, and a smaller SQL statement than <code>Update</code> would produce.</p>
<p>For domain models with behavior, skip <code>SetValues</code> and call the entity's own methods (<code>product.Rename(dto.Name)</code>).
Same principle: mutate the tracked instance instead of importing a rival.</p>
<h2>Fix 2: Check the Tracker Before Attaching</h2>
<p>Sometimes you receive a detached entity and legitimately do not know whether the context tracks a copy.
Resolve through <code>Local</code> first:</p>
<pre><code class="language-csharp">public void Upsert(Product incoming)
{
    var tracked = context.Products.Local
        .FirstOrDefault(p =&gt; p.Id == incoming.Id);

    if (tracked is not null)
    {
        context.Entry(tracked).CurrentValues.SetValues(incoming);
    }
    else
    {
        context.Products.Update(incoming);
    }
}
</code></pre>
<p><code>Local</code> looks only at the change tracker, no database round trip.
This is the pattern for generic repository code where you cannot control what callers loaded earlier.</p>
<h2>Fix 3: Stop Tracking What You Only Read</h2>
<p>If the earlier load was purely for validation or display, it never needed tracking:</p>
<pre><code class="language-csharp">var product = await context.Products
    .AsNoTracking()
    .FirstAsync(p =&gt; p.Id == id);
</code></pre>
<p>This is the legitimate use of <code>AsNoTracking</code>: declaring that a read is a read.
The anti-pattern is adding it reactively wherever the exception pops, which litters the codebase and eventually breaks a code path that relied on tracking.
The distinction, plus what identity resolution does for you, is the subject of <a href="https://milanjovanovic.tech/blog/ef-core-asnotracking-identity-resolution"><strong>AsNoTracking and identity resolution</strong></a>.</p>
<p>Note that <code>FindAsync</code> is often the better tool for load-then-modify flows, since it returns the already-tracked instance when there is one instead of colliding with it.
More on that in <a href="https://milanjovanovic.tech/blog/ef-core-find-vs-firstordefault"><strong>Find vs FirstOrDefault</strong></a>.</p>
<h2>Fix 4: Clear the Tracker in Long-Lived Contexts</h2>
<p>Batch jobs that reuse one context across thousands of iterations accumulate tracked entities.
Eventually the same key comes around twice and throws.
Between logical units of work, reset:</p>
<pre><code class="language-csharp">foreach (var batch in batches)
{
    await ProcessBatch(context, batch);
    await context.SaveChangesAsync();

    context.ChangeTracker.Clear();
}
</code></pre>
<p><code>ChangeTracker.Clear()</code> detaches everything efficiently.
It also caps memory growth, which matters as much as the exception in long-running processing.
Better yet, create a short-lived context per batch with <code>IDbContextFactory&lt;T&gt;</code>, which also plays nicely with <a href="https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling"><strong>DbContext pooling</strong></a>.</p>
<h2>The Fix That Is Not a Fix</h2>
<p>You will find advice to set the entry state manually:</p>
<pre><code class="language-csharp">context.Entry(updated).State = EntityState.Modified;
</code></pre>
<p>This throws the same exception when a rival instance is tracked, so it is not even a workaround, and when it does work it marks every property modified, producing full-row updates.
The same goes for disabling tracking globally with <code>ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking</code>: your updates keep working only until some code path expects tracked entities, and then you have a subtler bug than the one you started with.</p>
<h2>Summary</h2>
<p>&quot;Cannot be tracked&quot; means one context, one key, two objects.
The context is right to refuse; identity resolution is what makes <code>SaveChanges</code> trustworthy.</p>
<p>Find the earlier load that left the first instance tracked.
Then pick the fix that matches intent: copy values onto the tracked instance with <code>SetValues</code> (the right default for update endpoints), resolve through <code>Local</code> when you receive detached entities, use <code>AsNoTracking</code> for loads that were never going to save, and <code>ChangeTracker.Clear()</code> between batches in long-lived contexts.</p>
<p>If the error keeps reappearing across a codebase, the root cause is usually architectural: multiple layers each loading their own copy of the same aggregate.
One load per unit of work, flowing through the call stack, makes the whole class of error disappear.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Compiled Models for Faster Startup]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-compiled-models</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-compiled-models</guid>
            <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Large DbContexts pay a model-building tax on first use, and it grows with every entity you add.]]></description>
            <content:encoded><![CDATA[<p>A <strong>compiled model</strong> is your <code>DbContext</code> model generated ahead of time as C# source by <code>dotnet ef dbcontext optimize</code> and loaded with <code>UseModel</code>, instead of being built by reflection on first use.
It removes the one-time model building cost and nothing else, so it pays off for large models on cold-start-sensitive infrastructure.
Global query filters are not supported, which rules out most soft delete and multi-tenant models.</p>
<p>The first query your app sends through a <code>DbContext</code> is slow, and it has nothing to do with the database.
Before EF Core can translate anything, it has to <strong>build the model</strong>: run <code>OnModelCreating</code>, apply every configuration, discover every entity, navigation, and index via reflection, and validate the result.</p>
<p>For a 20-entity model, that is noise.
For a 300-entity model, it can be seconds of cold start, paid on the first request after every deploy, scale-out, or scale-from-zero wake-up.
Compiled models move that entire cost to build time.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-compiled-models/compiled-model-startup.png" alt="On first DbContext use, the default path runs OnModelCreating and reflects over the model for seconds of cold start, while a compiled model loads the pregenerated model for a fast cold start">
<h2>Where the Time Actually Goes</h2>
<p>Model building happens <strong>once per model</strong>, lazily, on first use of the context.
EF caches the result, so request two is fast; this is strictly a cold start tax.</p>
<p>The cost scales with model complexity: entity count, relationships, inheritance hierarchies, conventions to run over all of it.
Microsoft's own benchmarks used a synthetic model with 449 entity types and 6,390 properties, where model preparation on first query took around 2 seconds, and a compiled model cut that phase by roughly 10x.
In practice, under about 100 entities you will barely notice; past a couple hundred, the model build starts to dominate cold start ahead of even JIT and connection warmup.</p>
<p>Before optimizing, measure your own number.
The cheapest way is logging around the first materialized query:</p>
<pre><code class="language-csharp">var stopwatch = Stopwatch.StartNew();

await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService&lt;AppDbContext&gt;();
    _ = db.Model; // forces the model build, nothing else
}

app.Logger.LogInformation(
    &quot;Model build took {Elapsed} ms&quot;, stopwatch.ElapsedMilliseconds);
</code></pre>
<p>Accessing <code>db.Model</code> isolates model building from query compilation and database I/O, so you know exactly what a compiled model would buy you.
If that number is 40 ms, close this tab and go optimize something real.
If it is 900 ms and you run on scale-to-zero infrastructure, keep reading.</p>
<h2>Generating a Compiled Model</h2>
<p>The generator is part of the <code>dotnet ef</code> tooling:</p>
<pre><code class="language-bash">dotnet tool update -g dotnet-ef

dotnet ef dbcontext optimize \
  --output-dir CompiledModels \
  --namespace MyApp.Infrastructure.CompiledModels
</code></pre>
<p>This runs your <code>OnModelCreating</code> once, at design time, and emits the finished model as C# source files into <code>CompiledModels/</code>.
You will see one file per entity type plus an <code>AppDbContextModel</code> entry point.
The files are large and generated; commit them, but exclude them from coverage and review noise.</p>
<p>Wire it up in your context options:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options
        .UseNpgsql(connectionString)
        .UseModel(MyApp.Infrastructure.CompiledModels.AppDbContextModel.Instance));
</code></pre>
<p>At startup, EF now loads the pregenerated model object instead of reflecting its way through your configuration.
<code>OnModelCreating</code> simply never runs at runtime.</p>
<p>Two scope notes to set expectations:</p>
<ul>
<li>This affects <strong>model building only</strong>. Query translation, compilation, and execution are untouched. If your problem is slow queries, you want <a href="https://milanjovanovic.tech/blog/unleash-ef-core-performance-with-compiled-queries"><strong>compiled queries</strong></a> and the usual <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>query performance work</strong></a>, not compiled models. The names are similar, the features are unrelated.</li>
<li>It combines well with the other cold start levers, <strong>ReadyToRun and trimming</strong>, because each one removes a different chunk of first-request latency.</li>
</ul>
<h2>The Restrictions That Decide Everything</h2>
<p>Compiled models are not a free checkbox, and the limitations are not edge cases.</p>
<p><strong>Global query filters are not supported.</strong>
This is the big one.
If your model calls <code>HasQueryFilter</code> anywhere, <code>dotnet ef dbcontext optimize</code> refuses to generate.
And <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core"><strong>global query filters</strong></a> are load-bearing in a lot of real systems: they are the standard mechanism for <a href="https://milanjovanovic.tech/blog/implementing-soft-delete-with-ef-core"><strong>soft delete</strong></a> and for <a href="https://milanjovanovic.tech/blog/multi-tenant-applications-with-ef-core"><strong>multi-tenant row filtering</strong></a>.</p>
<p>Be careful with the failure mode here.
The tool refusing at generation time is the good outcome.
The dangerous path is a team that removes the filters to &quot;make optimize work&quot;, or reworks them into repeating <code>Where</code> clauses that someone eventually forgets.
A missing tenant filter is not a performance bug, it is a data leak.
If query filters carry security semantics in the application, that limitation alone disqualifies compiled models until the filter can be expressed safely.</p>
<p><strong>Lazy-loading and change-tracking proxies are not supported.</strong>
<code>UseLazyLoadingProxies</code> and <code>UseChangeTrackingProxies</code> do not work with a compiled model.
If you lean on lazy loading, that is a second disqualifier (and, separately, an invitation to revisit <a href="https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core"><strong>loading strategies</strong></a>).</p>
<p><strong>Custom <code>IModelCacheKeyFactory</code> scenarios do not fit.</strong>
The classic use is dynamic per-tenant model variation (different schemas per tenant, for example).
A compiled model is one frozen model; &quot;the model depends on runtime state&quot; is the opposite of that.</p>
<p><strong>The model is a snapshot, and it goes stale silently.</strong>
Add a property, change a relationship, tweak a value conversion, and the compiled model keeps loading happily, describing the <strong>old</strong> model.
EF does not detect the drift.
The symptoms are downstream and confusing: missing columns in generated SQL, or a <a href="https://milanjovanovic.tech/blog/ef-core-pending-model-changes-error"><strong>pending model changes error</strong></a> from migrations that seems to contradict the code in front of you.</p>
<h2>Keeping the Snapshot Fresh</h2>
<p>Staleness is a process problem, so fix it with process:</p>
<p><strong>EF Core 9+: let MSBuild do it.</strong>
The <code>Microsoft.EntityFrameworkCore.Tasks</code> package regenerates the compiled model during build:</p>
<pre><code class="language-bash">dotnet add package Microsoft.EntityFrameworkCore.Tasks
</code></pre>
<pre><code class="language-xml">&lt;PropertyGroup&gt;
  &lt;EFOptimizeContext&gt;true&lt;/EFOptimizeContext&gt;
&lt;/PropertyGroup&gt;
</code></pre>
<p>With that in place the snapshot can never drift from the code that produced it, which converts the worst limitation into a non-issue.
If you are on EF 8 or earlier, the manual equivalent is a CI step: regenerate with <code>dotnet ef dbcontext optimize</code> and fail the build if <code>git diff</code> is non-empty.
Never rely on humans remembering.</p>
<p>Also worth knowing: newer EF Core releases keep shaving time off model building itself, so re-measure on your current EF version before assuming you need this at all.</p>
<h2>Who Should Actually Use This?</h2>
<p>My decision list is short.</p>
<p><strong>Good fit:</strong></p>
<ul>
<li>Models in the hundreds of entity types, where the build cost is measured in seconds.</li>
<li>Serverless and scale-to-zero deployments (Lambda, Azure Functions, Container Apps to zero) where cold start is a user-facing number you pay constantly.</li>
<li>Modular systems with several large contexts, where each module pays its own model build. A <a href="https://milanjovanovic.tech/blog/modular-monolith-data-isolation"><strong>modular monolith</strong></a> with 8 modules and 8 DbContexts multiplies this cost by 8.</li>
<li>Pair it with <code>dotnet ef dbcontext optimize</code> running from CI so freshness is automatic.</li>
</ul>
<p><strong>Bad fit:</strong></p>
<ul>
<li>Any model using <code>HasQueryFilter</code> for soft delete or tenancy. Hard stop until EF lifts the limitation.</li>
<li>Lazy-loading proxy users.</li>
<li>Small models. The complexity spend is real (generated code in the repo, a build step, one more thing to understand) and the win is milliseconds.</li>
<li>Long-running services that restart weekly. You would be optimizing an event that happens 50 times a year.</li>
</ul>
<p>Notice the shape: compiled models are a <strong>deployment-profile</strong> optimization, in the same family as Native AOT and R2R, not a general &quot;make EF faster&quot; switch.
The steady state is untouched; only the first minute of process life improves.</p>
<h2>Summary</h2>
<ul>
<li>Model building is a one-time reflection cost on first <code>DbContext</code> use, and it scales with entity count: trivial at 20 entities, seconds at several hundred.</li>
<li><code>dotnet ef dbcontext optimize</code> plus <code>UseModel</code> moves that cost to build time, cutting the model preparation phase by roughly 10x on large models.</li>
<li>The limitations are decisive, not cosmetic: <strong>no global query filters</strong> (so most soft delete and multi-tenant designs are out), no lazy-loading or change-tracking proxies, no dynamic per-tenant models.</li>
<li>The snapshot goes stale silently. On EF 9+, turn on <code>EFOptimizeContext</code> so the build regenerates it; earlier, enforce regeneration in CI.</li>
<li>Adopt it for big models on cold-start-sensitive infrastructure. Skip it everywhere else and measure <code>db.Model</code> build time before deciding.</li>
</ul>
<p>Compiled models matter when model construction is a measured part of cold-start latency and add little value otherwise.
One stopwatch line tells you which camp you are in.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Find vs FirstOrDefault in EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-find-vs-firstordefault</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-find-vs-firstordefault</guid>
            <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[FindAsync and FirstOrDefaultAsync look interchangeable for loading by primary key, but they behave differently in one crucial way: Find checks the change…]]></description>
            <content:encoded><![CDATA[<p><code>FindAsync</code> checks the change tracker before it touches the database, and it accepts only primary key values.
<code>FirstOrDefaultAsync</code> always sends a query, and it accepts any predicate plus includes, no-tracking, and projections.
That single difference makes <code>Find</code> a free lookup for entities the context already holds, and a stale-read risk in a long-lived context.
Use <code>Find</code> to load and modify by key, and queries for everything else.</p>
<p>You need an entity by its primary key.
Two lines of EF Core do the job:</p>
<pre><code class="language-csharp">var order = await context.Orders.FindAsync(id);
var order = await context.Orders.FirstOrDefaultAsync(o =&gt; o.Id == id);
</code></pre>
<p>Most developers pick one by habit and never look back.
But these two methods have genuinely different semantics, and the difference, whether the change tracker is consulted before the database, cuts both ways.
In one codebase it is a free performance win.
In another it lets a background job keep processing state that the database has already changed.</p>
<h2>How FindAsync Works</h2>
<p><code>FindAsync</code> runs in two phases:</p>
<ol>
<li><strong>Change tracker lookup.</strong> If an entity of that type with that key is already tracked, return it. No SQL, no round trip, nanoseconds.</li>
<li><strong>Database query.</strong> On a miss, execute a <code>SELECT ... WHERE pk = @p</code>, track the result, return it (or <code>null</code>).</li>
</ol>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-find-vs-firstordefault/find-two-phase.png" alt="FindAsync two-phase lookup: if the entity is already tracked it returns the instance with no SQL, otherwise it runs a primary key SELECT, tracks the result, and returns the entity or null">
<p>You can see phase 1 in isolation:</p>
<pre><code class="language-csharp">var first = await context.Orders.FindAsync(id);   // SQL query
var second = await context.Orders.FindAsync(id);  // no SQL, same instance

Console.WriteLine(ReferenceEquals(first, second)); // True
</code></pre>
<p>Phase 1 also covers entities that were <strong>added but not yet saved</strong>:</p>
<pre><code class="language-csharp">var draft = new Order { Id = id, Status = OrderStatus.Draft };
context.Orders.Add(draft);

var found = await context.Orders.FindAsync(id); // returns draft, no SQL
</code></pre>
<p>That behavior is unique to <code>Find</code>.
A <code>FirstOrDefaultAsync</code> before <code>SaveChanges</code> queries the database, does not see the pending insert, and returns <code>null</code>.
If your unit of work creates an entity and a later step in the same request needs to fetch it, <code>Find</code> is the only lookup that behaves consistently.</p>
<p>For composite keys, pass the values in the order the key was declared, a detail I covered in <a href="https://milanjovanovic.tech/blog/ef-core-composite-keys"><strong>composite primary keys</strong></a>:</p>
<pre><code class="language-csharp">var item = await context.OrderItems.FindAsync(orderId, productId);
</code></pre>
<h2>The CancellationToken Trap</h2>
<p>That composite-key overload hides the one genuine footgun in the <code>Find</code> API.
<code>FindAsync</code> takes its key values as <code>params object?[]</code>, so this compiles and looks completely reasonable:</p>
<pre><code class="language-csharp">// Wrong: the token is absorbed as a second key value
var order = await context.Orders.FindAsync(id, cancellationToken);
</code></pre>
<p>The compiler binds it to the <code>params</code> overload, treats the token as another key component, and EF throws at runtime:</p>
<p>&quot;Entity type 'Order' is defined with a single key property, but 2 values were passed to the 'Find' method.&quot;</p>
<p>To pass a cancellation token, wrap the key values in an array so the call binds to the <code>FindAsync(object?[] keyValues, CancellationToken cancellationToken)</code> overload:</p>
<pre><code class="language-csharp">var order = await context.Orders.FindAsync([id], cancellationToken);
</code></pre>
<p><code>[id]</code> is a C# 12 collection expression; on older language versions, write <code>new object[] { id }</code> instead.
<code>FirstOrDefaultAsync(predicate, cancellationToken)</code> has no such trap, which is one quiet reason some codebases standardize on it.
Do not let that decide the choice; just know the array form and move on.</p>
<h2>How FirstOrDefaultAsync Works</h2>
<p><code>FirstOrDefaultAsync</code> is just a LINQ query.
It always generates SQL, always hits the database, and gives you the full query pipeline:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .Include(o =&gt; o.Items)
    .FirstOrDefaultAsync(o =&gt; o.Id == id);
</code></pre>
<p>Everything <code>Find</code> cannot do lives here:</p>
<ul>
<li><strong>Includes.</strong> <code>Find</code> has no way to load related data; you would follow up with explicit loading.</li>
<li><strong>No-tracking reads.</strong> <code>AsNoTracking</code> only exists on queries.</li>
<li><strong>Projections.</strong> Selecting a DTO instead of the entity, usually the fastest read of all, is query-only.</li>
<li><strong>Any predicate.</strong> Lookups by anything other than the primary key.</li>
<li><strong>Query filters.</strong> <code>FirstOrDefault</code> respects <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core"><strong>global query filters</strong></a>. <code>Find</code> bypasses them when it hits the tracker, and applies them on the database path, which can produce genuinely confusing mixed behavior in soft-delete and multi-tenant setups.</li>
</ul>
<h2>The Cache Hit That Becomes a Stale Read</h2>
<p>The change tracker consultation is a per-context cache with no expiration and no invalidation.
Within a short-lived request context, that is pure upside.
In anything long-lived, it is a stale-data machine:</p>
<pre><code class="language-csharp">// Background job, one context per batch run
var order = await context.Orders.FindAsync(orderId); // loads Status = Submitted

// meanwhile, a user cancels the order; another context saves Status = Cancelled

// ...later in the same batch:
var again = await context.Orders.FindAsync(orderId); // still Submitted, no SQL
</code></pre>
<p>The second call never asks the database, so it cannot see the cancellation.
The fix is either a context per unit of work (my strong preference, same reasoning as in <a href="https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling"><strong>DbContext pooling</strong></a>), an explicit reload when freshness matters:</p>
<pre><code class="language-csharp">await context.Entry(order).ReloadAsync();
</code></pre>
<p>or clearing the tracker between batches with <code>ChangeTracker.Clear()</code>.</p>
<p>Here is the part that surprises even experienced EF users: <strong>switching to <code>FirstOrDefaultAsync</code> does not fully fix stale reads on tracked entities.</strong>
A tracking query executes the SQL, but identity resolution then discards the fresh values for any entity already in the tracker and returns the existing instance, preserving your unsaved changes.
The database was consulted; the object you got back still holds the old (or locally modified) values.
I dug into that mechanism in <a href="https://milanjovanovic.tech/blog/ef-core-asnotracking-identity-resolution"><strong>AsNoTracking and identity resolution</strong></a>.
If you need guaranteed-fresh values in the same context, <code>ReloadAsync</code> or a no-tracking query are the honest options.</p>
<h2>Performance: What the Difference Is Worth</h2>
<p>On a tracker hit, <code>Find</code> costs a dictionary lookup.
A <code>FirstOrDefault</code> for the same entity costs a full round trip: SQL generation (cached), network, parse, execution, materialization.
Even against a local database that is a few hundred microseconds versus effectively zero.</p>
<p>On a tracker miss, both run a nearly identical primary-key <code>SELECT</code>, and the difference is noise.</p>
<p>So the performance case for <code>Find</code> is entirely about <strong>repeated access within one context</strong>: request pipelines where validation, authorization, and the handler each resolve the same aggregate, or graph-fixing code that touches the same parents repeatedly.
Those patterns get the repeat lookups for free.
If your code loads each entity exactly once per request, choose by capability, not speed, and spend your optimization budget where it counts, on the issues in <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a>.</p>
<h2>Side-by-Side Comparison</h2>
<p>The two methods, dimension by dimension:</p>
<table><thead><tr><th></th><th>FindAsync</th><th>FirstOrDefaultAsync</th></tr></thead><tbody><tr><td>Lookup by</td><td>Primary key values only</td><td>Any predicate</td></tr><tr><td>Change tracker</td><td>Checked first, SQL only on a miss</td><td>Always queries, identity resolution applies after</td></tr><tr><td>Sees pending adds before SaveChanges</td><td>Yes</td><td>No, it returns null</td></tr><tr><td>Include and projections</td><td>Not available</td><td>Full query pipeline</td></tr><tr><td>AsNoTracking</td><td>Not available</td><td>Available</td></tr><tr><td>Global query filters</td><td>Bypassed on a tracker hit, applied on the database path</td><td>Always applied</td></tr><tr><td>Cost on a tracker hit</td><td>A dictionary lookup, no round trip</td><td>A full database round trip</td></tr><tr><td>Cost on a tracker miss</td><td>A primary key SELECT</td><td>A nearly identical SELECT</td></tr><tr><td>CancellationToken</td><td>Needs the array form, FindAsync([id], token)</td><td>A second parameter, no trap</td></tr><tr><td>Best for</td><td>Load-then-modify by primary key</td><td>Includes, projections, and no-tracking reads</td></tr></tbody></table>
<h2>My Decision Rules</h2>
<ul>
<li><strong>Load-then-modify by primary key</strong>: <code>FindAsync</code>. It composes with pending adds, returns the tracked instance instead of colliding with it (the error I dissected in <a href="https://milanjovanovic.tech/blog/ef-core-entity-already-tracked-error"><strong>fixing the &quot;cannot be tracked&quot; error</strong></a>), and repeated calls are free.</li>
<li><strong>Read-only display or list data</strong>: a projection with <code>AsNoTracking</code>, via <code>FirstOrDefaultAsync</code> or <code>ToListAsync</code>. Never <code>Find</code>; you do not want tracking at all.</li>
<li><strong>Need related data</strong>: <code>FirstOrDefaultAsync</code> with <code>Include</code>, or better, a projection shaped for the use case.</li>
<li><strong>Soft delete or multi-tenancy in play</strong>: prefer queries, so filters apply uniformly.</li>
<li><strong>Long-lived context</strong>: prefer queries plus explicit reloads, or restructure to short-lived contexts and keep using <code>Find</code> safely.</li>
</ul>
<p>One naming note: <code>SingleOrDefaultAsync</code> versus <code>FirstOrDefaultAsync</code> on a primary key predicate makes no practical difference, the key is unique, but <code>Single</code> queries with <code>LIMIT 2</code> to assert uniqueness while <code>First</code> stops at one row and reads as intent here.</p>
<h2>Summary</h2>
<p><code>FindAsync</code> is a primary-key lookup with a built-in L1 cache: the change tracker.
<code>FirstOrDefaultAsync</code> is a real query with the full pipeline: predicates, includes, no-tracking, projections, and query filters.</p>
<p>The tracker check is the whole story.
It makes <code>Find</code> the right default for load-then-modify work in short-lived contexts, where repeat lookups are free and pending adds are visible.
It makes <code>Find</code> a liability in long-lived contexts, where it happily serves data the database moved past minutes ago.</p>
<p>And remember the twist: a tracking <code>FirstOrDefault</code> does not rescue you from staleness on already-tracked entities, because identity resolution keeps the old instance.
When freshness is a requirement, say it in code: <code>ReloadAsync</code>, <code>AsNoTracking</code>, or a fresh context.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[DbContext Pooling in EF Core: When It Helps and When It Bites]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling</guid>
            <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AddDbContextPool can shave allocations off every request by recycling DbContext instances instead of creating them.]]></description>
            <content:encoded><![CDATA[<p>DbContext pooling, enabled with <code>AddDbContextPool</code>, keeps a pool of <code>DbContext</code> instances and rents one per scope instead of constructing a new one, then resets it and returns it at scope end.
It helps on high-RPS endpoints where the work per context is tiny, and it is a rounding error where the database round trip dominates.
It bites when your context carries custom state, which the pool does not reset.</p>
<p>Every request in a typical ASP.NET Core app constructs a <code>DbContext</code>, uses it for a handful of queries, and throws it away.
Construction is not free: EF Core sets up the change tracker, the service scope, and per-instance state, and disposal tears it down.</p>
<p><code>AddDbContextPool</code> skips that churn by recycling instances.
On paper it is a one-word change.
In practice it changes the lifecycle rules of your context, and code that was fine with <code>AddDbContext</code> can start leaking state across requests.</p>
<p>Here is what pooling actually buys you, and the three ways it bites.</p>
<h2>What Pooling Changes</h2>
<p>The registration looks almost identical:</p>
<pre><code class="language-csharp">builder.Services.AddDbContextPool&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(builder.Configuration.GetConnectionString(&quot;Database&quot;)));
</code></pre>
<p>With plain <code>AddDbContext</code>, each scope constructs a new context and disposes it at scope end.
With pooling, &quot;dispose&quot; becomes &quot;reset and return to pool&quot;, and &quot;construct&quot; becomes &quot;rent from pool if one is available&quot;.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-dbcontext-pooling/pool-lifecycle.png" alt="Pooling lifecycle: a request rents a DbContext from the pool, uses it for queries, then on scope end EF state is reset and the change tracker cleared before the context returns to the pool">
<p>The reset clears EF Core's internal state: the change tracker is emptied, connection state is handled, and the context is as good as new <strong>from EF Core's perspective</strong>.
That last qualifier is where the problems live.</p>
<p>The default pool size is 1024.
Under burst load beyond that, extra contexts are created and simply disposed instead of pooled, so the pool never becomes a bottleneck or a queue.
You can tune it with the second argument to <code>AddDbContextPool</code>, but change the default only after measuring pool saturation and allocation pressure.</p>
<p>Do not confuse context pooling with <strong>connection</strong> pooling.
ADO.NET connection pooling happens a layer below and is always on; a non-pooled context still reuses pooled database connections.
For the Npgsql side of that story, see <strong>NpgsqlDataSource and connection pooling</strong>.</p>
<h2>Is DbContext Pooling Worth It?</h2>
<p>Pooling removes per-request allocation and setup of the context graph.
The EF team's own benchmarks show it matters most when the work per context is tiny: high-RPS endpoints running one cheap indexed query.
In those scenarios the requests-per-second improvement is material, but the result depends on how much work surrounds each context.</p>
<p>In a typical business app, the database round trip costs a few milliseconds and dominates the microseconds saved on construction.
There, pooling is a rounding error.</p>
<p>My rule: pooling is a legitimate optimization for hot, simple endpoints, and harmless elsewhere <strong>if</strong> your context is stateless.
Measure with BenchmarkDotNet or a load test before crediting it with anything.</p>
<h2>Bite 1: State on the Context Leaks Across Requests</h2>
<p>This is the big one.
The pool resets what EF Core knows about, and nothing else.
Any field you added to your <code>DbContext</code> subclass survives into the next request:</p>
<pre><code class="language-csharp">public class AppDbContext(DbContextOptions&lt;AppDbContext&gt; options)
    : DbContext(options)
{
    // DANGER with pooling: this survives the reset
    public Guid TenantId { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity&lt;Order&gt;()
            .HasQueryFilter(o =&gt; o.TenantId == TenantId);
    }
}
</code></pre>
<p>With <code>AddDbContext</code>, this pattern works: each request gets a fresh context, middleware sets <code>TenantId</code>, the <a href="https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core"><strong>global query filter</strong></a> does its job.
With <code>AddDbContextPool</code>, request B rents the context request A used.
If B's middleware fails to set <code>TenantId</code>, B silently queries A's tenant data.
That is not a performance bug, that is a data breach.</p>
<p>If you pool a stateful context, the state must be set <strong>unconditionally</strong> on every rental.
And because a pooled context cannot inject scoped services (the next bite), the assignment has to happen after the rental.
The safe pattern is middleware that resolves the scoped context, which rents it from the pool, and assigns the value before any handler runs a query:</p>
<pre><code class="language-csharp">// Middleware, runs on every request without exception
app.Use(async (httpContext, next) =&gt;
{
    var db = httpContext.RequestServices
        .GetRequiredService&lt;AppDbContext&gt;();

    // Always assign, even when the request has no tenant
    db.TenantId = ResolveTenant(httpContext);

    await next();
});
</code></pre>
<p>Multi-tenancy is the most common place this trap appears, and the tenant-per-request patterns from <a href="https://milanjovanovic.tech/blog/multi-tenant-applications-with-ef-core"><strong>multi-tenant applications with EF Core</strong></a> need this exact adjustment before they are pool-safe.</p>
<h2>Bite 2: The Constructor Contract</h2>
<p>A pooled context must expose a single public constructor that takes only <code>DbContextOptions&lt;T&gt;</code>.
The pool constructs instances itself, outside any request scope, so it cannot satisfy other dependencies:</p>
<pre><code class="language-csharp">// Works with AddDbContext, throws at startup with AddDbContextPool
public class AppDbContext(
    DbContextOptions&lt;AppDbContext&gt; options,
    ICurrentUser currentUser) : DbContext(options)
{
}
</code></pre>
<p>If your context injects the current user for <a href="https://milanjovanovic.tech/blog/audit-logging-ef-core"><strong>audit logging</strong></a>, or a tenant service, pooling forces a redesign: move the dependency out of the constructor and into an <a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors"><strong>interceptor</strong></a> resolved from DI, or set it post-rental as shown above.
Interceptors registered via <code>options.AddInterceptors</code> in the pooled options are shared singletons, so they must be stateless too.</p>
<h2>Bite 3: Long-Lived Rentals Poison the Pool</h2>
<p>The pool assumes short rentals.
A context held for the length of a background job, or one that tracked ten thousand entities, gets its change tracker cleared on return, but the internal structures may have grown, and while it is held, it is not available.</p>
<p>Two related mistakes:</p>
<ul>
<li>Injecting a pooled context into a singleton hosted service. The context is rented once and never returned. Use <code>IDbContextFactory&lt;T&gt;</code> instead, and there is a pooled variant:</li>
</ul>
<pre><code class="language-csharp">builder.Services.AddPooledDbContextFactory&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString));

public class OrderSyncJob(IDbContextFactory&lt;AppDbContext&gt; factory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await using var context = await factory.CreateDbContextAsync(ct);
            // short-lived unit of work, context returns to pool on dispose
        }
    }
}
</code></pre>
<ul>
<li>Treating the pooled context as a cache because &quot;it sticks around&quot;. The change tracker is wiped on every return. Anything you hoped would persist will not, and anything you did not expect to persist (your own fields) will. It is exactly backwards from what intuition suggests.</li>
</ul>
<h2>My Recommendation</h2>
<ul>
<li>Context has no custom state and no request-varying dependencies: pooling is low risk, but still measure it on the target workload.</li>
<li>Context carries per-request state (tenant, user, soft-delete toggles): pool only after making the assignment unconditional on every rental, and add a test that hammers two tenants concurrently and asserts isolation.</li>
<li>Background services: <code>AddPooledDbContextFactory</code>, one context per unit of work.</li>
<li>Low-traffic apps: skip it. The complexity is real and the win is not.</li>
</ul>
<p>More context lifetime guidance lives in <a href="https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices"><strong>DbContext configuration best practices</strong></a>.</p>
<h2>Summary</h2>
<p><code>AddDbContextPool</code> recycles context instances, trading construction cost for stricter lifecycle rules.
The pool resets EF Core's state, not yours: custom fields survive across requests, constructors are limited to options-only, and singletons that hold a rental starve the pool.</p>
<p>The performance win is real but narrow, concentrated in hot endpoints where per-request query work is tiny.
The failure mode is not narrow at all: leaked tenant or user state on a reused context is a correctness bug that only shows up under concurrent load.</p>
<p>Pool stateless contexts freely.
Pool stateful ones only after making the state impossible to forget.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[AsNoTracking vs AsNoTrackingWithIdentityResolution]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-asnotracking-identity-resolution</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-asnotracking-identity-resolution</guid>
            <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[AsNoTracking is the standard advice for read-only EF Core queries, but it has a side effect few people notice: shared related entities get duplicated in…]]></description>
            <content:encoded><![CDATA[<p>Use <code>AsNoTracking</code> for read-only queries whose results you map to DTOs, and <code>AsNoTrackingWithIdentityResolution</code> when the result is an object graph you will traverse.
Both skip the change tracker, but only identity resolution keeps a temporary identity map, so shared related entities resolve to one instance per key instead of a fresh copy per row.
That deduplication costs a little CPU per row.</p>
<p><code>AsNoTracking</code> is probably the most repeated EF Core performance tip on the internet, and it is good advice.
I give it myself.</p>
<p>But it has a side effect that almost nobody mentions.
Query 100 orders with their customer, where all 100 belong to the same customer, and you get <strong>100 separate Customer objects</strong> in memory.
Same key, same data, 100 instances.
There is a third query mode that fixes exactly this, and knowing when to reach for it is the point of this article.</p>
<h2>The Three Query Modes</h2>
<p>Every EF Core query runs in one of three modes:</p>
<p><strong>Tracking</strong> (the default).
Every materialized entity is registered in the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core"><strong>change tracker</strong></a> with a snapshot of its original values.
That is what makes <code>SaveChanges</code> work, and it is also why the change tracker maintains an <strong>identity map</strong>: one instance per key, guaranteed.
Read the same row twice, get the same object reference.</p>
<p><strong>No tracking.</strong></p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .Include(o =&gt; o.Customer)
    .AsNoTracking()
    .ToListAsync();
</code></pre>
<p>No snapshots, no change tracker entries, no identity map.
EF materializes a fresh object for whatever each row says, hands it to you, and forgets it existed.
This is the right default for read-only queries, and you will find it in every <a href="https://milanjovanovic.tech/blog/ef-core-performance-guide"><strong>EF Core performance guide</strong></a> for good reason.</p>
<p><strong>No tracking with identity resolution.</strong></p>
<pre><code class="language-csharp">var orders = await dbContext.Orders
    .Include(o =&gt; o.Customer)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();
</code></pre>
<p>Still untracked, still read-only.
But during materialization, EF keeps a temporary identity map: the first time it sees <code>Customer 42</code> it creates the instance, and every subsequent row with <code>CustomerId = 42</code> gets a reference to that <strong>same instance</strong>.
The map is thrown away when the query completes.</p>
<h2>Seeing the Duplication</h2>
<p>The behavior difference is easiest to see with reference equality.
Take 100 orders that all belong to one customer:</p>
<pre><code class="language-csharp">// Plain AsNoTracking
var orders = await dbContext.Orders
    .Where(o =&gt; o.CustomerId == customerId)
    .Include(o =&gt; o.Customer)
    .AsNoTracking()
    .ToListAsync();

var distinctInstances = orders
    .Select(o =&gt; o.Customer)
    .Distinct(ReferenceEqualityComparer.Instance)
    .Count();

Console.WriteLine(distinctInstances); // 100
</code></pre>
<p>One hundred <code>Customer</code> objects, all with the same primary key, all carrying identical copies of every column.
Swap in <code>AsNoTrackingWithIdentityResolution</code> and the same code prints <code>1</code>.</p>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-asnotracking-identity-resolution/identity-resolution.png" alt="The same query of 100 orders sharing one customer materializes 100 duplicated Customer objects under AsNoTracking, but a single shared Customer instance under AsNoTrackingWithIdentityResolution">
<p>To be precise about what is duplicated: this is not 100 customer rows coming over the wire in this shape (the JOIN repeats customer columns per row either way, that part is inherent to SQL).
The duplication is in <strong>materialization</strong>: 100 allocated objects instead of 1, and 100x the memory for that entity's data.</p>
<p>If the customer row is wide (a JSON column, a description, a byte array), the memory multiplication gets serious.
1,000 rows sharing 10 customers with 2 KB of customer data each: tracking or identity resolution holds 20 KB of customer data, plain <code>AsNoTracking</code> holds 2 MB.</p>
<h2>When Do the Duplicates Actually Hurt?</h2>
<p>Most of the time, duplicated instances are harmless.
You map the rows to a DTO, serialize, respond, and the garbage collector cleans up.
That is why plain <code>AsNoTracking</code> is still the right default.</p>
<p>The duplication bites in three specific situations:</p>
<p><strong>1. In-memory graph processing.</strong>
Any logic that assumes &quot;same entity means same object&quot; breaks quietly.
Grouping by reference, building lookups keyed by instance, walking the object graph and mutating shared nodes: with plain no-tracking, &quot;shared&quot; nodes are not shared, so you update one copy of the customer and the other 99 still show the old value.</p>
<p><strong>2. Memory-heavy read paths.</strong>
Reporting queries with wide shared parents, exports, or anything that holds large result sets alive while processing.
Here the duplication is a real allocation and GC cost, not a rounding error.</p>
<p><strong>3. Cyclic or diamond-shaped Includes.</strong>
Multiple include paths reaching the same entity produce independent copies of it.
Serializers configured with reference preservation then see distinct objects instead of one, and payload sizes balloon.</p>
<p>Notice what all three have in common: you are treating the result as a <strong>graph</strong>, not as rows.
That is the heuristic.
Rows to DTOs: <code>AsNoTracking</code>.
A graph you will traverse: identity resolution (or projection, more on that below).</p>
<h2>What Identity Resolution Costs</h2>
<p>Nothing is free.
<code>AsNoTrackingWithIdentityResolution</code> maintains a dictionary of materialized keys for the duration of the query, so it pays lookup and bookkeeping CPU per row, plus the map itself.</p>
<p>The expected cost profile is:</p>
<ul>
<li><code>AsNoTracking</code>: fastest, most allocations when parents are shared.</li>
<li><code>AsNoTrackingWithIdentityResolution</code>: slightly slower per row, but allocations drop as sharing grows.</li>
<li>Tracking: the expensive one, because snapshots and change tracker registration dwarf the identity map cost.</li>
</ul>
<p>The break-even depends on the share ratio.
With no shared entities at all (every order has a distinct customer), the identity map is pure overhead: skip it.
With high sharing, the reduced allocations can pay the CPU back, and the semantic correctness comes free.
<strong>Benchmark the actual query</strong> instead of treating general ratios as a guarantee.</p>
<p>Here is how the two modes compare, dimension by dimension:</p>
<table><thead><tr><th></th><th>AsNoTracking</th><th>AsNoTrackingWithIdentityResolution</th></tr></thead><tbody><tr><td>Change tracker entries</td><td>None</td><td>None</td></tr><tr><td>Identity map</td><td>None</td><td>Temporary, discarded when the query completes</td></tr><tr><td>Shared related entities</td><td>A fresh instance per row</td><td>One shared instance per key</td></tr><tr><td>Per-row cost</td><td>Lowest</td><td>Slightly higher, a dictionary lookup per row</td></tr><tr><td>Allocations when parents are shared</td><td>Highest</td><td>Drop as sharing grows</td></tr><tr><td>Usable with SaveChanges</td><td>No</td><td>No</td></tr><tr><td>Best for</td><td>Rows you map to a DTO and return</td><td>Graphs you traverse, group, or serialize with references</td></tr><tr><td>As the context-wide default</td><td>The one to set for read-heavy apps</td><td>Pays the map cost on every query</td></tr></tbody></table>
<h2>Setting a Default (and Overriding It)</h2>
<p>You can make no-tracking the context-wide default, which I do for read-heavy applications and in the query side of CQRS:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
</code></pre>
<p><code>QueryTrackingBehavior.NoTrackingWithIdentityResolution</code> is also valid as a default, but it pays the identity-map cost on every query to fix a problem many queries do not have.
Set <code>NoTracking</code> as the default and opt up per query:</p>
<pre><code class="language-csharp">// Read model: default no-tracking applies.
var summaries = await dbContext.Orders
    .Select(o =&gt; new OrderSummary(o.Id, o.Customer.Name, o.Total))
    .ToListAsync();

// Graph-shaped read: opt into identity resolution.
var graph = await dbContext.Orders
    .Include(o =&gt; o.Customer)
    .Include(o =&gt; o.Lines).ThenInclude(l =&gt; l.Product)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();

// Command: opt back into tracking to modify.
var order = await dbContext.Orders
    .AsTracking()
    .FirstAsync(o =&gt; o.Id == orderId);
</code></pre>
<p>One warning for the no-tracking-by-default setup: it is the classic source of &quot;I changed the entity and <code>SaveChanges</code> did nothing&quot;.
If updates silently stop working after you flip the default, that is why, and it is a cousin of the <a href="https://milanjovanovic.tech/blog/ef-core-entity-already-tracked-error"><strong>already-tracked errors</strong></a> you get when mixing modes carelessly.</p>
<h2>The Option That Beats Both: Projection</h2>
<p>Before choosing between the two no-tracking flavors, ask whether you need entities at all.</p>
<pre><code class="language-csharp">var report = await dbContext.Orders
    .Where(o =&gt; o.CreatedAt &gt;= from)
    .Select(o =&gt; new OrderReportRow(
        o.Id,
        o.Customer.Name,
        o.Lines.Sum(l =&gt; l.Quantity * l.UnitPrice)))
    .ToListAsync();
</code></pre>
<p>A <code>Select</code> into a DTO sidesteps the entire question: nothing is tracked, nothing is duplicated, only the needed columns cross the wire, and the identity map never enters the picture.
Projections do not need <code>AsNoTracking</code> at all, because there are no entities to track.
For pure read endpoints this wins on every axis, and skipping it is one of the <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query mistakes</strong></a> I see most often.
The same materialization behavior also underlies <a href="https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting"><strong>query splitting</strong></a> decisions: how EF turns rows into objects is worth understanding once, deeply.</p>
<p>Entities (and therefore this article's choice) remain relevant when you genuinely want the object graph: domain-shaped reads, mappers that consume entities, or code paths shared with tracking scenarios.</p>
<h2>Summary</h2>
<ul>
<li><code>AsNoTracking</code> skips the change tracker AND the identity map. Every row materializes a fresh instance, so shared parents are duplicated: 100 orders with one customer means 100 <code>Customer</code> objects.</li>
<li><code>AsNoTrackingWithIdentityResolution</code> keeps queries untracked but deduplicates by key during materialization, at the price of a per-query identity map.</li>
<li>Decide by shape: <strong>rows</strong> (map to DTO and return) take plain <code>AsNoTracking</code>; <strong>graphs</strong> (traverse, group, serialize with references) take identity resolution.</li>
<li>Set <code>NoTracking</code> as the context default for read-heavy apps, opt into <code>AsTracking</code> for commands, and reach for identity resolution explicitly where sharing matters.</li>
<li>The best version of a read query is often a <strong>projection</strong>, which makes the whole dilemma disappear.</li>
</ul>
<p>The tip &quot;use AsNoTracking for reads&quot; is useful but incomplete.
The missing piece is knowing that the identity map was doing quiet work for you, and knowing the one-line fix when its absence shows.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Pagination in ASP.NET Core With EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/pagination-aspnetcore</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/pagination-aspnetcore</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Loading all records at once is a performance disaster. Here is how to implement offset and keyset pagination in ASP.NET Core with EF Core.]]></description>
            <content:encoded><![CDATA[<p>ASP.NET Core and EF Core give you two practical pagination strategies.
Offset pagination uses <code>Skip</code> and <code>Take</code>, which lets users jump to any page number, but the database reads and discards every skipped row.
Keyset pagination filters on the last seen value instead, so an index seeks straight to the cursor position and page depth stops mattering.</p>
<p>An API that returns an unbounded collection has a performance bug waiting for enough data.</p>
<h2>Why Pagination Matters</h2>
<p>Imagine a query returning 100,000 orders. Without pagination, you're loading all of them into memory, serializing to JSON, and sending over the network. Your API response is slow, your server memory spikes, and your users wait.</p>
<p>Pagination loads a small subset at a time - 20 records per page instead of 100,000.
Unbounded queries are one of the most common <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a>, and pagination is the fix.</p>
<h2>Offset Pagination</h2>
<p>The most common approach. Use <code>Skip</code> and <code>Take</code>:</p>
<pre><code class="language-csharp">app.MapGet(&quot;/api/orders&quot;, async (
    ApplicationDbContext db,
    int page = 1,
    int pageSize = 20) =&gt;
{
    if (page &lt; 1) page = 1;
    if (pageSize &lt; 1) pageSize = 20;
    if (pageSize &gt; 100) pageSize = 100; // Prevent abuse

    var totalCount = await db.Orders.CountAsync();

    var orders = await db.Orders
        .OrderByDescending(o =&gt; o.CreatedAt)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o =&gt; new OrderResponse(
            o.Id,
            o.Status.ToString(),
            o.TotalAmount,
            o.CreatedAt))
        .ToListAsync();

    return new PagedResponse&lt;OrderResponse&gt;(
        orders,
        page,
        pageSize,
        totalCount);
});
</code></pre>
<p>The response wrapper:</p>
<pre><code class="language-csharp">public sealed record PagedResponse&lt;T&gt;(
    List&lt;T&gt; Items,
    int Page,
    int PageSize,
    int TotalCount)
{
    public int TotalPages =&gt; (int)Math.Ceiling(TotalCount / (double)PageSize);
    public bool HasNextPage =&gt; Page &lt; TotalPages;
    public bool HasPreviousPage =&gt; Page &gt; 1;
}

public sealed record OrderResponse(
    Guid Id,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt);
</code></pre>
<p>Generated SQL:</p>
<pre><code class="language-sql">SELECT o.&quot;Id&quot;, o.&quot;Status&quot;, o.&quot;TotalAmount&quot;, o.&quot;CreatedAt&quot;
FROM &quot;Orders&quot; AS o
ORDER BY o.&quot;CreatedAt&quot; DESC
LIMIT @take OFFSET @skip;
</code></pre>
<h3>The Problem With Offset Pagination</h3>
<p>Offset pagination has a scaling issue. <code>OFFSET 50000</code> means the database must scan and discard 50,000 rows before returning the next 20. The deeper you paginate, the slower it gets.</p>
<p>There's a second, sneakier issue: <code>COUNT(*)</code> runs on every request.
On a large filtered table, the count query can cost more than fetching the page.
If the UI doesn't display total pages, skip the count.</p>
<h2>Keyset Pagination (Cursor-Based)</h2>
<p>Keyset pagination uses the last seen value as a cursor.
I did a deep dive on why this is so fast in <a href="https://milanjovanovic.tech/blog/understanding-cursor-pagination-and-why-its-so-fast-deep-dive"><strong>understanding cursor pagination</strong></a>.</p>
<pre><code class="language-csharp">app.MapGet(&quot;/api/orders&quot;, async (
    ApplicationDbContext db,
    DateTime? cursor,
    int pageSize = 20) =&gt;
{
    if (pageSize &lt; 1) pageSize = 20;
    if (pageSize &gt; 100) pageSize = 100;

    IQueryable&lt;Order&gt; query = db.Orders;

    if (cursor.HasValue)
    {
        query = query.Where(o =&gt; o.CreatedAt &lt; cursor.Value);
    }

    var orders = await query
        .OrderByDescending(o =&gt; o.CreatedAt)
        .Take(pageSize + 1) // Take one extra to check for next page
        .Select(o =&gt; new OrderResponse(
            o.Id,
            o.Status.ToString(),
            o.TotalAmount,
            o.CreatedAt))
        .ToListAsync();

    var hasNextPage = orders.Count &gt; pageSize;
    if (hasNextPage)
    {
        orders.RemoveAt(orders.Count - 1);
    }

    var nextCursor = orders.LastOrDefault()?.CreatedAt;

    return new CursorPagedResponse&lt;OrderResponse&gt;(
        orders, nextCursor, hasNextPage);
});

public sealed record CursorPagedResponse&lt;T&gt;(
    List&lt;T&gt; Items,
    DateTime? NextCursor,
    bool HasNextPage);
</code></pre>
<p>Generated SQL:</p>
<pre><code class="language-sql">SELECT o.&quot;Id&quot;, o.&quot;Status&quot;, o.&quot;TotalAmount&quot;, o.&quot;CreatedAt&quot;
FROM &quot;Orders&quot; AS o
WHERE o.&quot;CreatedAt&quot; &lt; @cursor
ORDER BY o.&quot;CreatedAt&quot; DESC
LIMIT @take;
</code></pre>
<p>No <code>OFFSET</code>. The database uses the index to jump directly to the cursor position. Performance is constant regardless of how deep you paginate.
One provider note: with Npgsql, timestamp columns map to <code>timestamptz</code> by default, and parameter values must have <code>DateTimeKind.Utc</code>, so convert incoming cursor values with <code>DateTime.SpecifyKind</code> if they arrive unspecified.</p>
<img src="https://milanjovanovic.tech/blogs/articles/pagination-aspnetcore/offset-vs-keyset.png" alt="Offset pagination scans and discards 50000 rows before returning a page, while keyset pagination does an index seek straight to the cursor position and returns the page directly">
<h3>Compound Cursors</h3>
<p>When multiple rows can have the same <code>CreatedAt</code>, use a compound cursor:</p>
<pre><code class="language-csharp">app.MapGet(&quot;/api/orders&quot;, async (
    ApplicationDbContext db,
    DateTime? cursorDate,
    Guid? cursorId,
    int pageSize = 20) =&gt;
{
    IQueryable&lt;Order&gt; query = db.Orders;

    if (cursorDate.HasValue &amp;&amp; cursorId.HasValue)
    {
        query = query.Where(o =&gt;
            o.CreatedAt &lt; cursorDate.Value ||
            (o.CreatedAt == cursorDate.Value &amp;&amp;
             o.Id &lt; cursorId.Value));
    }

    var orders = await query
        .OrderByDescending(o =&gt; o.CreatedAt)
        .ThenByDescending(o =&gt; o.Id)
        .Take(pageSize + 1)
        .Select(o =&gt; new OrderResponse(
            o.Id, o.Status.ToString(), o.TotalAmount, o.CreatedAt))
        .ToListAsync();

    var hasNextPage = orders.Count &gt; pageSize;
    if (hasNextPage) orders.RemoveAt(orders.Count - 1);

    var last = orders.LastOrDefault();

    return new
    {
        Items = orders,
        NextCursorDate = last?.CreatedAt,
        NextCursorId = last?.Id,
        HasNextPage = hasNextPage
    };
});
</code></pre>
<p><code>Guid</code> has comparison operators since .NET 7, and EF Core translates <code>o.Id &lt; cursorId.Value</code> into a plain SQL comparison.
Back it with a composite index on <code>(CreatedAt, Id)</code> so the whole predicate stays an index seek.</p>
<h2>Extracting a Reusable Pagination Extension</h2>
<pre><code class="language-csharp">public static class PaginationExtensions
{
    public static async Task&lt;PagedResponse&lt;T&gt;&gt; ToPagedListAsync&lt;T&gt;(
        this IQueryable&lt;T&gt; query,
        int page,
        int pageSize,
        CancellationToken ct = default)
    {
        var totalCount = await query.CountAsync(ct);

        var items = await query
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(ct);

        return new PagedResponse&lt;T&gt;(items, page, pageSize, totalCount);
    }
}
</code></pre>
<p>Usage:</p>
<pre><code class="language-csharp">var result = await db.Orders
    .OrderByDescending(o =&gt; o.CreatedAt)
    .Select(o =&gt; new OrderResponse(
        o.Id, o.Status.ToString(), o.TotalAmount, o.CreatedAt))
    .ToPagedListAsync(page, pageSize, ct);
</code></pre>
<h2>Filtering + Pagination</h2>
<p>Always apply filters before pagination:</p>
<pre><code class="language-csharp">app.MapGet(&quot;/api/orders&quot;, async (
    ApplicationDbContext db,
    OrderStatus? status,
    DateTime? fromDate,
    int page = 1,
    int pageSize = 20) =&gt;
{
    var query = db.Orders.AsQueryable();

    if (status.HasValue)
        query = query.Where(o =&gt; o.Status == status.Value);

    if (fromDate.HasValue)
        query = query.Where(o =&gt; o.CreatedAt &gt;= fromDate.Value);

    return await query
        .OrderByDescending(o =&gt; o.CreatedAt)
        .Select(o =&gt; new OrderResponse(
            o.Id, o.Status.ToString(), o.TotalAmount, o.CreatedAt))
        .ToPagedListAsync(page, pageSize);
});
</code></pre>
<h2>Index Requirements</h2>
<p>Pagination queries need proper indexes:</p>
<pre><code class="language-csharp">// EF Core index configuration
public class OrderConfiguration : IEntityTypeConfiguration&lt;Order&gt;
{
    public void Configure(EntityTypeBuilder&lt;Order&gt; builder)
    {
        // Index for ordering + pagination
        builder.HasIndex(o =&gt; o.CreatedAt)
               .IsDescending();

        // Composite index for filtered pagination
        builder.HasIndex(o =&gt; new { o.Status, o.CreatedAt })
               .IsDescending(false, true);
    }
}
</code></pre>
<p>Without these indexes, the database does a full table scan for every page request.</p>
<h2>Offset vs Keyset - When to Use Each</h2>
<ul>
<li><strong>Jumping to an arbitrary page</strong>: only offset can do it; keyset moves strictly forward (and optionally backward)</li>
<li><strong>Performance at depth</strong>: offset degrades linearly; keyset stays constant</li>
<li><strong>Implementation effort</strong>: offset is a few lines; keyset needs cursors and tiebreakers</li>
<li><strong>Stability under concurrent inserts</strong>: offset pages shift when rows are added; keyset pages don't skip or duplicate rows</li>
<li><strong>Best fit</strong>: offset for admin UIs and small datasets; keyset for public APIs, infinite scroll, and large datasets</li>
</ul>
<p>Use offset pagination for admin pages where users need &quot;go to page 5&quot;. Use keyset pagination for public APIs and infinite scroll feeds.
Pagination design is one of the topics I cover end to end in <a href="https://milanjovanovic.tech/pragmatic-rest-apis">Pragmatic REST APIs</a>.</p>
<h2>Summary</h2>
<p>Cap every collection response and apply filters before pagination.
Offset pagination is appropriate when random page numbers matter and the result set stays modest.
For deep or continuously changing feeds, use a unique, stable ordering and a matching index to build a keyset cursor.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[How to Log SQL Queries Generated by EF Core]]></title>
            <link>https://milanjovanovic.tech/blog/log-sql-queries-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/log-sql-queries-ef-core</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Generated SQL exposes inefficient joins, repeated round trips, and missing predicates hidden by LINQ.]]></description>
            <content:encoded><![CDATA[<p>The quickest way to see EF Core's SQL is <code>LogTo(Console.WriteLine, LogLevel.Information)</code> in your <code>DbContext</code> options, or <code>ToQueryString()</code> on a query to read its SQL without executing it.
For more than ad-hoc debugging, query tags label a call site, a <code>DbCommandInterceptor</code> logs durations and slow queries, and MiniProfiler shows every query in a request.</p>
<p>The LINQ expression is not what your database executes.
Generated SQL reveals accidental joins, missing predicates, parameter values, and repeated queries that are invisible at the C# level.
EF Core gives you lightweight inspection for development and structured diagnostics for production.</p>
<h2>Why You Need to See the SQL</h2>
<p>SQL logging in EF Core means routing the commands the provider sends to the database, and optionally their parameter values, into a log you can read.</p>
<p>EF Core generates SQL behind the scenes. Most of the time it's fine, but sometimes it generates queries that are inefficient, missing indexes, or drastically different from what you expected. If you're not looking at the SQL, you're flying blind.</p>
<p>Check generated SQL during development, especially for complex LINQ queries.
The log output exposes <a href="https://milanjovanovic.tech/blog/n-plus-one-query-ef-core"><strong>N+1 problems</strong></a>, unnecessary subqueries, and missing <code>WHERE</code> clauses that are easy to miss in C#.
Understanding what EF Core produces is essential for <a href="https://milanjovanovic.tech/blog/ef-core-performance-guide"><strong>EF Core performance optimization</strong></a>.</p>
<h2>Built-in Logging</h2>
<p>The simplest approach - configure EF Core to log SQL to the console or your logging framework:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
{
    options.UseNpgsql(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging()
           .EnableDetailedErrors();
});
</code></pre>
<ul>
<li><code>LogTo(Console.WriteLine)</code>: Sends all EF Core log messages to the console</li>
<li><code>EnableSensitiveDataLogging()</code>: Shows parameter values in logs (disable in production!)</li>
<li><code>EnableDetailedErrors()</code>: Includes more context in error messages</li>
</ul>
<p>To filter only SQL queries:</p>
<pre><code class="language-csharp">options.LogTo(
    Console.WriteLine,
    new[] { DbLoggerCategory.Database.Command.Name },
    LogLevel.Information);
</code></pre>
<p>This outputs something like:</p>
<pre><code class="language-text">Executed DbCommand (5ms) [Parameters=[@__id_0='?' (DbType = Guid)],
CommandType='Text', CommandTimeout='30']
SELECT p.&quot;Id&quot;, p.&quot;Name&quot;, p.&quot;Price&quot;
FROM &quot;Products&quot; AS p
WHERE p.&quot;Id&quot; = @__id_0
</code></pre>
<h2>Using ILoggerFactory</h2>
<p>For better integration with ASP.NET Core's logging:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;((sp, options) =&gt;
{
    var loggerFactory = sp.GetRequiredService&lt;ILoggerFactory&gt;();

    options.UseNpgsql(connectionString)
           .UseLoggerFactory(loggerFactory);
});
</code></pre>
<p>Then configure the log level in <code>appsettings.Development.json</code>:</p>
<pre><code class="language-json">{
  &quot;Logging&quot;: {
    &quot;LogLevel&quot;: {
      &quot;Default&quot;: &quot;Information&quot;,
      &quot;Microsoft.EntityFrameworkCore.Database.Command&quot;: &quot;Information&quot;,
      &quot;Microsoft.EntityFrameworkCore.Infrastructure&quot;: &quot;Warning&quot;
    }
  }
}
</code></pre>
<p>This lets you control EF Core logging granularity through configuration.</p>
<h2>ToQueryString() for Ad-Hoc Inspection</h2>
<p>During debugging, you can convert any LINQ query to its SQL representation without executing it:</p>
<pre><code class="language-csharp">var query = dbContext.Products
    .Where(p =&gt; p.Price &gt; 100)
    .OrderBy(p =&gt; p.Name)
    .Take(10);

var sql = query.ToQueryString();
Console.WriteLine(sql);

// Output:
// SELECT p.&quot;Id&quot;, p.&quot;Name&quot;, p.&quot;Price&quot;
// FROM &quot;Products&quot; AS p
// WHERE p.&quot;Price&quot; &gt; 100.0
// ORDER BY p.&quot;Name&quot;
// LIMIT 10
</code></pre>
<p>This is useful for checking query shape before execution and in provider-backed tests that verify LINQ-to-SQL translation.</p>
<h2>Query Tags</h2>
<p>Add descriptive tags to queries so you can identify them in logs and database monitoring tools:</p>
<pre><code class="language-csharp">var products = await dbContext.Products
    .TagWith(&quot;GetPopularProducts - called from DashboardController&quot;)
    .Where(p =&gt; p.OrderCount &gt; 100)
    .OrderByDescending(p =&gt; p.OrderCount)
    .Take(20)
    .ToListAsync();
</code></pre>
<p>The generated SQL includes the tag as a comment:</p>
<pre><code class="language-sql">-- GetPopularProducts - called from DashboardController

SELECT p.&quot;Id&quot;, p.&quot;Name&quot;, p.&quot;OrderCount&quot;
FROM &quot;Products&quot; AS p
WHERE p.&quot;OrderCount&quot; &gt; 100
ORDER BY p.&quot;OrderCount&quot; DESC
LIMIT 20
</code></pre>
<p>This makes it trivial to trace slow queries back to their source code. In <code>pg_stat_activity</code>, slow query logs, or your database monitoring tool, you'll see the comment right alongside the query.</p>
<h2>Interceptors for Advanced Logging</h2>
<p>Use <a href="https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors"><strong>interceptors</strong></a> for fine-grained control over query logging:</p>
<pre><code class="language-csharp">public class QueryLoggingInterceptor : DbCommandInterceptor
{
    private readonly ILogger&lt;QueryLoggingInterceptor&gt; _logger;

    public QueryLoggingInterceptor(ILogger&lt;QueryLoggingInterceptor&gt; logger)
    {
        _logger = logger;
    }

    public override ValueTask&lt;InterceptionResult&lt;DbDataReader&gt;&gt; ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult&lt;DbDataReader&gt; result,
        CancellationToken cancellationToken = default)
    {
        _logger.LogDebug(&quot;Executing query:\n{Sql}&quot;, command.CommandText);
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }

    public override ValueTask&lt;DbDataReader&gt; ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Duration.TotalMilliseconds &gt; 500)
        {
            _logger.LogWarning(
                &quot;Slow query detected ({Duration}ms):\n{Sql}&quot;,
                eventData.Duration.TotalMilliseconds,
                command.CommandText);
        }

        return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }
}
</code></pre>
<p>Register the interceptor:</p>
<pre><code class="language-csharp">builder.Services.AddSingleton&lt;QueryLoggingInterceptor&gt;();

builder.Services.AddDbContext&lt;AppDbContext&gt;((sp, options) =&gt;
{
    var interceptor = sp.GetRequiredService&lt;QueryLoggingInterceptor&gt;();

    options.UseNpgsql(connectionString)
           .AddInterceptors(interceptor);
});
</code></pre>
<h2>Detecting N+1 Queries</h2>
<p>N+1 queries are the most common of the <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a>. An interceptor can detect them (register it as scoped so the counter is per request):</p>
<pre><code class="language-csharp">public class NPlus1DetectorInterceptor : DbCommandInterceptor
{
    private readonly ILogger&lt;NPlus1DetectorInterceptor&gt; _logger;
    private int _queryCount;
    private string? _currentEndpoint;

    public NPlus1DetectorInterceptor(
        ILogger&lt;NPlus1DetectorInterceptor&gt; logger)
    {
        _logger = logger;
    }

    public void ResetForRequest(string endpoint)
    {
        _queryCount = 0;
        _currentEndpoint = endpoint;
    }

    public override ValueTask&lt;DbDataReader&gt; ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        _queryCount++;

        if (_queryCount &gt; 10)
        {
            _logger.LogWarning(
                &quot;Potential N+1 detected: {Count} queries for {Endpoint}.\n&quot; +
                &quot;Latest query:\n{Sql}&quot;,
                _queryCount, _currentEndpoint, command.CommandText);
        }

        return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }
}
</code></pre>
<h2>Using MiniProfiler</h2>
<p>MiniProfiler gives you a visual SQL profiler in your browser:</p>
<pre><code class="language-bash">dotnet add package MiniProfiler.AspNetCore.Mvc
dotnet add package MiniProfiler.EntityFrameworkCore
</code></pre>
<p>Configure it:</p>
<pre><code class="language-csharp">builder.Services.AddMiniProfiler(options =&gt;
{
    options.RouteBasePath = &quot;/profiler&quot;;
}).AddEntityFramework();

app.UseMiniProfiler();
</code></pre>
<p>Navigate to <code>/profiler/results-index</code> to see:</p>
<ul>
<li>Every SQL query executed per request</li>
<li>Query duration</li>
<li>Duplicate query detection</li>
<li>Parameter values</li>
</ul>
<p>This is my go-to tool during development for spotting performance issues.</p>
<h2>Structured Logging for Production</h2>
<p>In production, log query metrics without the full SQL:</p>
<pre><code class="language-csharp">public class ProductionQueryInterceptor : DbCommandInterceptor
{
    private readonly ILogger&lt;ProductionQueryInterceptor&gt; _logger;

    public ProductionQueryInterceptor(
        ILogger&lt;ProductionQueryInterceptor&gt; logger)
    {
        _logger = logger;
    }

    public override ValueTask&lt;DbDataReader&gt; ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation(
            &quot;Query executed. Duration: {DurationMs}ms, &quot; +
            &quot;CommandType: {CommandType}, &quot; +
            &quot;HasParameters: {HasParameters}&quot;,
            eventData.Duration.TotalMilliseconds,
            command.CommandType,
            command.Parameters.Count &gt; 0);

        return base.ReaderExecutedAsync(
            command, eventData, result, cancellationToken);
    }
}
</code></pre>
<p>Never log full SQL with parameter values in production - it may contain sensitive data. Log metrics only.</p>
<h2>Debug View in Tests</h2>
<p>For integration tests, capture generated SQL:</p>
<pre><code class="language-csharp">[Fact]
public async Task GetProducts_GeneratesExpectedSql()
{
    var queries = new List&lt;string&gt;();

    var options = new DbContextOptionsBuilder&lt;AppDbContext&gt;()
        .UseNpgsql(_connectionString)
        .LogTo(sql =&gt;
        {
            if (sql.Contains(&quot;SELECT&quot;) || sql.Contains(&quot;INSERT&quot;))
            {
                queries.Add(sql);
            }
        }, LogLevel.Information)
        .Options;

    await using var context = new AppDbContext(options);

    var products = await context.Products
        .Where(p =&gt; p.IsActive)
        .ToListAsync();

    queries.Should().HaveCount(1);
    queries[0].Should().Contain(&quot;\&quot;IsActive\&quot;&quot;);
}
</code></pre>
<h2>Summary</h2>
<p>Use <code>ToQueryString</code> and development logging to understand query shape before performance becomes an incident.
Use query tags and interceptors to connect slow commands to application operations.
Keep sensitive-data logging out of production and prefer duration, command metadata, and sanitized diagnostics over parameter values.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Understanding the EF Core Change Tracker]]></title>
            <link>https://milanjovanovic.tech/blog/change-tracker-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/change-tracker-ef-core</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The Change Tracker is at the heart of EF Core. It tracks every entity you load or add, figures out what changed, and generates the right SQL.]]></description>
            <content:encoded><![CDATA[<p>The <strong>change tracker</strong> keeps a snapshot of every entity the <code>DbContext</code> loads or adds, compares the current values against that snapshot when you call <code>SaveChanges</code>, and generates the <code>INSERT</code>, <code>UPDATE</code>, or <code>DELETE</code> statements with only the changed columns.
It also maintains entity state, identity resolution, and relationship fixup for every tracked object.</p>
<p><code>SaveChanges</code> looks simple because the change tracker is doing the difficult work behind it.
Understanding that machinery makes tracking bugs and unnecessary memory use much easier to diagnose.</p>
<h2>How Does the Change Tracker Work?</h2>
<p>When you query data through a <code>DbContext</code>, EF Core doesn't just return objects - it <strong>tracks</strong> them. Every entity loaded from the database goes into the change tracker with its original property values stored as a snapshot.</p>
<p>When you call <code>SaveChangesAsync</code>, EF Core compares the current values to the original snapshot. If anything changed, it generates an <code>UPDATE</code> statement with only the modified columns.</p>
<h2>Entity States</h2>
<p>Every tracked entity is in one of five states:</p>
<pre><code class="language-csharp">var entry = context.Entry(order);
Console.WriteLine(entry.State);
</code></pre>
<ul>
<li><strong><code>Detached</code></strong>: not tracked by the context; <code>SaveChanges</code> does nothing</li>
<li><strong><code>Unchanged</code></strong>: loaded but not modified; <code>SaveChanges</code> does nothing</li>
<li><strong><code>Added</code></strong>: new entity, not yet in the database; produces an <code>INSERT</code></li>
<li><strong><code>Modified</code></strong>: loaded and changed; produces an <code>UPDATE</code></li>
<li><strong><code>Deleted</code></strong>: marked for deletion; produces a <code>DELETE</code></li>
</ul>
<img src="https://milanjovanovic.tech/blogs/articles/change-tracker-ef-core/entity-state-machine.png" alt="Entity state machine: a new entity starts Detached, becomes Added then Unchanged after an INSERT, moves to Modified when a property changes and back to Unchanged after an UPDATE, and moves to Deleted then removed after a DELETE.">
<h2>Tracking in Action</h2>
<pre><code class="language-csharp">// 1. Entity is loaded → state: Unchanged
var order = await context.Orders.FirstAsync(o =&gt; o.Id == orderId);

// 2. Modify a property → state: Modified
order.Status = OrderStatus.Confirmed;

// 3. SaveChanges → generates UPDATE for Status column only
await context.SaveChangesAsync();
</code></pre>
<p>EF Core knows exactly which columns changed. If you loaded an <code>Order</code> with 15 columns but changed only <code>Status</code>, the generated SQL is:</p>
<pre><code class="language-sql">UPDATE &quot;Orders&quot; SET &quot;Status&quot; = @p0 WHERE &quot;Id&quot; = @p1;
</code></pre>
<h2>Adding Entities</h2>
<pre><code class="language-csharp">// Option 1: DbSet.Add
context.Orders.Add(new Order { Id = Guid.NewGuid(), Status = OrderStatus.Draft });

// Option 2: context.Add
context.Add(newOrder);

// Option 3: Add parent, children are tracked automatically
var order = new Order();
order.AddLineItem(productId, quantity, price);

context.Orders.Add(order); // Order AND its LineItems marked as Added
await context.SaveChangesAsync(); // Inserts Order AND LineItems
</code></pre>
<h2>No-Tracking Queries</h2>
<p>If you only need to read data (no updates), skip tracking:</p>
<pre><code class="language-csharp">// Single query
var orders = await context.Orders
    .AsNoTracking()
    .Where(o =&gt; o.Status == OrderStatus.Confirmed)
    .ToListAsync();

// All queries in a context
context.ChangeTracker.QueryTrackingBehavior =
    QueryTrackingBehavior.NoTracking;

// Or configure at registration
services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
</code></pre>
<p>No-tracking queries are faster because EF Core skips snapshot creation and identity resolution.
Skipping tracking on read-only queries is one of the easiest wins among <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a>.</p>
<p>One variant worth knowing: <code>AsNoTrackingWithIdentityResolution()</code>.
It skips snapshots but still deduplicates entities by primary key, so a query with joins doesn't materialize the same <code>Customer</code> five times.</p>
<h2>Inspecting the Change Tracker</h2>
<p>See what the change tracker knows:</p>
<pre><code class="language-csharp">// All tracked entities
foreach (var entry in context.ChangeTracker.Entries())
{
    Console.WriteLine($&quot;{entry.Entity.GetType().Name}: {entry.State}&quot;);
}

// Only modified entities
var modified = context.ChangeTracker.Entries()
    .Where(e =&gt; e.State == EntityState.Modified);

// Modified properties on a specific entity
var orderEntry = context.Entry(order);
foreach (var prop in orderEntry.Properties)
{
    if (prop.IsModified)
    {
        Console.WriteLine(
            $&quot;{prop.Metadata.Name}: &quot; +
            $&quot;{prop.OriginalValue} → {prop.CurrentValue}&quot;);
    }
}
</code></pre>
<h2>Intercepting Changes (Audit Trails)</h2>
<p>Use the change tracker to automatically set audit fields.
For a full audit trail with old and new values, see <a href="https://milanjovanovic.tech/blog/audit-logging-ef-core"><strong>audit logging with EF Core interceptors</strong></a>.</p>
<pre><code class="language-csharp">public override async Task&lt;int&gt; SaveChangesAsync(
    CancellationToken ct = default)
{
    var now = DateTime.UtcNow;

    foreach (var entry in ChangeTracker.Entries&lt;IAuditable&gt;())
    {
        switch (entry.State)
        {
            case EntityState.Added:
                entry.Entity.CreatedAt = now;
                entry.Entity.UpdatedAt = now;
                break;

            case EntityState.Modified:
                entry.Entity.UpdatedAt = now;
                break;
        }
    }

    return await base.SaveChangesAsync(ct);
}
</code></pre>
<p><a href="https://milanjovanovic.tech/blog/implementing-soft-delete-with-ef-core"><strong>Soft delete</strong></a> is another common pattern:</p>
<pre><code class="language-csharp">var now = DateTime.UtcNow;

foreach (var entry in ChangeTracker.Entries&lt;ISoftDeletable&gt;())
{
    if (entry.State == EntityState.Deleted)
    {
        entry.State = EntityState.Modified;
        entry.Entity.IsDeleted = true;
        entry.Entity.DeletedAt = now;
    }
}
</code></pre>
<h2>Identity Resolution</h2>
<p>The change tracker ensures only one instance of an entity exists per primary key:</p>
<pre><code class="language-csharp">var order1 = await context.Orders.FindAsync(orderId);
var order2 = await context.Orders.FindAsync(orderId);

// Same object reference
Console.WriteLine(ReferenceEquals(order1, order2)); // True
</code></pre>
<p>The second call returns the tracked instance without hitting the database. This is called <strong>identity resolution</strong> and prevents conflicting changes.</p>
<h2>DetectChanges</h2>
<p>EF Core calls <code>DetectChanges</code> automatically before <code>SaveChangesAsync</code>. It compares current property values to the stored snapshots:</p>
<pre><code class="language-csharp">// Automatic detection (default)
order.Status = OrderStatus.Shipped;
await context.SaveChangesAsync(); // DetectChanges called internally

// Manual detection
context.ChangeTracker.DetectChanges();
</code></pre>
<p>For performance-critical scenarios with many tracked entities, you can disable automatic detection:</p>
<pre><code class="language-csharp">context.ChangeTracker.AutoDetectChangesEnabled = false;

// Manually detect when needed
context.ChangeTracker.DetectChanges();
await context.SaveChangesAsync();
</code></pre>
<h2>Performance Implications</h2>
<p>The change tracker has a cost. The more entities you track, the more snapshots EF Core maintains in memory.</p>
<h3>Batch Operations</h3>
<p>For bulk updates, skip the change tracker:</p>
<pre><code class="language-csharp">// ❌ Slow - loads all entities into memory, tracks each one
var orders = await context.Orders
    .Where(o =&gt; o.Status == OrderStatus.Draft &amp;&amp;
                o.CreatedAt &lt; cutoffDate)
    .ToListAsync();

foreach (var order in orders)
    order.Status = OrderStatus.Expired;

await context.SaveChangesAsync(); // N UPDATE statements

// ✅ Fast - single SQL statement, no tracking
await context.Orders
    .Where(o =&gt; o.Status == OrderStatus.Draft &amp;&amp;
                o.CreatedAt &lt; cutoffDate)
    .ExecuteUpdateAsync(s =&gt;
        s.SetProperty(o =&gt; o.Status, OrderStatus.Expired));
</code></pre>
<p><code>ExecuteUpdateAsync</code> (EF Core 7+) generates a single SQL statement. No entities are loaded or tracked.
There are important caveats around tracked entities going stale - I cover them in <a href="https://milanjovanovic.tech/blog/what-you-need-to-know-about-ef-core-bulk-updates"><strong>EF Core bulk updates</strong></a>.</p>
<h3>Clear the Tracker</h3>
<p>For long-running operations:</p>
<pre><code class="language-csharp">context.ChangeTracker.Clear();
</code></pre>
<p>This detaches all entities. Useful in background jobs that process many records.</p>
<h2>Common Pitfalls</h2>
<p><strong>Detached entities</strong>: Entities from a different <code>DbContext</code> instance aren't tracked:</p>
<pre><code class="language-csharp">// Entity loaded in one scope
var order = await GetOrderFromAnotherMethod();

// Trying to update in a new scope
context.Orders.Update(order); // Marks ALL properties as modified
</code></pre>
<p>Use <code>Attach</code> + set specific properties instead:</p>
<pre><code class="language-csharp">context.Orders.Attach(order);
context.Entry(order).Property(o =&gt; o.Status).IsModified = true;
</code></pre>
<p><strong>Update vs Attach</strong>: <code>Update</code> marks everything as modified. <code>Attach</code> marks nothing as modified:</p>
<pre><code class="language-csharp">context.Update(order);  // State: Modified (all columns in UPDATE)
context.Attach(order);  // State: Unchanged (you set modifications manually)
</code></pre>
<p><strong>Sharing a DbContext across threads</strong>: the change tracker is not thread-safe.
Running parallel queries against the same context corrupts its internal state.
See <a href="https://milanjovanovic.tech/blog/dbcontext-is-not-thread-safe-parallelizing-ef-core-queries-the-right-way"><strong>DbContext is not thread-safe</strong></a> for the right way to parallelize EF Core queries.</p>
<h2>Summary</h2>
<p>Tracking is valuable when a unit of work will modify an entity and call <code>SaveChanges</code>.
Read models should usually use projections or <code>AsNoTracking</code>, while set-based updates belong in <code>ExecuteUpdateAsync</code> or <code>ExecuteDeleteAsync</code>.
Keeping the tracked graph small makes both behavior and memory use easier to reason about.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[N+1 Query Problem in EF Core and How to Fix It]]></title>
            <link>https://milanjovanovic.tech/blog/n-plus-one-query-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/n-plus-one-query-ef-core</guid>
            <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The N+1 query problem is the most common EF Core performance killer. One query loads the parents, then N queries load the children.]]></description>
            <content:encoded><![CDATA[<p>The N+1 query problem is when code loads N parent rows with one query, then fires one more query per parent to load related data.
Loading 100 orders and then their line items in a loop costs 101 round trips instead of one.
Fix it with <code>Include</code>, a <code>Select</code> projection, or <code>AsSplitQuery</code>, and prevent most cases by leaving lazy loading disabled.</p>
<p>An endpoint can look fast with five rows and collapse when it returns five hundred.
Detecting the pattern requires looking at the query count, then choosing an explicit loading or projection strategy.</p>
<h2>What Is the N+1 Problem?</h2>
<p>You load 100 orders, then for each order you load its line items. That's 1 query for orders + 100 queries for line items = <strong>101 database round trips</strong>.</p>
<pre><code class="language-csharp">// ❌ N+1 problem - 101 queries for 100 orders
var orders = await _db.Orders.ToListAsync();

foreach (var order in orders)
{
    // Each iteration triggers a lazy-load query
    var total = order.LineItems.Sum(li =&gt; li.Price * li.Quantity);
    Console.WriteLine($&quot;Order {order.Id}: {total}&quot;);
}
</code></pre>
<p>With <a href="https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core"><strong>lazy loading</strong></a> enabled, accessing <code>order.LineItems</code> triggers a separate query for each order. The SQL output looks like:</p>
<pre><code class="language-sql">-- Query 1: Get all orders
SELECT * FROM &quot;Orders&quot;;

-- Query 2..101: Get line items for each order
SELECT * FROM &quot;LineItems&quot; WHERE &quot;OrderId&quot; = @p0;
SELECT * FROM &quot;LineItems&quot; WHERE &quot;OrderId&quot; = @p1;
SELECT * FROM &quot;LineItems&quot; WHERE &quot;OrderId&quot; = @p2;
-- ... 97 more
</code></pre>
<p>This is extremely slow, especially with network latency to the database.</p>
<img src="https://milanjovanovic.tech/blogs/articles/n-plus-one-query-ef-core/n-plus-one.png" alt="The N+1 pattern: loading 100 orders in one query, then accessing line items in a loop fires 100 more queries for 101 round trips, while the fix using Include or a Select projection collapses it to a single query that loads all the data">
<h2>Fix 1: Eager Loading With Include</h2>
<p>Load related data in a single query:</p>
<pre><code class="language-csharp">// ✅ 1 query - eager loading
var orders = await _db.Orders
    .Include(o =&gt; o.LineItems)
    .ToListAsync();

foreach (var order in orders)
{
    var total = order.LineItems.Sum(li =&gt; li.Price * li.Quantity);
}
</code></pre>
<p>SQL:</p>
<pre><code class="language-sql">SELECT o.*, li.*
FROM &quot;Orders&quot; o
LEFT JOIN &quot;LineItems&quot; li ON o.&quot;Id&quot; = li.&quot;OrderId&quot;;
</code></pre>
<p>One query. One round trip. All data loaded.</p>
<h3>Nested Includes</h3>
<pre><code class="language-csharp">var orders = await _db.Orders
    .Include(o =&gt; o.LineItems)
        .ThenInclude(li =&gt; li.Product)
    .Include(o =&gt; o.Customer)
    .ToListAsync();
</code></pre>
<h3>Filtered Includes (EF Core 5+)</h3>
<pre><code class="language-csharp">var orders = await _db.Orders
    .Include(o =&gt; o.LineItems.Where(li =&gt; li.Quantity &gt; 0))
    .ToListAsync();
</code></pre>
<h2>Fix 2: Split Queries</h2>
<p>A single query with multiple <code>Include</code>s can produce a cartesian explosion. Use split queries:</p>
<pre><code class="language-csharp">var orders = await _db.Orders
    .Include(o =&gt; o.LineItems)
    .Include(o =&gt; o.Payments)
    .AsSplitQuery()
    .ToListAsync();
</code></pre>
<p>This generates a small, fixed number of queries - one base query plus one per included collection - instead of one giant join:</p>
<pre><code class="language-sql">-- Query 1
SELECT * FROM &quot;Orders&quot;;

-- Query 2 (joined against the same Orders filter)
SELECT li.* FROM &quot;LineItems&quot; li
INNER JOIN &quot;Orders&quot; o ON li.&quot;OrderId&quot; = o.&quot;Id&quot;;

-- Query 3
SELECT p.* FROM &quot;Payments&quot; p
INNER JOIN &quot;Orders&quot; o ON p.&quot;OrderId&quot; = o.&quot;Id&quot;;
</code></pre>
<p>That's 3 round trips regardless of how many orders you load - a fixed cost, unlike N+1.
Two caveats: more round trips add latency, and the queries can observe different data if rows change between them (wrap in a transaction if that matters).
The split-versus-single-query tradeoff is covered in <a href="https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting"><strong>EF Core query splitting</strong></a>.</p>
<p>Configure globally:</p>
<pre><code class="language-csharp">options.UseNpgsql(connectionString, o =&gt;
{
    o.UseQuerySplittingBehavior(
        QuerySplittingBehavior.SplitQuery);
});
</code></pre>
<h2>Fix 3: Projection</h2>
<p>Only load what you need:</p>
<pre><code class="language-csharp">// ✅ Most efficient - single query, minimal data
var orderSummaries = await _db.Orders
    .Select(o =&gt; new OrderSummaryDto
    {
        OrderId = o.Id,
        CustomerName = o.Customer.Name,
        ItemCount = o.LineItems.Count,
        Total = o.LineItems.Sum(li =&gt; li.Price * li.Quantity)
    })
    .ToListAsync();
</code></pre>
<p>No N+1 problem because EF Core computes everything in SQL:</p>
<pre><code class="language-sql">SELECT
    o.&quot;Id&quot; AS &quot;OrderId&quot;,
    c.&quot;Name&quot; AS &quot;CustomerName&quot;,
    (SELECT COUNT(*) FROM &quot;LineItems&quot; li WHERE li.&quot;OrderId&quot; = o.&quot;Id&quot;) AS &quot;ItemCount&quot;,
    (SELECT SUM(li.&quot;Price&quot; * li.&quot;Quantity&quot;) FROM &quot;LineItems&quot; li WHERE li.&quot;OrderId&quot; = o.&quot;Id&quot;) AS &quot;Total&quot;
FROM &quot;Orders&quot; o
JOIN &quot;Customers&quot; c ON o.&quot;CustomerId&quot; = c.&quot;Id&quot;;
</code></pre>
<p>Projection is the best approach when you don't need the full entity graph.</p>
<h2>Fix 4: Explicit Loading</h2>
<p>Load related data on demand for specific entities:</p>
<pre><code class="language-csharp">var order = await _db.Orders.FindAsync(orderId);

// Explicitly load the collection
await _db.Entry(order)
    .Collection(o =&gt; o.LineItems)
    .LoadAsync();

// Now access without triggering lazy loading
var total = order.LineItems.Sum(li =&gt; li.Price * li.Quantity);
</code></pre>
<p>Better than lazy loading because you control when the query happens.</p>
<h2>Detecting N+1 Problems</h2>
<h3>Option 1: SQL Logging</h3>
<pre><code class="language-csharp">options.UseNpgsql(connectionString)
    .LogTo(Console.WriteLine, LogLevel.Information)
    .EnableSensitiveDataLogging();
</code></pre>
<p>Watch for repeated similar queries in the console output.
More options for this are in <a href="https://milanjovanovic.tech/blog/log-sql-queries-ef-core"><strong>how to log SQL queries in EF Core</strong></a>.</p>
<h3>Option 2: EF Core Interceptor</h3>
<pre><code class="language-csharp">public class QueryCountInterceptor : DbCommandInterceptor
{
    private int _queryCount;

    public override ValueTask&lt;InterceptionResult&lt;DbDataReader&gt;&gt; ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult&lt;DbDataReader&gt; result,
        CancellationToken cancellationToken = default)
    {
        _queryCount++;

        if (_queryCount &gt; 10)
        {
            Console.WriteLine(
                $&quot;⚠️ {_queryCount} queries executed! Possible N+1.&quot;);
        }

        return base.ReaderExecutingAsync(
            command, eventData, result, cancellationToken);
    }
}
</code></pre>
<p>Register it as scoped so the counter resets per request.
Async queries (<code>ToListAsync</code> and friends) only hit the async interception methods, so override <code>ReaderExecuting</code> as well if any code path uses synchronous execution.</p>
<h3>Option 3: MiniProfiler</h3>
<p>MiniProfiler highlights duplicate queries automatically:</p>
<pre><code class="language-csharp">builder.Services.AddMiniProfiler(options =&gt;
{
    options.RouteBasePath = &quot;/profiler&quot;;
}).AddEntityFramework();
</code></pre>
<h2>Disable Lazy Loading</h2>
<p>The simplest way to prevent N+1 - don't enable lazy loading in the first place:</p>
<pre><code class="language-csharp">// ❌ Enables lazy loading - invites N+1
options.UseLazyLoadingProxies();

// ✅ Default - no lazy loading
// Accessing unloaded navigation returns null or empty collection
options.UseNpgsql(connectionString);
</code></pre>
<p>Without lazy loading, unloaded navigations simply stay unpopulated: reference navigations are <code>null</code>, and collection navigations hold whatever you initialized them to (typically an empty list). This forces you to explicitly load what you need - and makes missing data obvious in testing instead of silently slow in production.</p>
<p>The N+1 problem is one of several <a href="https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes"><strong>EF Core query performance mistakes</strong></a> worth auditing your codebase for.</p>
<h2>Quick Reference</h2>
<ul>
<li><strong><code>Include</code></strong>: when you need full entities with their children; 1 query (or a few, if split)</li>
<li><strong><code>AsSplitQuery</code></strong>: when multiple includes cause a cartesian explosion; one base query plus one per included collection</li>
<li><strong><code>Select</code> projection</strong>: for read-only scenarios needing specific fields; 1 query, minimal data</li>
<li><strong>Explicit loading</strong>: for loading children of a single entity on demand; 1 extra query per <code>LoadAsync</code></li>
<li><strong>Dapper or raw SQL</strong>: when you need maximum control; exactly the SQL you write</li>
</ul>
<h2>Summary</h2>
<p>The N+1 problem can turn one query into hundreds of network round trips.
Prefer a projection for read models, <code>Include</code> for entity graphs, and split queries when one joined result would duplicate too much data.
Keep lazy loading disabled by default and verify query counts on representative data with logging, an interceptor, or a profiler.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Lazy Loading vs Eager Loading vs Explicit Loading]]></title>
            <link>https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core</guid>
            <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core gives you three ways to load related data: eager loading, explicit loading, and lazy loading. Each has trade-offs.]]></description>
            <content:encoded><![CDATA[<p>EF Core gives you three ways to load related data, and they differ in when the query runs.
Eager loading fetches related data upfront in the same query with <code>Include</code>.
Explicit loading waits until the parent entity is in memory, then issues one query per <code>LoadAsync</code> call.
Lazy loading fires a query the first time you touch a navigation property, which is where the N+1 problem comes from.</p>
<p>Eager loading is the best default for most scenarios.
Use explicit loading for conditional paths, and avoid lazy loading in production.</p>
<h2>The Related Data Problem</h2>
<p>When you query an <code>Order</code>, should EF Core also load the <code>OrderLineItems</code>? What about the <code>Customer</code>? Loading everything upfront is wasteful. Loading nothing means extra round trips later.</p>
<p>EF Core provides three strategies: <strong>eager loading</strong>, <strong>explicit loading</strong>, and <strong>lazy loading</strong>. Understanding when to use each one is critical for <a href="https://milanjovanovic.tech/blog/ef-core-performance-guide"><strong>performance</strong></a>.</p>
<h2>Eager Loading With Include</h2>
<p>Eager loading fetches related data in the same query using <code>Include</code> and <code>ThenInclude</code>:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .Include(o =&gt; o.LineItems)
    .Include(o =&gt; o.Customer)
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<p>EF Core generates a single query with <code>JOIN</code>s:</p>
<pre><code class="language-sql">SELECT o.*, li.*, c.*
FROM &quot;Orders&quot; o
LEFT JOIN &quot;OrderLineItems&quot; li ON li.&quot;OrderId&quot; = o.&quot;Id&quot;
LEFT JOIN &quot;Customers&quot; c ON c.&quot;Id&quot; = o.&quot;CustomerId&quot;
WHERE o.&quot;Id&quot; = @p0;
</code></pre>
<p>For nested relationships, use <code>ThenInclude</code>:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .Include(o =&gt; o.LineItems)
        .ThenInclude(li =&gt; li.Product)
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<h3>Filtered Includes</h3>
<p>Since EF Core 5, you can filter what gets included:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .Include(o =&gt; o.LineItems
        .Where(li =&gt; li.Quantity &gt; 0)
        .OrderBy(li =&gt; li.Price))
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<p>This is useful when you don't need all related entities - only a subset.</p>
<h3>When to Use Eager Loading</h3>
<p>Eager loading is the best default for most scenarios. You know exactly which relationships you need upfront, and EF Core fetches them in a single round trip. The downside is that large <code>Include</code> chains can produce massive SQL queries with many <code>JOIN</code>s.</p>
<p>When you include multiple sibling collections, the joined result set multiplies (a cartesian explosion).
Use <code>AsSplitQuery()</code> in those cases, and measure the round-trip versus duplication tradeoff described in <a href="https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting"><strong>EF Core query splitting</strong></a>.</p>
<h2>Explicit Loading</h2>
<p>Explicit loading lets you load related data <strong>on demand</strong> after the parent entity is already in memory:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

// Load line items explicitly
await context.Entry(order)
    .Collection(o =&gt; o.LineItems)
    .LoadAsync();

// Load a single reference
await context.Entry(order)
    .Reference(o =&gt; o.Customer)
    .LoadAsync();
</code></pre>
<p>Each <code>LoadAsync</code> call issues a separate <code>SELECT</code> query. This gives you fine-grained control over when data is fetched.</p>
<h3>Querying Before Loading</h3>
<p>You can also apply filters or projections before loading:</p>
<pre><code class="language-csharp">var expensiveItems = await context.Entry(order)
    .Collection(o =&gt; o.LineItems)
    .Query()
    .Where(li =&gt; li.Price &gt; 100)
    .ToListAsync();
</code></pre>
<p>The <code>Query()</code> method returns an <code>IQueryable</code> that you can chain with any LINQ operator. This avoids loading the entire collection when you only need a subset.</p>
<h3>When to Use Explicit Loading</h3>
<p>Explicit loading works well when:</p>
<ul>
<li>You conditionally need related data based on runtime logic</li>
<li>You want to avoid massive <code>JOIN</code> queries</li>
<li>You've loaded an entity from the <a href="https://milanjovanovic.tech/blog/change-tracker-ef-core"><strong>change tracker</strong></a> and need its relationships later</li>
</ul>
<h2>Lazy Loading</h2>
<p>Lazy loading automatically fetches related data the first time you access a navigation property. EF Core intercepts the property access and issues a query behind the scenes.</p>
<h3>Setting Up Lazy Loading</h3>
<p>Install the <code>Microsoft.EntityFrameworkCore.Proxies</code> package and enable it:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
           .UseLazyLoadingProxies());
</code></pre>
<p>Mark navigation properties as <code>virtual</code> so EF Core can create proxy classes:</p>
<pre><code class="language-csharp">public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }

    public Guid CustomerId { get; set; }
    public virtual Customer Customer { get; set; }

    public virtual ICollection&lt;OrderLineItem&gt; LineItems { get; set; }
}
</code></pre>
<p>Now accessing <code>order.LineItems</code> triggers a query automatically:</p>
<pre><code class="language-csharp">var order = await context.Orders
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

// This triggers a SELECT query for line items
foreach (var item in order.LineItems)
{
    // This triggers ANOTHER query for each product
    Console.WriteLine(item.Product.Name);
}
</code></pre>
<h3>The N+1 Problem</h3>
<p>That last example has a serious performance problem. If the order has 50 line items, accessing <code>item.Product</code> in the loop fires <strong>50 separate queries</strong> - one for each product. Combined with the initial order query and the line items query, that's 52 queries total.</p>
<p>This is the <a href="https://milanjovanovic.tech/blog/n-plus-one-query-ef-core"><strong>N+1 problem</strong></a>, and it's the biggest risk of lazy loading. Everything looks correct, but the app is hammering the database.</p>
<h3>Lazy Loading Without Proxies</h3>
<p>If you want lazy loading for a specific entity without proxying your whole model, EF Core supports injecting <code>ILazyLoader</code>:</p>
<pre><code class="language-csharp">public class Order
{
    private readonly ILazyLoader _lazyLoader;
    private List&lt;OrderLineItem&gt; _lineItems;

    public Order()
    {
    }

    private Order(ILazyLoader lazyLoader)
    {
        _lazyLoader = lazyLoader;
    }

    public List&lt;OrderLineItem&gt; LineItems
    {
        get =&gt; _lazyLoader.Load(this, ref _lineItems);
        set =&gt; _lineItems = value;
    }
}
</code></pre>
<p>EF Core injects <code>ILazyLoader</code> through the private constructor when it materializes the entity.
The <code>Load</code> extension method (from <code>Microsoft.EntityFrameworkCore.Infrastructure</code>) queries the collection on first access and is a no-op when the loader is <code>null</code>, so <code>new Order()</code> still works in tests.
It is more explicit than proxies, but it couples entities to an EF Core interface and rarely justifies that tradeoff.</p>
<h3>Detecting N+1 Queries</h3>
<p>You can <a href="https://milanjovanovic.tech/blog/log-sql-queries-ef-core"><strong>log SQL queries</strong></a> to catch this in development:</p>
<pre><code class="language-csharp">builder.Services.AddDbContext&lt;AppDbContext&gt;(options =&gt;
    options.UseNpgsql(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());
</code></pre>
<p>If you see a pattern of many similar <code>SELECT</code> statements inside a loop, you've found an N+1 problem.</p>
<h2>Comparing the Three Strategies</h2>
<img src="https://milanjovanovic.tech/blogs/articles/lazy-eager-explicit-loading-ef-core/loading-strategies.png" alt="The three related-data loading strategies branching from a single decision: eager loading with Include runs one query with JOINs, explicit loading with LoadAsync runs one query per call on demand, and lazy loading runs a query on each property access with N+1 risk">
<ul>
<li><strong>Eager loading</strong>: one query with JOINs (or split queries), you decide upfront what to load, no N+1 risk. Best for relationships you always need.</li>
<li><strong>Explicit loading</strong>: one query per <code>LoadAsync</code> call, full on-demand control, low N+1 risk. Best for conditional loading.</li>
<li><strong>Lazy loading</strong>: one query per navigation property access, fully automatic, high N+1 risk. Acceptable for prototyping, dangerous in production.</li>
</ul>
<h2>My Recommendations</h2>
<p>A practical default is:</p>
<p><strong>Use eager loading as the default.</strong> When you write a query, you almost always know what related data you need. Use <code>Include</code> to fetch it upfront.</p>
<p><strong>Use explicit loading for conditional paths.</strong> If you only need line items when the order status is Confirmed, use explicit loading to avoid unnecessary data.</p>
<p><strong>Avoid lazy loading in production.</strong> Lazy loading hides database queries behind property access. It makes performance problems invisible until they hit production. If you must use it, treat it as a shortcut for prototyping - not a production strategy.</p>
<pre><code class="language-csharp">// ✅ Eager - clear intent, single round trip
var order = await context.Orders
    .Include(o =&gt; o.LineItems)
        .ThenInclude(li =&gt; li.Product)
    .Include(o =&gt; o.Customer)
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

// ✅ Explicit - conditional loading
var order = await context.Orders
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

if (order.Status == OrderStatus.Confirmed)
{
    await context.Entry(order)
        .Collection(o =&gt; o.LineItems)
        .LoadAsync();
}

// ❌ Lazy - hidden queries, N+1 risk
foreach (var item in order.LineItems) // hidden query
{
    Console.WriteLine(item.Product.Name); // hidden query per item
}
</code></pre>
<h2>Summary</h2>
<p>Use a projection or eager loading when the query already knows which related data it needs.
Use explicit loading when the decision depends on state discovered after the entity is loaded.
Lazy loading hides network calls behind property access, so enable it only with query-count visibility and a deliberate reason.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[EF Core Query Performance: Avoid These Mistakes]]></title>
            <link>https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/ef-core-query-performance-mistakes</guid>
            <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[EF Core is powerful, but it is easy to write LINQ queries that generate slow SQL. Here are the most common performance mistakes and how to fix them: N+1…]]></description>
            <content:encoded><![CDATA[<p>LINQ can hide an expensive query behind code that looks harmless.
The most common EF Core query performance mistakes are N+1 loading, materializing entire tables, loading tracked entities for read-only work, missing pagination and indexes, and cartesian explosions from sibling <code>Include</code> calls.
Fixing those query-shape mistakes usually matters more than replacing the ORM.</p>
<h2>Why Are My EF Core Queries Slow?</h2>
<p><a href="https://milanjovanovic.tech/blog/ef-core-performance-guide"><strong>EF Core</strong></a> generates SQL from your LINQ queries. The SQL it generates is only as good as the LINQ you write.</p>
<p>These are the query-shape mistakes that most often turn straightforward LINQ into expensive database work.</p>
<h2>Mistake 1: N+1 Queries</h2>
<p>The most notorious performance problem. You load a list of orders, then access each order's customer in a loop:</p>
<pre><code class="language-csharp">// ✗ N+1 problem - 1 query for orders + N queries for customers
var orders = await dbContext.Orders.ToListAsync();

foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name); // Lazy load triggers a query
}
</code></pre>
<p>If you have 100 orders, this executes 101 queries.
I dig into detection and every fix in detail in the <a href="https://milanjovanovic.tech/blog/n-plus-one-query-ef-core"><strong>N+1 query problem in EF Core</strong></a>.</p>
<p><strong>Fix: Use eager loading or projections:</strong></p>
<pre><code class="language-csharp">// ✓ Eager loading - 1 query with JOIN
var orders = await dbContext.Orders
    .Include(o =&gt; o.Customer)
    .ToListAsync();

// ✓ Even better - projection, only fetches needed columns
var orderSummaries = await dbContext.Orders
    .Select(o =&gt; new
    {
        o.Id,
        CustomerName = o.Customer.Name,
        o.TotalAmount
    })
    .ToListAsync();
</code></pre>
<p>Projections are usually the better fit for read models because they select only the columns the result needs.</p>
<h2>Mistake 2: Loading Entire Tables</h2>
<pre><code class="language-csharp">// ✗ Loads ALL orders into memory, then filters
var expensiveOrders = dbContext.Orders
    .ToList() // Everything loaded here
    .Where(o =&gt; o.TotalAmount &gt; 1000);
</code></pre>
<p>The <code>.ToList()</code> before <code>.Where()</code> materializes the entire table. The filtering happens in C#, not SQL.</p>
<p><strong>Fix: Filter before materializing:</strong></p>
<pre><code class="language-csharp">// ✓ SQL WHERE clause - only matching rows returned
var expensiveOrders = await dbContext.Orders
    .Where(o =&gt; o.TotalAmount &gt; 1000)
    .ToListAsync();
</code></pre>
<p><strong>Rule of thumb:</strong> Call <code>.ToListAsync()</code> or <code>.FirstOrDefaultAsync()</code> as the <em>last</em> operation.</p>
<h2>Mistake 3: Loading Entities for Read-Only Operations</h2>
<pre><code class="language-csharp">// ✗ Loads full Order entity with change tracking
var order = await dbContext.Orders
    .Include(o =&gt; o.LineItems)
    .Include(o =&gt; o.Customer)
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);

return new OrderResponse(
    order.Id,
    order.Customer.Name,
    order.TotalAmount,
    order.Status.ToString());
</code></pre>
<p>You loaded a full entity graph with change tracking, just to map it to a DTO. Wasteful.</p>
<p><strong>Fix: Project directly into the DTO:</strong></p>
<pre><code class="language-csharp">// ✓ No entity loading, no change tracking, minimal SQL
var response = await dbContext.Orders
    .Where(o =&gt; o.Id == orderId)
    .Select(o =&gt; new OrderResponse(
        o.Id,
        o.Customer.Name,
        o.TotalAmount,
        o.Status.ToString()))
    .FirstOrDefaultAsync();
</code></pre>
<p>Or use <code>AsNoTracking()</code> if you must load entities:</p>
<pre><code class="language-csharp">// ✓ At least skip change tracking
var order = await dbContext.Orders
    .AsNoTracking()
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<h2>Mistake 4: Missing Pagination</h2>
<pre><code class="language-csharp">// ✗ Returns every order in the database
var orders = await dbContext.Orders.ToListAsync();
</code></pre>
<p><strong>Fix: Always paginate collection queries:</strong></p>
<pre><code class="language-csharp">// ✓ Fetches only one page
var orders = await dbContext.Orders
    .OrderBy(o =&gt; o.CreatedAt)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .Select(o =&gt; new OrderSummary(o.Id, o.Status, o.TotalAmount))
    .ToListAsync();
</code></pre>
<p>For large or frequently-scrolled datasets, cursor-based pagination outperforms offset pagination - see <a href="https://milanjovanovic.tech/blog/pagination-aspnetcore"><strong>pagination in ASP.NET Core</strong></a> for the tradeoffs.</p>
<h2>Mistake 5: Missing Indexes</h2>
<p>EF Core creates indexes for primary keys and foreign keys. But your custom queries often need additional indexes.</p>
<p>If your <code>WHERE</code> clause filters on <code>Status</code> and <code>CreatedAt</code>, you need a composite index:</p>
<pre><code class="language-csharp">builder.HasIndex(o =&gt; new { o.Status, o.CreatedAt })
    .HasDatabaseName(&quot;IX_Orders_Status_CreatedAt&quot;);
</code></pre>
<p>Check your query plans. If you see a sequential scan on a table with millions of rows, you're missing an index.</p>
<h2>Mistake 6: Using String Interpolation in Queries</h2>
<pre><code class="language-csharp">// ✗ SQL injection risk AND prevents query plan caching
var orders = await dbContext.Orders
    .FromSqlRaw($&quot;&quot;&quot;SELECT * FROM &quot;Orders&quot; WHERE &quot;Status&quot; = '{status}'&quot;&quot;&quot;)
    .ToListAsync();
</code></pre>
<p><strong>Fix: Use parameterized queries:</strong></p>
<pre><code class="language-csharp">// ✓ Parameterized - safe and cacheable
var orders = await dbContext.Orders
    .FromSqlInterpolated($&quot;&quot;&quot;SELECT * FROM &quot;Orders&quot; WHERE &quot;Status&quot; = {status}&quot;&quot;&quot;)
    .ToListAsync();
</code></pre>
<p><code>FromSqlInterpolated</code> automatically parameterizes the interpolated values. <code>FromSqlRaw</code> with string interpolation is a SQL injection vulnerability.
In EF Core 7+, <code>FromSql</code> does the same thing as <code>FromSqlInterpolated</code> with a shorter name.</p>
<h2>Mistake 7: Cartesian Explosion</h2>
<p>Loading multiple collections with <code>Include</code> creates a cartesian product:</p>
<pre><code class="language-csharp">// ✗ Cartesian explosion - rows multiply
var order = await dbContext.Orders
    .Include(o =&gt; o.LineItems)    // 10 items
    .Include(o =&gt; o.Payments)     // 3 payments
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
// Returns 30 rows (10 x 3) instead of ~13
</code></pre>
<img src="https://milanjovanovic.tech/blogs/articles/ef-core-query-performance-mistakes/cartesian-explosion.png" alt="Including two sibling collections on an order (10 line items and 3 payments) multiplies into a single joined result of 30 rows, while AsSplitQuery issues two queries returning about 13 rows total">
<p><strong>Fix: Use split queries or separate loads:</strong></p>
<pre><code class="language-csharp">// ✓ Split query - separate SQL per Include
var order = await dbContext.Orders
    .Include(o =&gt; o.LineItems)
    .Include(o =&gt; o.Payments)
    .AsSplitQuery()
    .FirstOrDefaultAsync(o =&gt; o.Id == orderId);
</code></pre>
<p>EF Core 5+ also has a global setting:</p>
<pre><code class="language-csharp">optionsBuilder.UseNpgsql(connectionString, o =&gt;
    o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
</code></pre>
<p>Split queries aren't free: they trade one round trip for several, and the queries can observe different data unless wrapped in a transaction.
The round-trip and duplication tradeoffs are covered in <a href="https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting"><strong>EF Core query splitting</strong></a>.</p>
<h2>Mistake 8: Not Using Compiled Queries for Hot Paths</h2>
<p>For queries that execute thousands of times per second:</p>
<pre><code class="language-csharp">private static readonly Func&lt;AppDbContext, Guid, Task&lt;Order?&gt;&gt; GetOrderByIdQuery =
    EF.CompileAsyncQuery(
        (AppDbContext db, Guid id) =&gt;
            db.Orders.FirstOrDefault(o =&gt; o.Id == id));

// Usage
var order = await GetOrderByIdQuery(dbContext, orderId);
</code></pre>
<p>Compiled queries skip repeated LINQ compilation work after the delegate is created.
That can matter on hot endpoints with cheap database work, but it should be measured as described in <a href="https://milanjovanovic.tech/blog/unleash-ef-core-performance-with-compiled-queries"><strong>EF Core compiled queries</strong></a>.</p>
<h2>Mistake 9: Querying Inside Loops</h2>
<pre><code class="language-csharp">// ✗ One query per customer
foreach (var customerId in customerIds)
{
    var orders = await dbContext.Orders
        .Where(o =&gt; o.CustomerId == customerId)
        .ToListAsync();

    // Process orders
}
</code></pre>
<p><strong>Fix: Batch the query:</strong></p>
<pre><code class="language-csharp">// ✓ Single query for all customers, grouped in memory
var orders = await dbContext.Orders
    .Where(o =&gt; customerIds.Contains(o.CustomerId))
    .ToListAsync();

var ordersByCustomer = orders.ToLookup(o =&gt; o.CustomerId);
</code></pre>
<p>One round trip instead of N, and the in-memory <code>ToLookup</code> gives you the same per-customer grouping.</p>
<h2>Quick Reference</h2>
<ul>
<li><strong>N+1 queries</strong>: use <code>Include()</code> or, better, a <code>Select()</code> projection</li>
<li><strong>Loading full tables</strong>: filter with <code>Where()</code> before <code>ToList()</code></li>
<li><strong>Change tracking overhead</strong>: use <code>AsNoTracking()</code> or projections</li>
<li><strong>No pagination</strong>: add <code>Skip()</code> + <code>Take()</code>, or cursor pagination</li>
<li><strong>Missing indexes</strong>: add indexes for your WHERE/ORDER BY columns</li>
<li><strong>SQL injection</strong>: use <code>FromSqlInterpolated</code>, never interpolate into <code>FromSqlRaw</code></li>
<li><strong>Cartesian explosion</strong>: use <code>AsSplitQuery()</code></li>
<li><strong>Hot path overhead</strong>: use <code>EF.CompileAsyncQuery()</code></li>
<li><strong>Queries in loops</strong>: batch with <code>Contains()</code></li>
</ul>
<h2>Summary</h2>
<p>Project only the columns the read model needs, support selective predicates with indexes, and paginate every potentially large collection.
Watch the generated SQL and query count so N+1 loading and cartesian products cannot hide behind concise LINQ.
Optimize from a measured database plan before replacing EF Core or adding a lower-level data-access path.</p>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[When to Choose Vertical Slice Architecture Over Layered Architecture]]></title>
            <link>https://milanjovanovic.tech/blog/when-to-choose-vertical-slice-architecture</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/when-to-choose-vertical-slice-architecture</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Four files across four folders for a query that returns one object: that is the layered tax on every feature.]]></description>
            <content:encoded><![CDATA[<p>Choose Vertical Slice Architecture when your features are mostly independent, the application is CRUD-heavy, you want fast iteration with minimal ceremony, or your team works on many features in parallel.
Layers still win when many features share complex business logic or you need strict compile-time boundaries.
Here are the concrete signals for each, and what to do when they point both ways.</p>
<p>Layered architecture is the default choice in .NET, and defaults rarely get questioned.
But an architecture you picked by inertia is still an architecture decision, just one made without looking at the alternatives.</p>
<h2>The Problem With Layers</h2>
<p>In a layered architecture, a simple &quot;Get Order by ID&quot; feature touches:</p>
<ol>
<li><code>OrdersController</code> (Presentation)</li>
<li><code>IOrderService</code> + <code>OrderService</code> (Application)</li>
<li><code>IOrderRepository</code> + <code>OrderRepository</code> (Infrastructure)</li>
<li><code>OrderDto</code>, <code>OrderResponse</code> (Mapping)</li>
</ol>
<p>Four files across four folders for a database query that returns one object. Adding a new feature means touching every layer. Modifying a feature means jumping between folders.</p>
<h2>What Changes Together Should Live Together</h2>
<p><a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a> organizes code by feature instead of layer, and it's <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-is-easier-than-you-think"><strong>easier to adopt than most people think</strong></a>:</p>
<pre><code>// Layered: related code is scattered
Controllers/OrdersController.cs    ← GetOrder, CreateOrder, DeleteOrder
Services/OrderService.cs           ← GetOrder, CreateOrder, DeleteOrder
Repositories/OrderRepository.cs    ← GetOrder, CreateOrder, DeleteOrder

// VSA: related code is co-located
Features/Orders/GetOrder.cs        ← everything for GetOrder
Features/Orders/CreateOrder.cs     ← everything for CreateOrder
Features/Orders/DeleteOrder.cs     ← everything for DeleteOrder
</code></pre>
<p>When you work on &quot;Get Order,&quot; you open one file. When you review a pull request, the diff shows one file per feature.</p>
<h2>When to Choose VSA</h2>
<h3>Your Features Are Independent</h3>
<p>If most features don't share logic, VSA reduces unnecessary abstractions:</p>
<pre><code class="language-csharp">// This feature needs no repository, no service layer
public static class GetOrder
{
    public sealed record Query(Guid Id);

    public sealed record OrderResponse(
        Guid Id, string Status, decimal TotalAmount, DateTime CreatedAt);

    public sealed class Handler(ApplicationDbContext db)
    {
        public async Task&lt;OrderResponse?&gt; Handle(
            Query query, CancellationToken ct)
        {
            return await db.Orders
                .Where(o =&gt; o.Id == query.Id)
                .Select(o =&gt; new OrderResponse(
                    o.Id, o.Status, o.TotalAmount, o.CreatedAt))
                .FirstOrDefaultAsync(ct);
        }
    }
}
</code></pre>
<p>No interface, no repository, no service. Just a query that returns data.</p>
<h3>Your Team Is Growing</h3>
<p>With layers, two developers working on separate features often edit the same files - the same controller, the same service. Merge conflicts happen frequently.</p>
<p>With VSA, each developer works in separate files. Feature A doesn't touch Feature B's code.</p>
<h3>You Want Fast Iteration</h3>
<p>VSA has less ceremony. Adding a new feature:</p>
<ol>
<li>Create one file</li>
<li>Define the request, handler, and endpoint</li>
<li>Done</li>
</ol>
<p>No interface to define, no repository to implement, and assembly scanning handles the registration.</p>
<h3>Your Application Is CRUD-Heavy</h3>
<p>Many business applications are variations of create-read-update-delete. VSA handles this cleanly:</p>
<pre><code>Features/
  Products/
    CreateProduct.cs      ← 60 lines
    GetProduct.cs         ← 40 lines
    GetProducts.cs        ← 50 lines
    UpdateProduct.cs      ← 70 lines
    DeleteProduct.cs      ← 30 lines
</code></pre>
<p>Each file is small and self-contained. No layered abstractions adding complexity without value.</p>
<h3>You're Using CQRS</h3>
<p><a href="https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start"><strong>CQRS</strong></a> and VSA are a natural pair. Commands and queries are already separate operations - putting each in its own file is the logical next step:</p>
<pre><code>Features/
  Orders/
    Commands/
      PlaceOrder.cs
      CancelOrder.cs
    Queries/
      GetOrder.cs
      GetOrders.cs
</code></pre>
<h2>When to Choose Layers</h2>
<h3>You Have Complex Shared Business Logic</h3>
<p>If 10 features all need the same pricing calculation, a <code>PricingService</code> in a service layer makes sense. Duplicating that logic across 10 slices is worse.</p>
<h3>You Need Strict Architectural Boundaries</h3>
<p>Layers enforce compile-time boundaries. The presentation layer physically cannot reference the database. VSA in a single project doesn't prevent a handler from doing whatever it wants.</p>
<h3>Your Team Is Familiar With Layers</h3>
<p>Architecture decisions are team decisions. If your team knows layered architecture well and productivity is good, switching to VSA for the sake of switching creates churn without value.</p>
<h3>You Have a Rich Domain Model</h3>
<p><strong>Domain-Driven Design</strong> with a <strong>rich domain model</strong> benefits from a dedicated domain layer. The domain layer contains complex business rules that multiple features share. <a href="https://milanjovanovic.tech/blog/clean-architecture-vs-onion-vs-hexagonal"><strong>Clean Architecture</strong></a> is a better fit here.</p>
<h2>The Middle Ground</h2>
<p>You don't have to be all-in on either approach. Many successful projects combine both:</p>
<pre><code>src/
  MyApp.Api/
    Features/           ← Vertical slices for individual operations
      Orders/
        PlaceOrder.cs
        GetOrder.cs
    Domain/             ← Shared domain entities (from Clean Architecture)
      Order.cs
      Customer.cs
    Shared/             ← Cross-cutting concerns
      Behaviors/
        ValidationBehavior.cs
</code></pre>
<p>Use slices for application logic. Use a domain layer for shared business rules. Use pipeline behaviors for cross-cutting concerns.</p>
<h2>Migrating From Layers, Incrementally</h2>
<p>Choosing VSA doesn't mean rewriting your layered application. The migration path I recommend:</p>
<ol>
<li><strong>New features go in a <code>Features</code> folder</strong> as self-contained slices. Don't touch the existing layers yet.</li>
<li><strong>When you modify an existing feature</strong>, consider moving it into a slice as part of the change. The controller action, service method, and repository method collapse into one handler.</li>
<li><strong>Leave stable code alone.</strong> A feature nobody has touched in a year gains nothing from being restructured.</li>
<li><strong>Delete layers as they empty out.</strong> When <code>OrderService</code> has one method left, inline it and remove the class.</li>
</ol>
<p>After a few months you have a codebase that's mostly slices with a small legacy core, and you got there without a big-bang rewrite or a feature freeze.</p>
<h2>Decision Matrix</h2>
<img src="https://milanjovanovic.tech/blogs/articles/when-to-choose-vertical-slice-architecture/architecture-decision.png" alt="A decision flow: rich shared domain logic or strict compile-time boundaries point to layered or Clean Architecture; independent, CRUD-heavy, or CQRS features point to vertical slices, otherwise a hybrid">
<p>Signals that point toward <strong>Vertical Slice Architecture</strong>:</p>
<ul>
<li>Features are largely independent of each other</li>
<li>The application is CRUD-heavy</li>
<li>You need fast iteration with minimal ceremony</li>
<li>The team is growing and works on many features in parallel</li>
<li>You're already using CQRS</li>
</ul>
<p>Signals that point toward <strong>layers</strong>:</p>
<ul>
<li>Heavy shared business logic across features</li>
<li>A rich domain model with DDD</li>
<li>You need strict compile-time boundaries</li>
<li>The team is experienced and productive with layers</li>
</ul>
<p>If your signals land on both sides, that's normal. It usually means the hybrid approach (slices plus a shared domain layer) is your answer.</p>
<h2>Summary</h2>
<p>Choose <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a> when:</p>
<ol>
<li><strong>Features are independent</strong> and rarely share logic</li>
<li><strong>Your team needs parallel development</strong> without merge conflicts</li>
<li><strong>You value simplicity</strong> - one file per feature, no unnecessary abstractions</li>
<li><strong>You're already using CQRS</strong> (with or without MediatR)</li>
<li><strong>The application is CRUD-heavy</strong> without complex domain logic</li>
</ol>
<p>Choose layers when shared business logic, strict boundaries, or DDD richness justifies the overhead.</p>
<p>The best architecture is the one your team can maintain and evolve. Start with what fits your problem, not what's trending.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Vertical Slice Architecture vs Clean Architecture]]></title>
            <link>https://milanjovanovic.tech/blog/vertical-slice-vs-clean-architecture</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/vertical-slice-vs-clean-architecture</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Clean Architecture manages complexity through layer discipline. Vertical Slice Architecture manages it through feature isolation.]]></description>
            <content:encoded><![CDATA[<p>Neither architecture is better universally, and the choice is a fit question, not a religious war.
<strong>Clean Architecture</strong> manages complexity through layer discipline, and it wins when features share a rich domain model.
<strong>Vertical Slice Architecture</strong> manages complexity through feature isolation, and it wins when features vary wildly in complexity.
Here is how they compare, when to pick which, and the hybrid that many real projects land on.</p>
<h2>Two Different Philosophies</h2>
<p><a href="https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design"><strong>Clean Architecture</strong></a> organizes code by <strong>technical layer</strong> - Domain, Application, Infrastructure, Presentation. Each layer has clear responsibilities, and dependencies point inward.</p>
<p><a href="https://milanjovanovic.tech/blog/vertical-slice-architecture"><strong>Vertical Slice Architecture</strong></a> organizes code by <strong>feature</strong> - each feature is a self-contained slice that cuts through all layers from UI to database. You don't share code between slices unless it's a genuine cross-cutting concern.</p>
<p>Both are valid. But they optimize for different things.</p>
<img src="https://milanjovanovic.tech/blogs/articles/vertical-slice-vs-clean-architecture/clean-vs-vsa.png" alt="Clean Architecture groups code into Presentation, Application, Domain, and Infrastructure layers, while Vertical Slice Architecture groups code into self-contained feature slices">
<h2>Clean Architecture in Brief</h2>
<p>Clean Architecture separates your solution into concentric layers:</p>
<pre><code>Presentation → Application → Domain ← Infrastructure
</code></pre>
<p>The dependency rule: inner layers define abstractions, outer layers implement them.</p>
<p>A typical project structure:</p>
<pre><code>MyApp.Domain/         ← Entities, Value Objects, Interfaces
MyApp.Application/    ← Use Cases, DTOs, Validators
MyApp.Infrastructure/ ← EF Core, External APIs
MyApp.Api/            ← Controllers, Endpoints
</code></pre>
<p>Adding a new feature means touching <strong>multiple projects</strong>: define the entity in Domain, add a command/handler in Application, configure persistence in Infrastructure, add an endpoint in Presentation.</p>
<h2>Vertical Slice Architecture in Brief</h2>
<p>Vertical Slice Architecture groups everything for a feature together:</p>
<pre><code>Features/
  PlaceOrder/
    PlaceOrderEndpoint.cs
    PlaceOrderCommand.cs
    PlaceOrderHandler.cs
    PlaceOrderValidator.cs
  GetOrderById/
    GetOrderByIdEndpoint.cs
    GetOrderByIdQuery.cs
    GetOrderByIdHandler.cs
    OrderResponse.cs
</code></pre>
<p>Adding a new feature means creating a <strong>new folder</strong> with all the code for that feature. You don't modify existing features.</p>
<h2>Key Differences</h2>
<h3>Coupling Direction</h3>
<p><strong>Clean Architecture</strong> couples by <em>layer</em>. All repositories live together. All entities live together. Changing how repositories work could affect many features.</p>
<p><strong>Vertical Slices</strong> couple by <em>feature</em>. Each feature is independent. Changing the PlaceOrder feature doesn't affect GetOrderById.</p>
<h3>Code Reuse</h3>
<p><strong>Clean Architecture</strong> encourages code reuse across features. A shared <code>OrderRepository</code> serves every use case that needs orders.</p>
<p><strong>Vertical Slices</strong> minimize shared code. Each feature can query the database differently. PlaceOrder might use a repository; GetOrderById might use raw Dapper.</p>
<h3>Consistency</h3>
<p><strong>Clean Architecture</strong> enforces consistency. Every feature follows the same patterns - same handler structure, same validation approach, same repository layer.</p>
<p><strong>Vertical Slices</strong> allow variation. Simple CRUD features can be simple. Complex features can use rich domain models. Each slice uses what it needs.</p>
<h3>Indirection</h3>
<p><strong>Clean Architecture</strong> adds layers of indirection. To trace a request from endpoint to database, you pass through multiple abstractions: endpoint → handler → repository → DbContext.</p>
<p><strong>Vertical Slices</strong> minimize indirection. A simple query handler might go straight to the database with no intermediate abstractions.</p>
<h2>Side-by-Side Comparison</h2>
<p>Here's how the two approaches stack up, dimension by dimension:</p>
<table><thead><tr><th></th><th>Clean Architecture</th><th>Vertical Slice Architecture</th></tr></thead><tbody><tr><td>Organization</td><td>By technical layer</td><td>By feature</td></tr><tr><td>Coupling</td><td>Within layers</td><td>Within features</td></tr><tr><td>Code reuse</td><td>High, through shared services and repositories</td><td>Intentionally low</td></tr><tr><td>Consistency</td><td>Enforced by uniform patterns</td><td>Each slice picks its own abstraction level</td></tr><tr><td>New feature effort</td><td>Touches multiple projects</td><td>One new folder</td></tr><tr><td>Learning curve</td><td>Moderate to high</td><td>Low</td></tr><tr><td>Best for</td><td>Complex domain logic</td><td>Features with varied complexity</td></tr><tr><td>Main risk</td><td>Over-engineering simple features</td><td>Duplication between features</td></tr></tbody></table>
<h2>When to Choose Clean Architecture</h2>
<p><strong>Choose Clean Architecture when:</strong></p>
<ol>
<li>
<p><strong>Your domain is complex.</strong> If you have rich business rules, invariants, and domain events, Clean Architecture gives you the structure to manage that complexity.</p>
</li>
<li>
<p><strong>Multiple features share domain logic.</strong> If your Order entity is used by PlaceOrder, CancelOrder, RefundOrder, and ShipOrder - sharing it through a Domain layer makes sense.</p>
</li>
<li>
<p><strong>You want strict architectural boundaries.</strong> Clean Architecture's layers can be enforced with <a href="https://milanjovanovic.tech/blog/enforcing-software-architecture-with-architecture-tests"><strong>architecture tests</strong></a>, giving you confidence that infrastructure doesn't leak into your domain.</p>
</li>
<li>
<p><strong>Your team is large and values uniformity.</strong> One enforced pattern for every use case keeps dozens of developers producing code that looks the same, which pays off in reviews and onboarding.</p>
</li>
<li>
<p><strong>The project is long-lived.</strong> The upfront investment in structure pays off over years as the codebase grows.</p>
</li>
</ol>
<h2>When to Choose Vertical Slice Architecture</h2>
<p><strong>Choose Vertical Slices when:</strong></p>
<ol>
<li>
<p><strong>Features have different complexity.</strong> Some endpoints are simple CRUD, others have complex workflows. Vertical slices let each feature use the appropriate level of abstraction.</p>
</li>
<li>
<p><strong>You want fast feature delivery.</strong> New features are self-contained. You don't need to understand the entire repository layer to add a new query.</p>
</li>
<li>
<p><strong>Your team is small.</strong> Less infrastructure to maintain. Fewer abstractions to navigate.</p>
</li>
<li>
<p><strong>You're building a CRUD-heavy API.</strong> If most features are thin wrappers around database operations, layers add overhead without value.</p>
</li>
<li>
<p><strong>You want to minimize coupling.</strong> Features that don't share code can be modified and tested in isolation, without touching the rest of the system.</p>
</li>
</ol>
<h2>Can You Combine Them?</h2>
<p>Yes. And many teams do.</p>
<p>A common approach: use <strong>Vertical Slices for feature organization</strong> inside a <strong>Clean Architecture solution structure</strong>.</p>
<pre><code>MyApp.Application/
  Features/
    Orders/
      PlaceOrder/
        PlaceOrderCommand.cs
        PlaceOrderCommandHandler.cs
      GetOrderById/
        GetOrderByIdQuery.cs
        GetOrderByIdQueryHandler.cs
    Customers/
      RegisterCustomer/
        RegisterCustomerCommand.cs
        RegisterCustomerCommandHandler.cs
</code></pre>
<p>You get:</p>
<ul>
<li>Feature-based organization (vertical slices)</li>
<li>Layer boundaries enforced at the project level (Clean Architecture)</li>
<li>Shared domain model for complex business rules</li>
<li>Independent use cases that don't affect each other</li>
</ul>
<p>This hybrid approach is what I use in my <a href="https://milanjovanovic.tech/pragmatic-clean-architecture">Pragmatic Clean Architecture course</a>. It gives you the best of both worlds.</p>
<h2>Common Mistakes</h2>
<p><strong>1. Using Clean Architecture for everything.</strong> A simple API with five CRUD endpoints doesn't need four projects and a dozen abstractions.</p>
<p><strong>2. Duplicating everything in Vertical Slices.</strong> If three features need the same validation logic, extract it. &quot;Minimize code sharing&quot; doesn't mean &quot;never share code.&quot;</p>
<p><strong>3. Choosing based on popularity, not fit.</strong> Clean Architecture is more popular in .NET. That doesn't make it the right choice for every project.</p>
<p><strong>4. Thinking it's permanent.</strong> You can start with Vertical Slices and introduce layer boundaries as complexity grows. Architecture should evolve with your project. If you're unsure where your project falls, I've written about <a href="https://milanjovanovic.tech/blog/when-to-choose-vertical-slice-architecture"><strong>when to choose Vertical Slice Architecture</strong></a> in more detail.</p>
<h2>Summary</h2>
<p>Clean Architecture and Vertical Slice Architecture solve different problems:</p>
<ul>
<li><strong>Clean Architecture</strong> manages complexity through <strong>layer discipline</strong></li>
<li><strong>Vertical Slices</strong> manage complexity through <strong>feature isolation</strong></li>
</ul>
<p>For complex domains with shared business rules → Clean Architecture.
For varied features with different complexity levels → Vertical Slices.
For most real projects → a pragmatic combination of both.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Vertical Slice Architecture Folder Structure: From 5 to 50+ Features]]></title>
            <link>https://milanjovanovic.tech/blog/vertical-slice-project-structure-dotnet</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/vertical-slice-project-structure-dotnet</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A concrete folder and solution layout for Vertical Slice Architecture in .NET, and how it evolves as you grow from 5 features to 50+.]]></description>
            <content:encoded><![CDATA[<p>Structure a Vertical Slice Architecture project around a <code>Features</code> folder with one file per use case, plus a <code>Domain</code> folder for shared entities, <code>Data</code> for EF Core infrastructure, and <code>Shared</code> for cross-cutting behaviors.
One project is enough to start, and grouping by domain area keeps the layout manageable past 50 features.</p>
<p>Vertical Slice Architecture sounds simple until you create the solution and have to decide where everything goes.
Where do domain entities live?
Does the DbContext get its own project?
This article answers those questions with a concrete layout, and shows how it evolves as the project grows.</p>
<h2>The Problem With Layered Folders</h2>
<p>In a traditional layered project, you get folders like this:</p>
<pre><code>Controllers/
  OrdersController.cs
  CustomersController.cs
  ProductsController.cs
Services/
  OrderService.cs
  CustomerService.cs
  ProductService.cs
Repositories/
  OrderRepository.cs
  CustomerRepository.cs
  ProductRepository.cs
Models/
  Order.cs
  Customer.cs
  Product.cs
</code></pre>
<p>To understand how &quot;Place Order&quot; works, you jump between 4+ folders. Adding a feature means touching multiple folders. Related code is scattered.</p>
<p><a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a> fixes this by organizing code around features. This article is about the practical part: the actual folder and solution layout, and how it holds up as the feature count grows.</p>
<p>For what goes <strong>inside</strong> a slice (the command, handler, and validator structure), see my newsletter issue on <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-structuring-vertical-slices"><strong>structuring vertical slices</strong></a>. Here we stay at the folder level.</p>
<h2>The Starting Layout: 5-15 Features</h2>
<p>One project, one <code>Features</code> folder, one file per use case:</p>
<pre><code>Features/
  Orders/
    PlaceOrder.cs
    GetOrder.cs
    GetOrders.cs
    CancelOrder.cs
    OrdersModule.cs
  Customers/
    RegisterCustomer.cs
    GetCustomer.cs
    UpdateCustomer.cs
    CustomersModule.cs
  Products/
    CreateProduct.cs
    GetProducts.cs
    SearchProducts.cs
    ProductsModule.cs
</code></pre>
<p>Everything for &quot;Place Order&quot; is in one file. Everything for orders is in one folder. The <code>*Module.cs</code> file per folder registers that feature group's endpoints (a <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-carter-dotnet"><strong>Carter</strong></a> module or a plain extension method, your choice).</p>
<p>Two naming rules keep this navigable:</p>
<ul>
<li><strong>Files are verbs</strong>: <code>PlaceOrder.cs</code>, not <code>OrderService.cs</code>. The folder listing reads like a feature list.</li>
<li><strong>One use case per file</strong>: if a file handles two operations, it's two slices pretending to be one.</li>
</ul>
<h2>Full Project Structure</h2>
<p>Here's the complete single-project layout I use:</p>
<pre><code>src/
  MyApp.Api/
    Features/
      Orders/
        PlaceOrder.cs
        GetOrder.cs
        GetOrders.cs
        CancelOrder.cs
        UpdateOrderStatus.cs
        OrdersModule.cs
      Customers/
        RegisterCustomer.cs
        GetCustomer.cs
        GetCustomers.cs
        UpdateCustomer.cs
        CustomersModule.cs
      Products/
        CreateProduct.cs
        GetProducts.cs
        SearchProducts.cs
        ProductsModule.cs
    Domain/
      Order.cs
      Customer.cs
      Product.cs
      Common/
        Entity.cs
        Result.cs
        Error.cs
    Data/
      ApplicationDbContext.cs
      Configurations/
        OrderConfiguration.cs
        CustomerConfiguration.cs
        ProductConfiguration.cs
      Migrations/
    Shared/
      Behaviors/
        ValidationBehavior.cs
        LoggingBehavior.cs
      Middleware/
        ExceptionHandlingMiddleware.cs
    Program.cs
tests/
  MyApp.Api.Tests/
    Features/
      Orders/
        PlaceOrderTests.cs
        GetOrderTests.cs
      Customers/
        RegisterCustomerTests.cs
</code></pre>
<p>Note that the test project mirrors the <code>Features</code> tree exactly. Finding the tests for a slice should never require a search.</p>
<h2>Key Decisions</h2>
<h3>One Project or Multiple?</h3>
<p><strong>Single project</strong> - the default for VSA. Keep it simple:</p>
<pre><code>MyApp.Api/
  Features/
  Domain/
  Data/
  Shared/
</code></pre>
<p><strong>Multiple projects</strong> - only when you need strict compile-time enforcement:</p>
<pre><code>MyApp.Api/          ← entry point
MyApp.Features/     ← all features
MyApp.Domain/       ← domain entities
MyApp.Data/         ← EF Core, migrations
</code></pre>
<p>Start with one project. Split when you have a reason. Folder boundaries are cheap to change; project boundaries are not. If you want boundary enforcement without extra projects, <strong>architecture tests</strong> on namespaces get you most of the way.</p>
<h3>Where Do Domain Entities Live?</h3>
<p>In a <code>Domain/</code> folder within the same project:</p>
<pre><code>Domain/
  Order.cs
  LineItem.cs
  Customer.cs
  Product.cs
  Common/
    Entity.cs
    AggregateRoot.cs
    IDomainEvent.cs
</code></pre>
<p>Domain entities are shared across features. An <code>Order</code> entity is used by <code>PlaceOrder</code>, <code>GetOrder</code>, and <code>CancelOrder</code>. Slices own their request and response types; they share the domain model underneath.</p>
<h3>Where Does the DbContext Live?</h3>
<p>In a <code>Data/</code> folder:</p>
<pre><code>Data/
  ApplicationDbContext.cs
  Configurations/
    OrderConfiguration.cs
    CustomerConfiguration.cs
  Migrations/
</code></pre>
<p>EF Core configurations are separate from features - they're infrastructure, not business logic.</p>
<h3>Shared Code (Cross-Cutting Concerns)</h3>
<p>Pipeline behaviors, middleware, and shared abstractions in <code>Shared/</code>:</p>
<pre><code>Shared/
  Behaviors/
    ValidationBehavior.cs
    LoggingBehavior.cs
    CachingBehavior.cs
  Middleware/
    ExceptionHandlingMiddleware.cs
    RequestLoggingMiddleware.cs
  Abstractions/
    ICommand.cs
    IQuery.cs
    ICacheable.cs
  Extensions/
    ResultExtensions.cs
</code></pre>
<p>Keep this folder small and boring. If <code>Shared</code> starts accumulating business logic, a slice boundary is leaking; see <a href="https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture"><strong>cross-cutting concerns in Vertical Slice Architecture</strong></a> for what belongs here and what doesn't.</p>
<h2>When Features Get Complex</h2>
<p>A simple feature fits in one file. A complex feature graduates to a folder:</p>
<pre><code>Features/
  Orders/
    PlaceOrder/
      PlaceOrderCommand.cs
      PlaceOrderHandler.cs
      PlaceOrderValidator.cs
      PlaceOrderResponse.cs
    GetOrder/
      GetOrderQuery.cs
      GetOrderHandler.cs
    Shared/
      OrderResponse.cs
    OrdersModule.cs
</code></pre>
<p>My threshold: split into a folder when the single file grows past roughly 150-200 lines, or when a slice needs private helper classes that would pollute the file.</p>
<p>A feature-local <code>Shared/</code> folder (like <code>Orders/Shared/</code>) is fine for DTOs reused by two or three sibling slices, like the <code>OrderResponse</code> that both <code>GetOrder</code> and <code>GetOrders</code> return. It's still inside the feature boundary, which is very different from a global shared folder.</p>
<h2>Scaling to 50+ Features</h2>
<p>A flat <code>Features</code> folder stops working around a few dozen slices. The fix is one more level: group by domain area.</p>
<img src="https://milanjovanovic.tech/blogs/articles/vertical-slice-project-structure-dotnet/structure-evolution.png" alt="The folder structure evolves from a flat Features folder at 5-15 features, to domain-area grouping at 50+ features, to a modular monolith where each area becomes a module">
<pre><code>Features/
  Ordering/
    PlaceOrder.cs
    GetOrder.cs
    CancelOrder.cs
    OrderingModule.cs
  Catalog/
    CreateProduct.cs
    SearchProducts.cs
    CatalogModule.cs
  Identity/
    RegisterUser.cs
    Login.cs
    RefreshToken.cs
    IdentityModule.cs
  Shipping/
    CreateShipment.cs
    TrackShipment.cs
    ShippingModule.cs
</code></pre>
<p>These groups aren't arbitrary. They mirror <a href="https://milanjovanovic.tech/blog/bounded-context-ddd-explained"><strong>bounded contexts</strong></a>, and each one is a candidate module if you later evolve toward a <a href="https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet"><strong>modular monolith</strong></a>. At that point each domain area gets its own project (or set of projects), and the folder structure you already have becomes the module structure. I've written about exactly <a href="https://milanjovanovic.tech/blog/where-vertical-slices-fit-inside-the-modular-monolith-architecture"><strong>where vertical slices fit inside a modular monolith</strong></a>.</p>
<p>The practical signals that you've hit this stage:</p>
<ul>
<li>You scroll to find anything in <code>Features/</code></li>
<li>Two domain areas keep reaching into each other's entities</li>
<li>Different teams own different feature groups and step on each other in PRs</li>
</ul>
<p>Restructuring is mechanical: create the domain-area folders, move files, fix namespaces. Do it in one PR before the pain compounds.</p>
<h2>Conventions That Keep the Structure Healthy</h2>
<p>A folder structure only stays clean if a few conventions back it up:</p>
<ul>
<li><strong>Namespace mirrors folder.</strong> <code>MyApp.Api.Features.Ordering.PlaceOrder</code> tells you exactly where the file lives. Most IDEs enforce this automatically.</li>
<li><strong>Handlers are <code>internal</code>.</strong> Nothing outside the slice should call a handler directly. The endpoint (or dispatcher) is the only entry point.</li>
<li><strong>One route prefix per module.</strong> <code>OrdersModule</code> owns <code>/api/orders</code>; no other module maps routes under it.</li>
<li><strong>No slice-to-slice references.</strong> If <code>PlaceOrder</code> needs something from <code>Shipping</code>, that's a domain service or a domain event, not a <code>using</code> statement. A couple of <a href="https://milanjovanovic.tech/blog/5-architecture-tests-you-should-add-to-your-dotnet-projects"><strong>architecture tests</strong></a> will hold this line for you.</li>
</ul>
<h2>Summary</h2>
<p>A practical VSA folder structure:</p>
<ol>
<li><strong>Features folder</strong> - one file per use case, named as a verb</li>
<li><strong>Domain folder</strong> - shared entities and value objects</li>
<li><strong>Data folder</strong> - DbContext, configurations, migrations</li>
<li><strong>Shared folder</strong> - pipeline behaviors, middleware, abstractions (keep it boring)</li>
<li><strong>Single project</strong> to start, split only when you need compile-time boundaries</li>
<li><strong>Single-file slices</strong> first, folders past ~150-200 lines</li>
<li><strong>Domain-area grouping</strong> at 50+ features, mirroring bounded contexts</li>
</ol>
<p>The structure should make it obvious what your application does by reading the feature folder names.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Vertical Slice Architecture With Carter in .NET]]></title>
            <link>https://milanjovanovic.tech/blog/vertical-slice-architecture-carter-dotnet</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/vertical-slice-architecture-carter-dotnet</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Carter turns Minimal API endpoints into self-registering modules: each feature defines its routes, and app.MapCarter() wires them up at startup.]]></description>
            <content:encoded><![CDATA[<p>Carter organizes ASP.NET Core Minimal API endpoints into self-registering modules: each feature defines its routes in an <code>ICarterModule</code> class, and <code>app.MapCarter()</code> discovers and maps them at startup.
That convention is a natural fit for Vertical Slice Architecture, where each feature already owns its handler and validator.
Here is how I combine the two, from installation to integration tests.</p>
<p>Minimal APIs are great until <code>Program.cs</code> hits 500 lines.</p>
<h2>What Is Carter?</h2>
<p><a href="https://github.com/CarterCommunity/Carter">Carter</a> is a library that adds convention-based routing to ASP.NET Core <a href="https://milanjovanovic.tech/blog/minimal-apis-dotnet"><strong>Minimal APIs</strong></a>. It lets you define endpoints in self-contained modules instead of one giant <code>Program.cs</code>.</p>
<p>Combined with <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a>, Carter gives each feature its own endpoint module, handler, and model - all in one place.</p>
<h2>Setting Up Carter</h2>
<p>Install the package:</p>
<pre><code class="language-bash">dotnet add package Carter
</code></pre>
<p>Register it:</p>
<pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCarter();

builder.Services.AddMediatR(config =&gt;
{
    config.RegisterServicesFromAssembly(typeof(Program).Assembly);
    config.AddOpenBehavior(typeof(ValidationBehavior&lt;,&gt;));
});

builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

var app = builder.Build();

app.MapCarter();
app.Run();
</code></pre>
<p><code>MapCarter()</code> automatically discovers all <code>ICarterModule</code> implementations and registers their routes.</p>
<p><code>ValidationBehavior</code> is the FluentValidation pipeline behavior that runs each slice's validator before the handler and returns failures as a failed <code>Result</code>.
I break it down in <a href="https://milanjovanovic.tech/blog/validation-vertical-slice-architecture"><strong>validation in Vertical Slice Architecture</strong></a>.</p>
<img src="https://milanjovanovic.tech/blogs/articles/vertical-slice-architecture-carter-dotnet/carter-module-discovery.png" alt="app.MapCarter scans the assembly for ICarterModule implementations and registers the routes each module owns, one module per feature area">
<h2>A Feature Slice With Carter</h2>
<p>Here's a complete <a href="https://milanjovanovic.tech/blog/feature-folders-dotnet"><strong>feature slice</strong></a> for placing an order:</p>
<pre><code class="language-csharp">// Features/Orders/PlaceOrder.cs
public static class PlaceOrder
{
    public sealed record Command(
        Guid CustomerId,
        List&lt;OrderItemRequest&gt; Items) : IRequest&lt;Result&lt;Guid&gt;&gt;;

    public sealed record OrderItemRequest(Guid ProductId, int Quantity);

    public sealed class Validator : AbstractValidator&lt;Command&gt;
    {
        public Validator()
        {
            RuleFor(x =&gt; x.CustomerId).NotEmpty();
            RuleFor(x =&gt; x.Items).NotEmpty();
            RuleForEach(x =&gt; x.Items).ChildRules(item =&gt;
            {
                item.RuleFor(x =&gt; x.ProductId).NotEmpty();
                item.RuleFor(x =&gt; x.Quantity).GreaterThan(0);
            });
        }
    }

    public sealed class Handler : IRequestHandler&lt;Command, Result&lt;Guid&gt;&gt;
    {
        private readonly ApplicationDbContext _db;

        public Handler(ApplicationDbContext db) =&gt; _db = db;

        public async Task&lt;Result&lt;Guid&gt;&gt; Handle(
            Command request, CancellationToken ct)
        {
            var customer = await _db.Customers
                .FirstOrDefaultAsync(c =&gt; c.Id == request.CustomerId, ct);

            if (customer is null)
            {
                return Result.Failure&lt;Guid&gt;(
                    new Error(&quot;Customer.NotFound&quot;, &quot;Customer not found.&quot;));
            }

            var order = new Order
            {
                Id = Guid.NewGuid(),
                CustomerId = request.CustomerId,
                Items = request.Items.Select(i =&gt; new OrderItem
                {
                    ProductId = i.ProductId,
                    Quantity = i.Quantity
                }).ToList(),
                CreatedAt = DateTime.UtcNow
            };

            _db.Orders.Add(order);
            await _db.SaveChangesAsync(ct);

            return order.Id;
        }
    }
}
</code></pre>
<p>Now the Carter module:</p>
<pre><code class="language-csharp">// Features/Orders/OrdersModule.cs
public class OrdersModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup(&quot;/api/orders&quot;)
            .WithTags(&quot;Orders&quot;);

        group.MapPost(&quot;&quot;, async (
            PlaceOrder.Command command,
            ISender sender,
            CancellationToken ct) =&gt;
        {
            var result = await sender.Send(command, ct);

            return result.IsSuccess
                ? Results.Created($&quot;/api/orders/{result.Value}&quot;, result.Value)
                : result.ToProblemDetails();
        });

        group.MapGet(&quot;{id:guid}&quot;, async (
            Guid id,
            ISender sender,
            CancellationToken ct) =&gt;
        {
            var result = await sender.Send(new GetOrder.Query(id), ct);

            return result.IsSuccess
                ? Results.Ok(result.Value)
                : result.ToProblemDetails();
        });

        group.MapGet(&quot;&quot;, async (
            int page,
            int pageSize,
            ISender sender,
            CancellationToken ct) =&gt;
        {
            var result = await sender.Send(
                new GetOrders.Query(page, pageSize), ct);

            return Results.Ok(result.Value);
        });
    }
}
</code></pre>
<p><code>ToProblemDetails()</code> is a small extension method that maps a failed <code>Result</code> to <code>Results.Problem</code> or <code>Results.ValidationProblem</code>, so every endpoint returns consistent Problem Details responses.</p>
<h2>Project Structure</h2>
<pre><code>Features/
  Orders/
    PlaceOrder.cs
    GetOrder.cs
    GetOrders.cs
    CancelOrder.cs
    OrdersModule.cs
  Customers/
    RegisterCustomer.cs
    GetCustomer.cs
    CustomersModule.cs
  Products/
    CreateProduct.cs
    GetProducts.cs
    ProductsModule.cs
</code></pre>
<p>Each feature folder contains:</p>
<ul>
<li><strong>One file per use case</strong> (command/query + handler + validator)</li>
<li><strong>One Carter module</strong> for routing all endpoints in that domain</li>
</ul>
<p>Everything for orders lives in <code>Features/Orders/</code>. No jumping between Controllers, Services, Models, and Repositories folders.</p>
<h2>Route Groups and Common Configuration</h2>
<p>Carter modules support route groups with shared configuration:</p>
<pre><code class="language-csharp">public class OrdersModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup(&quot;/api/orders&quot;)
            .WithTags(&quot;Orders&quot;)
            .RequireAuthorization();

        group.MapPost(&quot;&quot;, HandlePlaceOrder);
        group.MapGet(&quot;{id:guid}&quot;, HandleGetOrder);
        group.MapDelete(&quot;{id:guid}&quot;, HandleCancelOrder)
            .RequireAuthorization(&quot;Admin&quot;);
    }

    private static async Task&lt;IResult&gt; HandlePlaceOrder(
        PlaceOrder.Command command,
        ISender sender,
        CancellationToken ct)
    {
        var result = await sender.Send(command, ct);
        return result.IsSuccess
            ? Results.Created($&quot;/api/orders/{result.Value}&quot;, result.Value)
            : result.ToProblemDetails();
    }

    private static async Task&lt;IResult&gt; HandleGetOrder(
        Guid id,
        ISender sender,
        CancellationToken ct)
    {
        var result = await sender.Send(new GetOrder.Query(id), ct);
        return result.IsSuccess
            ? Results.Ok(result.Value)
            : result.ToProblemDetails();
    }

    private static async Task&lt;IResult&gt; HandleCancelOrder(
        Guid id,
        ISender sender,
        CancellationToken ct)
    {
        var result = await sender.Send(new CancelOrder.Command(id), ct);
        return result.IsSuccess
            ? Results.NoContent()
            : result.ToProblemDetails();
    }
}
</code></pre>
<p>Extract handler methods to keep <code>AddRoutes</code> readable.</p>
<h2>Endpoint Filters</h2>
<p>If you'd rather handle <a href="https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture"><strong>cross-cutting concerns</strong></a> at the HTTP boundary instead of inside the MediatR pipeline, use endpoint filters.
Here's a validation filter that resolves the slice's FluentValidation validator from DI:</p>
<pre><code class="language-csharp">public class ValidationFilter&lt;TRequest&gt; : IEndpointFilter
{
    public async ValueTask&lt;object?&gt; InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments
            .OfType&lt;TRequest&gt;()
            .FirstOrDefault();

        if (request is null)
        {
            return await next(context);
        }

        var validator = context.HttpContext.RequestServices
            .GetService&lt;IValidator&lt;TRequest&gt;&gt;();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(request);
            if (!result.IsValid)
            {
                return Results.ValidationProblem(result.ToDictionary());
            }
        }

        return await next(context);
    }
}
</code></pre>
<p>The filter is generic over the request type, so you apply it per endpoint:</p>
<pre><code class="language-csharp">group.MapPost(&quot;&quot;, HandlePlaceOrder)
    .AddEndpointFilter&lt;ValidationFilter&lt;PlaceOrder.Command&gt;&gt;();
</code></pre>
<p>Pick one place to validate (pipeline behavior or endpoint filter), not both.</p>
<h2>Testing Carter Modules</h2>
<p>Because Carter modules are just Minimal API routes, they test like any other endpoint through <strong>WebApplicationFactory</strong>:</p>
<pre><code class="language-csharp">public class OrdersModuleTests
    : IClassFixture&lt;WebApplicationFactory&lt;Program&gt;&gt;
{
    private readonly HttpClient _client;

    public OrdersModuleTests(WebApplicationFactory&lt;Program&gt; factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task PlaceOrder_WithInvalidBody_ReturnsProblemDetails()
    {
        var response = await _client.PostAsJsonAsync(&quot;/api/orders&quot;, new
        {
            CustomerId = Guid.Empty,
            Items = Array.Empty&lt;object&gt;()
        });

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}
</code></pre>
<p><code>MapCarter()</code> runs during test host startup, so all modules are discovered exactly as in production. No special test setup for Carter itself.</p>
<h2>Why Carter + VSA Works</h2>
<p>What the combination buys you:</p>
<ul>
<li><strong>Auto-discovery</strong>: Carter finds modules automatically, no manual registration in <code>Program.cs</code></li>
<li><strong>Feature isolation</strong>: each module encapsulates a bounded set of endpoints</li>
<li><strong>Clean Program.cs</strong>: just <code>app.MapCarter()</code> instead of dozens of <code>MapGet</code>/<code>MapPost</code> calls</li>
<li><strong>Route grouping</strong>: share authorization, filters, and tags across related endpoints</li>
<li><strong>Testability</strong>: each handler is independent and easily unit-tested</li>
</ul>
<h2>Carter vs. Plain Minimal APIs</h2>
<p>Without Carter, endpoints pile up in <code>Program.cs</code> or require manual extension methods:</p>
<pre><code class="language-csharp">// Without Carter - gets messy fast
app.MapPost(&quot;/api/orders&quot;, HandlePlaceOrder);
app.MapGet(&quot;/api/orders/{id}&quot;, HandleGetOrder);
app.MapGet(&quot;/api/orders&quot;, HandleGetOrders);
app.MapPost(&quot;/api/customers&quot;, HandleRegisterCustomer);
app.MapGet(&quot;/api/customers/{id}&quot;, HandleGetCustomer);
// ... 50 more lines
</code></pre>
<p>With Carter, each module owns its routes. <code>Program.cs</code> stays clean.</p>
<p>To be fair, Carter isn't the only way to get there. You can build the same auto-discovery yourself with an <code>IEndpoint</code> interface and assembly scanning, which I showed in <a href="https://milanjovanovic.tech/blog/automatically-register-minimal-apis-in-aspnetcore"><strong>automatically registering Minimal APIs</strong></a>. Choose Carter if you want the convention ready-made and don't mind a third-party dependency in your API layer; roll your own if you'd rather own those 30 lines of reflection.</p>
<h2>Summary</h2>
<p>Carter doesn't change what a vertical slice is.
It standardizes how a slice exposes its routes: each feature folder gets a module, each module owns its endpoints, and <code>Program.cs</code> shrinks to <code>app.MapCarter()</code>.</p>
<p>If you want that convention ready-made, use Carter.
If you'd rather avoid the dependency, the <code>IEndpoint</code> approach gets you the same result for 30 lines of your own code.
Either way, if you're building with Minimal APIs and <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-is-easier-than-you-think"><strong>vertical slices</strong></a>, the endpoints should end up where they belong: inside the slice.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Validation in Vertical Slice Architecture]]></title>
            <link>https://milanjovanovic.tech/blog/validation-vertical-slice-architecture</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/validation-vertical-slice-architecture</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Where does validation go in Vertical Slice Architecture? Co-locate validators with features and use a pipeline behavior to run them automatically.]]></description>
            <content:encoded><![CDATA[<p>Validation in Vertical Slice Architecture lives in the slice: the validator sits right next to the command and handler it guards, and a pipeline behavior runs it automatically.
In a layered codebase, validation rules end up far from the feature they protect.
Here is the full setup with FluentValidation and MediatR: co-located validators, automatic execution, and a clean split between input validation and domain validation.</p>
<h2>Where Does Validation Go?</h2>
<p>In <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a>, each feature is self-contained. The validator lives next to the handler - not in a separate &quot;Validators&quot; folder across the project.</p>
<pre><code>Features/
  Orders/
    PlaceOrder.cs        ← Command + Handler + Validator
    GetOrder.cs
    CancelOrder.cs
</code></pre>
<h2>The Validator</h2>
<p>Use FluentValidation to define rules:</p>
<pre><code class="language-csharp">public static class PlaceOrder
{
    public sealed record Command(
        Guid CustomerId,
        List&lt;OrderItemRequest&gt; Items) : IRequest&lt;Result&lt;Guid&gt;&gt;;

    public sealed record OrderItemRequest(
        Guid ProductId,
        int Quantity,
        decimal UnitPrice);

    public sealed class Validator : AbstractValidator&lt;Command&gt;
    {
        public Validator()
        {
            RuleFor(x =&gt; x.CustomerId)
                .NotEmpty()
                .WithMessage(&quot;Customer ID is required.&quot;);

            RuleFor(x =&gt; x.Items)
                .NotEmpty()
                .WithMessage(&quot;At least one item is required.&quot;);

            RuleForEach(x =&gt; x.Items).ChildRules(item =&gt;
            {
                item.RuleFor(x =&gt; x.ProductId).NotEmpty();
                item.RuleFor(x =&gt; x.Quantity)
                    .GreaterThan(0)
                    .WithMessage(&quot;Quantity must be positive.&quot;);
                item.RuleFor(x =&gt; x.UnitPrice)
                    .GreaterThan(0)
                    .WithMessage(&quot;Price must be positive.&quot;);
            });
        }
    }

    public sealed class Handler : IRequestHandler&lt;Command, Result&lt;Guid&gt;&gt;
    {
        private readonly ApplicationDbContext _db;

        public Handler(ApplicationDbContext db) =&gt; _db = db;

        public async Task&lt;Result&lt;Guid&gt;&gt; Handle(
            Command request, CancellationToken ct)
        {
            // No input validation here - the pipeline already ran it
            var order = Order.Create(request.CustomerId, request.Items);

            _db.Orders.Add(order);
            await _db.SaveChangesAsync(ct);

            return order.Id;
        }
    }
}
</code></pre>
<p>The command, validator, and handler are all in one file. Everything about &quot;Place Order&quot; is in one place.</p>
<h2>Automatic Validation With a Pipeline Behavior</h2>
<p>Instead of calling the validator manually in every handler, use a MediatR pipeline behavior. This is the same approach I showed for <a href="https://milanjovanovic.tech/blog/cqrs-validation-with-mediatr-pipeline-and-fluentvalidation"><strong>CQRS validation with MediatR and FluentValidation</strong></a>:</p>
<pre><code class="language-csharp">public sealed class ValidationBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : IRequest&lt;TResponse&gt;
{
    private readonly IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; _validators;

    public ValidationBehavior(
        IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; validators)
    {
        _validators = validators;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext&lt;TRequest&gt;(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v =&gt; v.ValidateAsync(context, ct)));

        var failures = validationResults
            .SelectMany(r =&gt; r.Errors)
            .Where(f =&gt; f is not null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}
</code></pre>
<p>Register it:</p>
<pre><code class="language-csharp">builder.Services.AddMediatR(cfg =&gt;
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior&lt;,&gt;));
});

builder.Services.AddValidatorsFromAssembly(
    typeof(Program).Assembly);
</code></pre>
<p>Every request that has a matching <code>IValidator&lt;T&gt;</code> is validated automatically before reaching the handler.
Pair this throwing variant with a <a href="https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers"><strong>global exception handler</strong></a> that turns <code>ValidationException</code> into a 400 Problem Details response.</p>
<h2>Result-Based Validation</h2>
<p>Instead of throwing exceptions, return validation errors as a <code>Result</code>:</p>
<pre><code class="language-csharp">public sealed class ValidationBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : IRequest&lt;TResponse&gt;
    where TResponse : Result
{
    private readonly IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; _validators;

    public ValidationBehavior(
        IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; validators)
    {
        _validators = validators;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext&lt;TRequest&gt;(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v =&gt; v.ValidateAsync(context, ct)));

        var errors = validationResults
            .SelectMany(r =&gt; r.Errors)
            .Where(f =&gt; f is not null)
            .Select(f =&gt; new Error(f.PropertyName, f.ErrorMessage))
            .ToArray();

        if (errors.Length != 0)
            return (TResponse)(object)Result.Failure(
                new ValidationError(errors));

        return await next();
    }
}
</code></pre>
<p>The handler never runs if validation fails. The endpoint returns a 400 response with the validation errors.</p>
<p>One wrinkle to be aware of: the cast at the end only works when <code>TResponse</code> is the non-generic <code>Result</code>. For handlers returning <code>Result&lt;T&gt;</code>, a complete implementation creates the typed failure through a static factory or a small piece of reflection. It's a one-time cost in the behavior, and every slice benefits.</p>
<h2>Input Validation vs Domain Validation</h2>
<p>There are two layers of validation in any application:</p>
<img src="https://milanjovanovic.tech/blogs/articles/validation-vertical-slice-architecture/input-vs-domain-validation.png" alt="Input validation with FluentValidation runs before the handler and rejects bad format; domain validation runs inside the handler and enforces business rules like stock and customer status">
<p><strong>Input validation</strong> (FluentValidation) - checks data format and presence:</p>
<ul>
<li>Is the email format valid?</li>
<li>Is the quantity positive?</li>
<li>Is the required field present?</li>
</ul>
<p><strong>Domain validation</strong> (<strong>domain invariants</strong>) - checks business rules:</p>
<ul>
<li>Can this customer place an order?</li>
<li>Is this product in stock?</li>
<li>Does the discount code apply?</li>
</ul>
<pre><code class="language-csharp">// Input validation (FluentValidation) - runs BEFORE the handler
public sealed class Validator : AbstractValidator&lt;Command&gt;
{
    public Validator()
    {
        RuleFor(x =&gt; x.CustomerId).NotEmpty();
        RuleForEach(x =&gt; x.Items).ChildRules(item =&gt;
        {
            item.RuleFor(x =&gt; x.ProductId).NotEmpty();
            item.RuleFor(x =&gt; x.Quantity).GreaterThan(0);
        });
    }
}

// Domain validation - runs INSIDE the handler
public sealed class Handler : IRequestHandler&lt;Command, Result&lt;Guid&gt;&gt;
{
    private readonly ApplicationDbContext _db;

    public Handler(ApplicationDbContext db) =&gt; _db = db;

    public async Task&lt;Result&lt;Guid&gt;&gt; Handle(
        Command request, CancellationToken ct)
    {
        var productIds = request.Items.Select(i =&gt; i.ProductId).ToList();

        var products = await _db.Products
            .Where(p =&gt; productIds.Contains(p.Id))
            .ToDictionaryAsync(p =&gt; p.Id, ct);

        foreach (var item in request.Items)
        {
            if (!products.TryGetValue(item.ProductId, out var product))
                return Result.Failure&lt;Guid&gt;(ProductErrors.NotFound);

            if (product.StockQuantity &lt; item.Quantity)
                return Result.Failure&lt;Guid&gt;(ProductErrors.InsufficientStock);
        }

        // Domain checks passed - create the order
        var order = Order.Create(request.CustomerId, request.Items);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        return order.Id;
    }
}
</code></pre>
<p>Input validation rejects obviously bad data. Domain validation enforces business rules.</p>
<h2>Async Validators</h2>
<p>Some validation requires database access:</p>
<pre><code class="language-csharp">public sealed class Validator : AbstractValidator&lt;Command&gt;
{
    public Validator(ApplicationDbContext db)
    {
        RuleFor(x =&gt; x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, ct) =&gt;
                !await db.Users.AnyAsync(u =&gt; u.Email == email, ct))
            .WithMessage(&quot;Email is already registered.&quot;);
    }
}
</code></pre>
<p>Use async validators sparingly. Most input validation should be synchronous. Save database checks for the handler when possible.</p>
<h2>Testing Your Validators</h2>
<p>Co-located validators are trivially testable with FluentValidation's built-in <code>TestHelper</code>:</p>
<pre><code class="language-csharp">public class PlaceOrderValidatorTests
{
    private readonly PlaceOrder.Validator _validator = new();

    [Fact]
    public void Should_Fail_When_Items_Are_Empty()
    {
        var command = new PlaceOrder.Command(Guid.NewGuid(), []);

        var result = _validator.TestValidate(command);

        result.ShouldHaveValidationErrorFor(x =&gt; x.Items);
    }

    [Fact]
    public void Should_Pass_For_Valid_Command()
    {
        var command = new PlaceOrder.Command(
            Guid.NewGuid(),
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 2, 10m)]);

        var result = _validator.TestValidate(command);

        result.ShouldNotHaveAnyValidationErrors();
    }
}
</code></pre>
<p><code>TestValidate</code> gives you assertion helpers that point at the exact rule that failed. These tests run in microseconds, so cover every rule. More on the broader strategy in <a href="https://milanjovanovic.tech/blog/testing-vertical-slices-dotnet"><strong>testing vertical slices</strong></a>.</p>
<h2>Endpoint Error Mapping</h2>
<p>Map validation errors to Problem Details:</p>
<pre><code class="language-csharp">app.MapPost(&quot;/api/orders&quot;, async (
    PlaceOrder.Command command,
    ISender sender) =&gt;
{
    var result = await sender.Send(command);

    return result.Match(
        onSuccess: id =&gt; Results.Created($&quot;/api/orders/{id}&quot;, id),
        onFailure: error =&gt; error switch
        {
            ValidationError ve =&gt; Results.ValidationProblem(
                ve.Errors.GroupBy(e =&gt; e.Code)
                    .ToDictionary(
                        g =&gt; g.Key,
                        g =&gt; g.Select(e =&gt; e.Description).ToArray())),
            _ =&gt; Results.Problem(
                detail: error.Description,
                statusCode: StatusCodes.Status400BadRequest)
        });
});
</code></pre>
<h2>Summary</h2>
<p>Validation in Vertical Slice Architecture:</p>
<ol>
<li><strong>Co-locate validators with features</strong> - same file as the command and handler</li>
<li><strong>Pipeline behavior</strong> validates automatically before the handler runs</li>
<li><strong>Input validation</strong> (format, presence) goes in FluentValidation</li>
<li><strong>Domain validation</strong> (business rules) stays in the handler or domain model</li>
<li><strong>Throw or return Results</strong> - both work, pick one convention and stay consistent</li>
<li><strong>Map to Problem Details</strong> for consistent API error responses</li>
</ol>
<p>Validation is a <a href="https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture"><strong>cross-cutting concern</strong></a> - handle it once in the pipeline, not in every handler.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Testing Vertical Slices in .NET]]></title>
            <link>https://milanjovanovic.tech/blog/testing-vertical-slices-dotnet</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/testing-vertical-slices-dotnet</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Vertical slices change the shape of your tests. Instead of repository, service, and controller tests held together by mocks, you test one feature at a time…]]></description>
            <content:encoded><![CDATA[<p>Test vertical slices by feature, not by layer.
Unit test the validator and handler directly for fast feedback, then run the whole slice end to end (routing, validation, handler, database) with WebApplicationFactory and Testcontainers.
Here is how I structure that, from validator unit tests to full integration tests.</p>
<p>Layered architectures produce layered tests: repository tests, service tests, controller tests, and a mock for every seam between them.
Vertical slices collapse those seams, so the tests change shape too.</p>
<h2>Testing Slices, Not Layers</h2>
<p>In <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a>, each feature is self-contained - request, handler, validation, persistence. This means your tests should be organized by feature, not by layer.</p>
<p>Instead of:</p>
<ul>
<li><code>OrderRepositoryTests</code></li>
<li><code>OrderServiceTests</code></li>
<li><code>OrderControllerTests</code></li>
</ul>
<p>You write:</p>
<ul>
<li><code>PlaceOrderTests</code></li>
<li><code>GetOrderTests</code></li>
<li><code>CancelOrderTests</code></li>
</ul>
<p>Each test covers one slice from input to output.</p>
<h2>Unit Testing a Handler</h2>
<p>The simplest test targets the handler directly:</p>
<pre><code class="language-csharp">public class PlaceOrderTests
{
    private readonly ApplicationDbContext _db;
    private readonly PlaceOrder.Handler _handler;

    public PlaceOrderTests()
    {
        _db = CreateInMemoryDbContext();
        _handler = new PlaceOrder.Handler(_db);
    }

    [Fact]
    public async Task Should_Create_Order_With_Valid_Request()
    {
        // Arrange
        var customer = new Customer { Id = Guid.NewGuid(), Name = &quot;John&quot; };
        _db.Customers.Add(customer);
        await _db.SaveChangesAsync();

        var command = new PlaceOrder.Command(
            customer.Id,
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 2)]);

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeTrue();
        var order = await _db.Orders.FirstAsync();
        order.CustomerId.Should().Be(customer.Id);
        order.Items.Should().HaveCount(1);
    }

    [Fact]
    public async Task Should_Fail_When_Customer_Not_Found()
    {
        var command = new PlaceOrder.Command(
            Guid.NewGuid(),
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 1)]);

        var result = await _handler.Handle(command, CancellationToken.None);

        result.IsSuccess.Should().BeFalse();
        result.Error.Code.Should().Be(&quot;Customer.NotFound&quot;);
    }

    private static ApplicationDbContext CreateInMemoryDbContext()
    {
        var options = new DbContextOptionsBuilder&lt;ApplicationDbContext&gt;()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        return new ApplicationDbContext(options);
    }
}
</code></pre>
<p>This tests business logic without HTTP, serialization, or middleware.</p>
<p>One caveat: the in-memory <code>DbContext</code> keeps these tests fast, but it doesn't enforce constraints or translate real SQL. That's an acceptable trade for handler logic tests. For query-heavy slices, prefer the Testcontainers approach below (I compare the options in <strong>testing EF Core repositories</strong>).</p>
<h2>Testing Validation</h2>
<p>Test validators separately - they're fast and don't need infrastructure:</p>
<pre><code class="language-csharp">public class PlaceOrderValidatorTests
{
    private readonly PlaceOrder.Validator _validator = new();

    [Fact]
    public void Should_Fail_When_CustomerId_Is_Empty()
    {
        var command = new PlaceOrder.Command(
            Guid.Empty,
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 1)]);

        var result = _validator.Validate(command);

        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e =&gt;
            e.PropertyName == nameof(PlaceOrder.Command.CustomerId));
    }

    [Fact]
    public void Should_Fail_When_Items_Empty()
    {
        var command = new PlaceOrder.Command(Guid.NewGuid(), []);

        var result = _validator.Validate(command);

        result.IsValid.Should().BeFalse();
    }

    [Fact]
    public void Should_Pass_With_Valid_Command()
    {
        var command = new PlaceOrder.Command(
            Guid.NewGuid(),
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 3)]);

        var result = _validator.Validate(command);

        result.IsValid.Should().BeTrue();
    }
}
</code></pre>
<h2>Integration Testing With WebApplicationFactory</h2>
<p>For end-to-end slice testing, use <strong>WebApplicationFactory</strong>:</p>
<pre><code class="language-csharp">public class PlaceOrderEndpointTests
    : IClassFixture&lt;WebApplicationFactory&lt;Program&gt;&gt;
{
    private readonly WebApplicationFactory&lt;Program&gt; _factory;
    private readonly HttpClient _client;

    public PlaceOrderEndpointTests(
        WebApplicationFactory&lt;Program&gt; factory)
    {
        _factory = factory.WithWebHostBuilder(builder =&gt;
        {
            builder.ConfigureServices(services =&gt;
            {
                // Replace real DB with test container or in-memory
                services.RemoveAll&lt;DbContextOptions&lt;ApplicationDbContext&gt;&gt;();
                services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
                    options.UseInMemoryDatabase(&quot;test&quot;));
            });
        });

        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task PlaceOrder_Returns_Created()
    {
        // The handler rejects unknown customers, so seed one first
        var customerId = await SeedCustomerAsync();

        var request = new
        {
            CustomerId = customerId,
            Items = new[]
            {
                new { ProductId = Guid.NewGuid(), Quantity = 2 }
            }
        };

        var response = await _client.PostAsJsonAsync(
            &quot;/api/orders&quot;, request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
    }

    [Fact]
    public async Task PlaceOrder_Returns_BadRequest_For_Empty_Items()
    {
        var request = new
        {
            CustomerId = Guid.NewGuid(),
            Items = Array.Empty&lt;object&gt;()
        };

        var response = await _client.PostAsJsonAsync(
            &quot;/api/orders&quot;, request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    private async Task&lt;Guid&gt; SeedCustomerAsync()
    {
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider
            .GetRequiredService&lt;ApplicationDbContext&gt;();

        var customer = new Customer { Id = Guid.NewGuid(), Name = &quot;Test&quot; };
        db.Customers.Add(customer);
        await db.SaveChangesAsync();

        return customer.Id;
    }
}
</code></pre>
<p>This tests the full HTTP pipeline - routing, model binding, validation, handler, persistence, and response serialization.</p>
<h2>Integration Testing With Testcontainers</h2>
<p>For realistic tests against a real database, use <a href="https://milanjovanovic.tech/blog/testcontainers-integration-testing-using-docker-in-dotnet"><strong>Testcontainers</strong></a>:</p>
<pre><code class="language-csharp">public class OrderApiTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage(&quot;postgres:16-alpine&quot;)
        .Build();

    private WebApplicationFactory&lt;Program&gt; _factory = null!;
    private HttpClient _client = null!;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        _factory = new WebApplicationFactory&lt;Program&gt;()
            .WithWebHostBuilder(builder =&gt;
            {
                builder.ConfigureServices(services =&gt;
                {
                    services.RemoveAll&lt;DbContextOptions&lt;ApplicationDbContext&gt;&gt;();
                    services.AddDbContext&lt;ApplicationDbContext&gt;(options =&gt;
                        options.UseNpgsql(_postgres.GetConnectionString()));
                });
            });

        _client = _factory.CreateClient();

        // Apply migrations
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider
            .GetRequiredService&lt;ApplicationDbContext&gt;();
        await db.Database.MigrateAsync();
    }

    [Fact]
    public async Task Full_Order_Lifecycle()
    {
        var customerId = await SeedCustomerAsync();

        // Place order
        var placeResponse = await _client.PostAsJsonAsync(
            &quot;/api/orders&quot;,
            new
            {
                CustomerId = customerId,
                Items = new[]
                {
                    new { ProductId = Guid.NewGuid(), Quantity = 2 }
                }
            });
        placeResponse.StatusCode.Should().Be(HttpStatusCode.Created);

        var orderId = await placeResponse.Content
            .ReadFromJsonAsync&lt;Guid&gt;();

        // Get order
        var getResponse = await _client.GetAsync(
            $&quot;/api/orders/{orderId}&quot;);
        getResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        // Cancel order
        var cancelResponse = await _client.DeleteAsync(
            $&quot;/api/orders/{orderId}&quot;);
        cancelResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
    }

    private async Task&lt;Guid&gt; SeedCustomerAsync()
    {
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider
            .GetRequiredService&lt;ApplicationDbContext&gt;();

        var customer = new Customer { Id = Guid.NewGuid(), Name = &quot;Test&quot; };
        db.Customers.Add(customer);
        await db.SaveChangesAsync();

        return customer.Id;
    }

    public async Task DisposeAsync()
    {
        await _factory.DisposeAsync();
        await _postgres.DisposeAsync();
    }
}
</code></pre>
<h2>Where Mocks Still Fit</h2>
<p>Slices reduce the need for mocking, but they don't eliminate it. External systems (payment gateways, email providers, third-party APIs) should still be replaced with <strong>test doubles</strong>, even in integration tests:</p>
<pre><code class="language-csharp">var factory = new WebApplicationFactory&lt;Program&gt;()
    .WithWebHostBuilder(builder =&gt;
    {
        builder.ConfigureTestServices(services =&gt;
        {
            services.RemoveAll&lt;IPaymentGateway&gt;();
            services.AddScoped&lt;IPaymentGateway, FakePaymentGateway&gt;();
        });
    });
</code></pre>
<p>The rule I follow: fake what you don't own (external services), keep what you do own (your database, your handlers, your validation) real. That way a passing slice test means the feature genuinely works, minus only the third-party call you can't control anyway.</p>
<h2>Guarding Slice Independence</h2>
<p>One more test category worth having: a few architecture tests that keep slices from quietly coupling to each other. A <code>PlaceOrder</code> handler reaching into <code>Features.Shipping</code> internals is exactly the kind of erosion that's invisible in code review. I cover the setup in <strong>architecture testing in .NET</strong>; two or three rules per feature group are enough.</p>
<h2>Test Organization</h2>
<p>Mirror the feature structure:</p>
<pre><code>Features/
  Orders/
    PlaceOrder.cs
    GetOrder.cs
    CancelOrder.cs
tests/
  Features/
    Orders/
      PlaceOrderTests.cs
      PlaceOrderValidatorTests.cs
      GetOrderTests.cs
      CancelOrderTests.cs
</code></pre>
<p>Each test file tests one slice. Finding the tests for a feature is trivial.</p>
<h2>What to Test at Each Level</h2>
<p>Three levels, each with a distinct job:</p>
<img src="https://milanjovanovic.tech/blogs/articles/testing-vertical-slices-dotnet/slice-test-levels.png" alt="An integration test covers the whole slice from HTTP through routing, validation, handler, and database, while validator tests and handler unit tests target individual stages">
<ul>
<li><strong>Validator tests</strong>: input validation rules. Pure logic, run in microseconds.</li>
<li><strong>Handler unit tests</strong>: business logic in isolation. Fast, no HTTP.</li>
<li><strong>Integration tests</strong>: the full HTTP pipeline against a real database. Slower, but they prove the slice actually works.</li>
</ul>
<p>Because a slice is a complete feature, integration tests here carry more weight than in layered architectures. Don't be afraid to have plenty of them; that's <a href="https://milanjovanovic.tech/blog/the-test-pyramid-is-a-lie-and-what-i-do-instead"><strong>what I do instead of the classic test pyramid</strong></a>. A slice test that goes HTTP-to-database catches serialization bugs, validation wiring, and query errors in one shot.</p>
<h2>Summary</h2>
<p>Testing vertical slices in .NET:</p>
<ol>
<li><strong>Organize tests by feature</strong>, not by layer</li>
<li><strong>Unit test handlers</strong> with in-memory DbContext for fast feedback</li>
<li><strong>Unit test validators</strong> separately - they're pure logic</li>
<li><strong>Integration test with WebApplicationFactory</strong> for full HTTP pipeline</li>
<li><strong>Use Testcontainers</strong> for realistic database tests</li>
<li><strong>Lean on integration tests</strong> - a slice test proves the whole feature works</li>
</ol>
<p>Each slice is independently testable. That's the power of vertical slices.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Feature Folders in .NET: Organizing Code by Feature]]></title>
            <link>https://milanjovanovic.tech/blog/feature-folders-dotnet</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/feature-folders-dotnet</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Stop organizing code by technical concern (Controllers, Services, Models). Organize by feature instead - so everything related to placing an order lives in one…]]></description>
            <content:encoded><![CDATA[<p>Feature folders organize .NET code by business capability instead of technical layer.
Everything related to one feature (endpoint, command, handler, validator, DTOs) lives in a single folder, so the solution reads like a list of capabilities.
Here is how to implement them in .NET, and when they pay off.</p>
<p>Open almost any .NET solution and you can guess the top-level folders before it loads: Controllers, Services, Models.
That structure tells you which framework the team used, but nothing about what the application does.</p>
<h2>The Problem With Layer-Based Organization</h2>
<p>Most .NET projects start like this:</p>
<pre><code>Controllers/
    OrdersController.cs
    CustomersController.cs
    ProductsController.cs
Services/
    OrderService.cs
    CustomerService.cs
    ProductService.cs
Models/
    Order.cs
    Customer.cs
    Product.cs
DTOs/
    OrderRequest.cs
    OrderResponse.cs
    CustomerRequest.cs
Validators/
    OrderValidator.cs
    CustomerValidator.cs
</code></pre>
<p>To work on one feature (placing an order), you touch files in 5+ folders. To understand a feature, you jump between directories piecing together how <code>OrdersController</code> calls <code>OrderService</code> which uses <code>Order</code> and <code>OrderRequest</code>.</p>
<p>This is <strong>organizing by layer</strong> - it groups files by what they are (controller, service, model), not by what they do.</p>
<img src="https://milanjovanovic.tech/blogs/articles/feature-folders-dotnet/layer-vs-feature.png" alt="By layer, one feature is scattered across Controllers, Services, and Models folders; by feature, everything for Place Order lives in a single folder">
<h2>Feature Folders: Organize by What Code Does</h2>
<p>Feature folders flip the structure. Everything related to a feature lives together:</p>
<pre><code>Features/
    Orders/
        PlaceOrder/
            PlaceOrderEndpoint.cs
            PlaceOrderCommand.cs
            PlaceOrderCommandHandler.cs
            PlaceOrderRequest.cs
            PlaceOrderResponse.cs
            PlaceOrderValidator.cs
        CancelOrder/
            CancelOrderEndpoint.cs
            CancelOrderCommand.cs
            CancelOrderCommandHandler.cs
        GetOrderById/
            GetOrderByIdEndpoint.cs
            GetOrderByIdQuery.cs
            GetOrderByIdQueryHandler.cs
            OrderResponse.cs
    Customers/
        RegisterCustomer/
            RegisterCustomerEndpoint.cs
            RegisterCustomerCommand.cs
            RegisterCustomerCommandHandler.cs
        GetCustomer/
            GetCustomerEndpoint.cs
            GetCustomerQuery.cs
</code></pre>
<p>To work on &quot;Place Order,&quot; you open one folder. Everything is there. No jumping between directories.</p>
<p>This is how <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture"><strong>Vertical Slice Architecture</strong></a> and <a href="https://milanjovanovic.tech/blog/screaming-architecture"><strong>Screaming Architecture</strong></a> naturally organize code.</p>
<h2>Implementing Feature Folders</h2>
<h3>Step 1: Create the Feature Structure</h3>
<p>Each feature gets its own folder with everything it needs:</p>
<pre><code class="language-csharp">// Features/Orders/PlaceOrder/PlaceOrderCommand.cs
public sealed record PlaceOrderCommand(
    Guid CustomerId,
    List&lt;OrderItemRequest&gt; Items) : ICommand&lt;Guid&gt;;

// Features/Orders/PlaceOrder/PlaceOrderCommandHandler.cs
public sealed class PlaceOrderCommandHandler(
    IOrderRepository orderRepository,
    IUnitOfWork unitOfWork)
    : ICommandHandler&lt;PlaceOrderCommand, Guid&gt;
{
    public async Task&lt;Result&lt;Guid&gt;&gt; Handle(
        PlaceOrderCommand command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Items);
        orderRepository.Add(order);
        await unitOfWork.SaveChangesAsync(ct);
        return order.Id;
    }
}

// Features/Orders/PlaceOrder/PlaceOrderEndpoint.cs
public static class PlaceOrderEndpoint
{
    public static void Map(IEndpointRouteBuilder app)
    {
        app.MapPost(&quot;/api/orders&quot;, async (
            PlaceOrderRequest request,
            ICommandHandler&lt;PlaceOrderCommand, Guid&gt; handler,
            CancellationToken ct) =&gt;
        {
            var command = new PlaceOrderCommand(request.CustomerId, request.Items);
            var result = await handler.Handle(command, ct);

            return result.IsSuccess
                ? Results.Created($&quot;/api/orders/{result.Value}&quot;, result.Value)
                : result.ToProblemDetails();
        });
    }
}

// Features/Orders/PlaceOrder/PlaceOrderValidator.cs
public sealed class PlaceOrderValidator : AbstractValidator&lt;PlaceOrderCommand&gt;
{
    public PlaceOrderValidator()
    {
        RuleFor(x =&gt; x.CustomerId).NotEmpty();
        RuleFor(x =&gt; x.Items).NotEmpty();
    }
}
</code></pre>
<p>The <code>ICommand</code> and <code>ICommandHandler</code> abstractions are the thin CQRS interfaces I defined in <a href="https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start"><strong>CQRS Pattern: The Way It Should Have Been From the Start</strong></a>.</p>
<h3>Step 2: Auto-Register Endpoints</h3>
<p>Scan for all endpoint classes and register them:</p>
<pre><code class="language-csharp">public static class EndpointRegistration
{
    public static void MapFeatureEndpoints(this IEndpointRouteBuilder app)
    {
        var endpointTypes = typeof(Program).Assembly
            .GetTypes()
            .Where(t =&gt; t.GetMethods(BindingFlags.Public | BindingFlags.Static)
                .Any(m =&gt; m.Name == &quot;Map&quot; &amp;&amp;
                    m.GetParameters().Length == 1 &amp;&amp;
                    m.GetParameters()[0].ParameterType == typeof(IEndpointRouteBuilder)));

        foreach (var type in endpointTypes)
        {
            var method = type.GetMethod(&quot;Map&quot;,
                BindingFlags.Public | BindingFlags.Static,
                [typeof(IEndpointRouteBuilder)]);

            method?.Invoke(null, [app]);
        }
    }
}
</code></pre>
<pre><code class="language-csharp">// Program.cs
app.MapFeatureEndpoints();
</code></pre>
<h3>Step 3: Register Handlers</h3>
<p>Use assembly scanning with the Scrutor library, so a new slice never means editing <code>Program.cs</code>:</p>
<pre><code class="language-bash">dotnet add package Scrutor
</code></pre>
<pre><code class="language-csharp">builder.Services.Scan(scan =&gt; scan
    .FromAssemblyOf&lt;PlaceOrderCommandHandler&gt;()
    .AddClasses(c =&gt; c.AssignableTo(typeof(ICommandHandler&lt;,&gt;)))
    .AsImplementedInterfaces()
    .WithScopedLifetime()
    .AddClasses(c =&gt; c.AssignableTo(typeof(IQueryHandler&lt;,&gt;)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());
</code></pre>
<h2>One File or One Folder per Feature?</h2>
<p>There are two popular granularities, and both are fine:</p>
<p><strong>Folder per operation</strong> (shown above): <code>PlaceOrder/</code> contains five or six small files. Best when slices carry validators, mappers, and multiple DTOs.</p>
<p><strong>Single file per operation</strong>: the whole slice lives in <code>PlaceOrder.cs</code> as a static class with nested types:</p>
<pre><code class="language-csharp">// Features/Orders/PlaceOrder.cs
public static class PlaceOrder
{
    public sealed record Command(
        Guid CustomerId, List&lt;OrderItemRequest&gt; Items) : ICommand&lt;Guid&gt;;

    public sealed class Validator : AbstractValidator&lt;Command&gt; { /* ... */ }

    internal sealed class Handler : ICommandHandler&lt;Command, Guid&gt; { /* ... */ }

    public static void Map(IEndpointRouteBuilder app) { /* ... */ }
}
</code></pre>
<p>The single-file style keeps the entire feature on one screen and makes names collision-free (<code>PlaceOrder.Command</code>, <code>CancelOrder.Command</code>).
The static <code>Map</code> method keeps the same shape as before, so the endpoint scanner from Step 2 picks it up unchanged. It's the style I lean toward for small-to-medium slices, and I've written more about <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-structuring-vertical-slices"><strong>structuring vertical slices</strong></a> if you want the full reasoning.</p>
<p>Start with one file. Split into a folder when the file gets uncomfortable to scroll.</p>
<h2>Feature Folders in Clean Architecture</h2>
<p>You can combine feature folders with <a href="https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design"><strong>Clean Architecture</strong></a>:</p>
<pre><code>src/
  MyApp.Domain/
    Orders/
      Order.cs
      OrderLineItem.cs
      IOrderRepository.cs
    Customers/
      Customer.cs
  MyApp.Application/
    Orders/
      PlaceOrder/
        PlaceOrderCommand.cs
        PlaceOrderCommandHandler.cs
        PlaceOrderValidator.cs
      CancelOrder/
        CancelOrderCommand.cs
        CancelOrderCommandHandler.cs
      GetOrderById/
        GetOrderByIdQuery.cs
        GetOrderByIdQueryHandler.cs
        OrderResponse.cs
  MyApp.Infrastructure/
    Persistence/
      Repositories/
        OrderRepository.cs
  MyApp.Api/
    Endpoints/
      Orders/
        PlaceOrderEndpoint.cs
        CancelOrderEndpoint.cs
        GetOrderByIdEndpoint.cs
</code></pre>
<p>The Application layer uses feature folders. The Domain layer groups entities by aggregate. The Presentation layer mirrors the Application structure.</p>
<h2>When to Use Feature Folders</h2>
<p><strong>Feature folders work well when:</strong></p>
<ul>
<li>Features are relatively independent</li>
<li>The team works on features, not layers</li>
<li>You want code locality (related code close together)</li>
<li>You're using <a href="https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start"><strong>CQRS</strong></a> (commands and queries naturally form features)</li>
</ul>
<p><strong>Stick with layers when:</strong></p>
<ul>
<li>There's extensive code sharing between features</li>
<li>The project is very small (5-10 files total)</li>
<li>Your team is more comfortable with the traditional structure</li>
</ul>
<h2>Shared Code</h2>
<p>Some code is truly shared - domain entities, base classes, common helpers. Put these in a <code>Common</code> or <code>Shared</code> folder:</p>
<pre><code>Features/
    Orders/
        PlaceOrder/...
        CancelOrder/...
    Customers/...
Common/
    Domain/
        Entity.cs
        ValueObject.cs
        AggregateRoot.cs
    Results/
        Result.cs
        Error.cs
</code></pre>
<p>Keep the shared folder minimal. If code is only used by one feature, it belongs in that feature's folder. When two features start needing the same logic, resist the reflex to abstract immediately; I've written about <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-where-does-the-shared-logic-live"><strong>where shared logic should live</strong></a>, and the short answer is: extract when the duplication hurts, not when it merely exists.</p>
<h2>Migrating an Existing Codebase</h2>
<p>You don't need a rewrite to adopt feature folders. The incremental path:</p>
<ol>
<li><strong>Create the <code>Features</code> folder</strong> next to your existing <code>Controllers</code>/<code>Services</code> folders.</li>
<li><strong>Move one feature end to end.</strong> Pick a small, actively developed one. Pull its controller action, service method, DTOs, and validator into a feature folder, collapsing the service and repository indirection where it adds nothing.</li>
<li><strong>Ship it.</strong> The old layers and the new folder coexist fine; routing doesn't care where files live.</li>
<li><strong>Repeat opportunistically.</strong> Migrate features when you touch them for other reasons. Untouched code stays where it is.</li>
</ol>
<p>The biggest friction is usually psychological, not technical: the codebase looks &quot;inconsistent&quot; during the transition. That's fine. A consistent structure that hides features is worse than a mixed one that's converging on clarity.</p>
<h2>Summary</h2>
<p>Feature folders organize code by business capability instead of technical concern. Every file related to placing an order lives in the <code>PlaceOrder</code> folder.</p>
<p>The result: faster navigation, fewer merge conflicts, and code that screams what the application does.</p>
<p>Stop grouping by layer. Start grouping by feature.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
        <item>
            <title><![CDATA[Cross-Cutting Concerns in Vertical Slice Architecture]]></title>
            <link>https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture</link>
            <guid isPermaLink="false">https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture</guid>
            <pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[One criticism of Vertical Slice Architecture is code duplication across slices. Here is how to handle cross-cutting concerns like validation, logging, and…]]></description>
            <content:encoded><![CDATA[<p>Handle cross-cutting concerns in Vertical Slice Architecture with pipeline behaviors or endpoint filters that wrap every handler automatically.
Validation, logging, transactions, and caching live in one place, and each slice declares only what is unique to it, like its validator or cache key.
Copy-pasting those concerns into every handler is how the pattern gets a bad name, and here is how I avoid it.</p>
<h2>The Duplication Problem</h2>
<p>In <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet"><strong>Vertical Slice Architecture</strong></a>, each feature is self-contained. Cross-cutting concerns are the behaviors that cut across every slice: validation, logging, caching, authorization, transaction management.</p>
<p>You don't want to copy-paste these into every handler. That would defeat the purpose. And this question is really a special case of a broader one I keep coming back to: <a href="https://milanjovanovic.tech/blog/vertical-slice-architecture-where-does-the-shared-logic-live"><strong>where does the shared logic live</strong></a> in a sliced codebase?</p>
<h2>MediatR Pipeline Behaviors</h2>
<p>The most common solution is <a href="https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors"><strong>MediatR pipeline behaviors</strong></a>. They wrap every request handler automatically.</p>
<h3>Validation Behavior</h3>
<p>Validate every command before the handler executes:</p>
<pre><code class="language-csharp">public class ValidationBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : IRequest&lt;TResponse&gt;
{
    private readonly IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; _validators;

    public ValidationBehavior(IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; validators)
    {
        _validators = validators;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        if (!_validators.Any())
        {
            return await next();
        }

        var context = new ValidationContext&lt;TRequest&gt;(request);

        var failures = _validators
            .Select(v =&gt; v.Validate(context))
            .SelectMany(r =&gt; r.Errors)
            .Where(f =&gt; f is not null)
            .ToList();

        if (failures.Count != 0)
        {
            throw new ValidationException(failures);
        }

        return await next();
    }
}
</code></pre>
<p>Each slice just defines a validator:</p>
<pre><code class="language-csharp">// Features/Orders/PlaceOrder.cs
public sealed class Validator : AbstractValidator&lt;Command&gt;
{
    public Validator()
    {
        RuleFor(x =&gt; x.CustomerId).NotEmpty();
        RuleFor(x =&gt; x.Items).NotEmpty();
    }
}
</code></pre>
<p>The behavior picks it up automatically - zero wiring per slice.</p>
<h3>Logging Behavior</h3>
<p>Log every request entry, exit, and duration:</p>
<pre><code class="language-csharp">public class LoggingBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : IRequest&lt;TResponse&gt;
{
    private readonly ILogger&lt;LoggingBehavior&lt;TRequest, TResponse&gt;&gt; _logger;

    public LoggingBehavior(
        ILogger&lt;LoggingBehavior&lt;TRequest, TResponse&gt;&gt; logger)
    {
        _logger = logger;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation(&quot;Handling {RequestName}&quot;, requestName);

        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        _logger.LogInformation(
            &quot;Handled {RequestName} in {ElapsedMs}ms&quot;,
            requestName, sw.ElapsedMilliseconds);

        return response;
    }
}
</code></pre>
<h3>Transaction Behavior</h3>
<p>Wrap commands in a database transaction.
The <code>ICommand</code> constraint uses the marker interface from <a href="https://milanjovanovic.tech/blog/combining-vertical-slices-cqrs"><strong>combining vertical slices with CQRS</strong></a>:</p>
<pre><code class="language-csharp">public class TransactionBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : ICommand&lt;TResponse&gt;
{
    private readonly ApplicationDbContext _db;

    public TransactionBehavior(ApplicationDbContext db)
    {
        _db = db;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        await using var transaction =
            await _db.Database.BeginTransactionAsync(ct);

        try
        {
            var response = await next();
            await transaction.CommitAsync(ct);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
}
</code></pre>
<p>Notice the constraint <code>ICommand&lt;TResponse&gt;</code> - this behavior only wraps commands, not queries. Queries don't need transactions.</p>
<h3>Caching Behavior</h3>
<p>Cache query results:</p>
<pre><code class="language-csharp">public interface ICacheable
{
    string CacheKey { get; }
    TimeSpan? Expiration { get; }
}

public class CachingBehavior&lt;TRequest, TResponse&gt;
    : IPipelineBehavior&lt;TRequest, TResponse&gt;
    where TRequest : IRequest&lt;TResponse&gt;, ICacheable
{
    private readonly IDistributedCache _cache;

    public CachingBehavior(IDistributedCache cache)
    {
        _cache = cache;
    }

    public async Task&lt;TResponse&gt; Handle(
        TRequest request,
        RequestHandlerDelegate&lt;TResponse&gt; next,
        CancellationToken ct)
    {
        var cachedResult = await _cache.GetStringAsync(request.CacheKey, ct);
        if (cachedResult is not null)
        {
            return JsonSerializer.Deserialize&lt;TResponse&gt;(cachedResult)!;
        }

        var response = await next();

        await _cache.SetStringAsync(
            request.CacheKey,
            JsonSerializer.Serialize(response),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    request.Expiration ?? TimeSpan.FromMinutes(5)
            },
            ct);

        return response;
    }
}
</code></pre>
<p>Opt in per query:</p>
<pre><code class="language-csharp">public sealed record Query(Guid Id)
    : IRequest&lt;ProductResponse&gt;, ICacheable
{
    public string CacheKey =&gt; $&quot;product-{Id}&quot;;
    public TimeSpan? Expiration =&gt; TimeSpan.FromMinutes(10);
}
</code></pre>
<p>Only queries that implement <code>ICacheable</code> get cached. The behavior ignores everything else.</p>
<h2>Registration</h2>
<p>Register all behaviors in order:</p>
<pre><code class="language-csharp">services.AddMediatR(config =&gt;
{
    config.RegisterServicesFromAssembly(typeof(Program).Assembly);
    config.AddOpenBehavior(typeof(LoggingBehavior&lt;,&gt;));
    config.AddOpenBehavior(typeof(ValidationBehavior&lt;,&gt;));
    config.AddOpenBehavior(typeof(TransactionBehavior&lt;,&gt;));
    config.AddOpenBehavior(typeof(CachingBehavior&lt;,&gt;));
});
</code></pre>
<p>Order matters. Logging wraps validation, which wraps transaction, which wraps caching.</p>
<img src="https://milanjovanovic.tech/blogs/articles/cross-cutting-concerns-in-vertical-slice-architecture/behavior-pipeline.png" alt="A request passing through nested Logging, Validation, Transaction, and Caching behaviors before reaching the handler and the database">
<h2>Pitfalls to Watch For</h2>
<p>A few ways this setup bites in practice:</p>
<ul>
<li><strong>Behavior order bugs are silent.</strong> If you register <code>CachingBehavior</code> before <code>TransactionBehavior</code>, a cached response can be returned without the transaction ever opening - which is correct for queries but masks a misconfigured command that accidentally implements <code>ICacheable</code>. Review the registration order whenever you add a behavior.</li>
<li><strong>Caching failures, not just successes.</strong> The caching behavior above serializes whatever the handler returns, including a failed <code>Result</code>. Add a check so only successful responses get cached, or you'll serve a cached error for ten minutes.</li>
<li><strong>Transactions around everything.</strong> Wrapping every command in an explicit transaction is redundant when the handler makes a single <code>SaveChangesAsync</code> call (EF Core already wraps that in a transaction). Reserve the transaction behavior for handlers that perform multiple save operations.</li>
<li><strong>Behavior sprawl.</strong> Every behavior runs on every matching request. Ten behaviors deep, debugging a request means stepping through ten wrappers. Keep the pipeline short and boring.</li>
</ul>
<h2>Can You Handle Cross-Cutting Concerns Without MediatR?</h2>
<p>If you've moved off MediatR (or never adopted it), the same pattern works as decorators over your own handler interfaces. Define <code>ICommandHandler&lt;TCommand, TResponse&gt;</code>, then register decorators (Scrutor's <code>Decorate</code> makes this one line per behavior). The mechanics change; the idea (cross-cutting logic wraps the handler, slices stay clean) doesn't.</p>
<h2>Endpoint Filters (Alternative)</h2>
<p>If you prefer not to use MediatR, ASP.NET Core <strong>endpoint filters</strong> handle cross-cutting concerns at the HTTP layer:</p>
<pre><code class="language-csharp">public class ValidationFilter&lt;TRequest&gt; : IEndpointFilter
{
    public async ValueTask&lt;object?&gt; InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments
            .OfType&lt;TRequest&gt;()
            .FirstOrDefault();

        if (request is null)
        {
            return await next(context);
        }

        var validator = context.HttpContext.RequestServices
            .GetService&lt;IValidator&lt;TRequest&gt;&gt;();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(request);
            if (!result.IsValid)
            {
                return Results.ValidationProblem(
                    result.ToDictionary());
            }
        }

        return await next(context);
    }
}
</code></pre>
<h2>Base Handler Classes (Use Sparingly)</h2>
<p>Another option - a base class for shared handler logic:</p>
<pre><code class="language-csharp">public abstract class BaseHandler
{
    protected readonly ApplicationDbContext Db;
    protected readonly ICurrentUserService CurrentUser;

    protected BaseHandler(
        ApplicationDbContext db,
        ICurrentUserService currentUser)
    {
        Db = db;
        CurrentUser = currentUser;
    }
}
</code></pre>
<p>I generally avoid this. It creates coupling and makes the inheritance hierarchy grow. Pipeline behaviors are more flexible.</p>
<h2>The Pattern Summary</h2>
<p>Here's the mapping I use, concern by concern:</p>
<ul>
<li><strong>Validation</strong>: pipeline behavior + FluentValidation. Applies to every request that has a validator; slices without one pass through untouched.</li>
<li><strong>Logging</strong>: pipeline behavior. Applies to all requests.</li>
<li><strong>Transactions</strong>: pipeline behavior constrained to <code>ICommand</code>. Write operations only.</li>
<li><strong>Caching</strong>: pipeline behavior + <code>ICacheable</code> marker. Opt-in per query.</li>
<li><strong>Authorization</strong>: pipeline behavior or endpoint filter, declared per request.</li>
<li><strong>Error handling</strong>: a <a href="https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers"><strong>global exception handler</strong></a> at the HTTP boundary, so handlers never need try-catch blocks for presentation concerns.</li>
</ul>
<h2>Summary</h2>
<p>Cross-cutting concerns in Vertical Slice Architecture are solved with:</p>
<ol>
<li><strong>Pipeline behaviors</strong> - wrap every handler automatically</li>
<li><strong>Marker interfaces</strong> - opt in to specific behaviors (<code>ICacheable</code>, <code>ICommand</code>)</li>
<li><strong>Endpoint filters</strong> - HTTP-level concerns</li>
<li><strong>Convention over configuration</strong> - validators are discovered, not registered manually</li>
</ol>
<p>Each slice stays focused on its feature. The pipeline handles everything else.</p>
<p>Thanks for reading, and stay awesome!</p>
<hr>
]]></content:encoded>
            <author>milan@milanjovanovic.tech (Milan Jovanović)</author>
        </item>
    </channel>
</rss>