1. The Problem Context β Why EF Core Still Trips Senior Engineers
You inherit a perfectly tidy ASP.NET Core service. The dev box flies. Production lists time out at p99 = 4.2s, the change feed publisher locks for 30 seconds when an admin clicks βarchive yearβ, and a single endpoint emits seventy-threeSQL statements per request. Profiler traces blame βthe ORMβ. EF Core gets the reputation. The framework rarely deserves it.
Include, cartesian explosion from nested collections, and treating the DbContext as if it were a process-wide singleton. EF Core 9 ships first-class fixes for every one of them.EF Core is a leaky abstraction by design β it generates SQL, and SQL is what the database executes. The job isn't to hide the database; it's to give you a typed, testable API on top of it while keeping the generated SQL inspectable. Once you internalize that, the surprises stop.
2. The Concept Explanation β Mental Model for EF Core 9
EF Core revolves around three runtime objects: the model (entity types, relationships, conventions, compiled into a graph), the change tracker (entries that snapshot loaded entities), and the query pipeline (LINQ expression β query model β SQL β reader β materialization). Every performance discussion bottoms out in one of those three layers.
flowchart LR
A[LINQ Query] --> B[Expression Tree]
B --> C[Query Translator]
C --> D[Provider SQL]
D --> E[(Database)]
E --> F[DbDataReader]
F --> G[Materializer]
G --> H{Tracking?}
H -- Yes --> I[ChangeTracker entries]
H -- No --> J[Detached entities]
I --> K[SaveChanges β INSERT/UPDATE/DELETE]
J --> L[Return to caller]Three doctrines flow from this picture:
- Reads should not track. Change tracking exists to let
SaveChangescompute deltas. If you're returning a DTO, every snapshot you create is wasted memory and CPU. - Round-trips dominate latency, not SQL complexity. One ugly join almost always beats five tidy queries.
Include,AsSplitQuery, and projection are the levers. - The DbContext is a unit-of-work, not a repository.It's designed to be short-lived, scoped to a logical operation, and disposed. Sharing one across threads is undefined behavior.
3. The Implementation β EF Core 9 in Practice
3.1 Bulk updates without loading entities
Before EF Core 7 you had to load entities into memory just to mutate a column. Now ExecuteUpdateAsync and ExecuteDeleteAsync compile straight to a single SQL statement β no change tracker involvement.
// Archive every order older than 90 days β one round trip, no materialization
await db.Orders
.Where(o => o.CreatedAt < DateTimeOffset.UtcNow.AddDays(-90))
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Archived)
.SetProperty(o => o.ArchivedAt, DateTimeOffset.UtcNow));
// Hard-delete soft-deleted rows older than a year
await db.Orders
.Where(o => o.DeletedAt < DateTimeOffset.UtcNow.AddYears(-1))
.ExecuteDeleteAsync();These methods bypass SaveChanges, interceptors that hook entity events, and client-side validation. That is usually what you want for batch jobs and exactly not what you want for domain operations that must raise events.
3.2 Read paths: project, do not include
// Bad: hydrates Order, OrderLines (often a List<OrderLine>), and Customer
var orders = await db.Orders
.Include(o => o.Lines)
.Include(o => o.Customer)
.AsNoTracking()
.ToListAsync();
// Good: project to a DTO. EF Core writes a single SELECT with only the columns we need.
var orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderListItemDto(
o.Id,
o.PlacedAt,
o.Status,
o.Customer.DisplayName,
o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
.ToListAsync();Projection eliminates change tracking automatically (no entity = nothing to track), trims columns, and is far easier to evolve than entity hydration. Treat Include as a write-side tool β when you actually need a tracked aggregate to mutate.
3.3 Avoiding cartesian explosion: AsSplitQuery
When one parent has multiple collection navigations, a single JOIN multiplies rows. A customer with 10 orders and 5 addresses returns 50 rows; add a third collection and it becomes brutal. AsSplitQuery issues one SELECT per collection.
var customer = await db.Customers
.Include(c => c.Orders)
.Include(c => c.Addresses)
.AsSplitQuery() // 3 queries, total bytes << cartesian
.SingleAsync(c => c.Id == id);
// Or set the default per-context if your read model leans this way
optionsBuilder.UseSqlServer(cs, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));3.4 EF Core 9: auto-compiled models for cold-start
Large models (hundreds of entities) spend hundreds of milliseconds at first DbContext instantiation building the model. EF Core 9 lets the build happen at compile-time and picks up the result automatically β no UseModel call needed when the compiled model lives in the same assembly as the context.
# One-shot generation
dotnet ef dbcontext optimize
# Or wire it into MSBuild so every build refreshes the compiled model
dotnet add package Microsoft.EntityFrameworkCore.Tasks<PropertyGroup>
<EFOptimizeContext>true</EFOptimizeContext>
<EFScaffoldModelStage>build</EFScaffoldModelStage>
</PropertyGroup>3.5 Parallel work: IDbContextFactory
Blazor Server, background workers, and any code that fans out work in parallel must not share a DbContext. Use IDbContextFactory<T>; each unit of work gets its own scope and disposes deterministically.
builder.Services.AddDbContextFactory<AppDb>(o => o.UseSqlServer(cs));
public sealed class ReportBuilder(IDbContextFactory<AppDb> factory)
{
public Task BuildAsync(IEnumerable<Guid> tenantIds, CancellationToken ct) =>
Parallel.ForEachAsync(tenantIds, ct, async (tenantId, token) =>
{
await using var db = await factory.CreateDbContextAsync(token);
// ...one isolated unit-of-work per tenant
});
}3.6 JSON columns and complex types
EF Core 8 introduced JSON columns; EF Core 9 lets you GroupBy and ExecuteUpdate against complex types. Use them when a value object is part ofthe aggregate but doesn't need its own table.
public sealed record Address(string Line1, string? Line2, string City, string Country, string PostCode);
public sealed class Customer
{
public Guid Id { get; set; }
public required Address Billing { get; set; } // complex type β flattened into columns
public List<ContactMethod> Contacts { get; set; } = []; // owned JSON collection
}
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(c => c.Billing);
b.OwnsMany(c => c.Contacts, n => n.ToJson());
});4. Pitfalls Engineers Hit Repeatedly
- Tracked reads. Forgetting
AsNoTrackingon list endpoints is the single most common cause of GC pressure I've diagnosed. SetQueryTrackingBehavior.NoTrackingWithIdentityResolutionat the context level for read-heavy services and opt back in only where you need to mutate. - The repository tax. Wrapping
DbContextin a genericIRepository<T>that returnsIQueryable<T>leaks the abstraction and forbids projection.DbContextalready is a unit-of-work andDbSet<T>already is a repository. See the companion article on the repository pattern. - Migrations applied on app start. Multiple replicas race; partial migrations corrupt the schema. EF Core 9 added a migration lock, but you should still apply migrations from a deploy step or the CLI, never from
Program.cs. - Synchronous I/O on Cosmos. EF Core 9 throws by default if you call a blocking API on the Cosmos provider. Convert to
ToListAsync/SaveChangesAsyncβ sync support is being removed entirely in EF 11. - Missing indexes for filters. EF will happily generate
WHERE Status = 0 AND CreatedAt > @xagainst a 50M row table that has no index on those columns. Look at the SQL with.ToQueryString()and run an execution plan during development. - Captive DbContext. Injecting
AppDbinto a Singleton service (e.g. anIHostedService) holds the same instance forever and surfaces as intermittent βA second operation was started on this contextβ errors. InjectIDbContextFactory<T>instead.
5. Practical Takeaways
- Default new contexts to
NoTrackingWithIdentityResolution; opt in to tracking per query when you intend to write. - For list endpoints:
Selecta DTO, neverIncludeentities. - For aggregates with multiple collection navigations:
AsSplitQuery(). - For batch updates and deletes:
ExecuteUpdateAsync/ExecuteDeleteAsyncβ but only when you don't need domain events. - Generate compiled models for any service with more than a few dozen entities; wire it into MSBuild so it stays current.
- Use
IDbContextFactory<T>from background work, parallel loops, and Blazor Server. - Apply migrations from the deploy pipeline. Never from application startup in production.
- Inspect generated SQL with
.ToQueryString()in tests. If the SQL surprises you, the production query plan will too.

