.agents/skills/dotnet-efcore-patterns/SKILL.md
EF Core, DbContext, AsNoTracking, query splitting.
npx skillsauth add dodyg/blue-nile-pds dotnet-efcore-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Tactical patterns for Entity Framework Core in .NET applications. Covers DbContext lifetime management, read-only query optimization, query splitting, migration workflows, interceptors, compiled queries, and connection resiliency. These patterns apply to EF Core 8+ and are compatible with SQL Server, PostgreSQL, and SQLite providers.
Cross-references: [skill:dotnet-csharp-dependency-injection] for service registration and DbContext lifetime, [skill:dotnet-csharp-async-patterns] for cancellation token propagation in queries, [skill:dotnet-efcore-architecture] for strategic data patterns, [skill:dotnet-data-access-strategy] for data access technology selection.
DbContext is a unit of work and should be short-lived. In ASP.NET Core, register it as scoped (one per request):
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
| Scenario | Lifetime | Registration |
|----------|----------|-------------|
| Web API / MVC request | Scoped (default) | AddDbContext<T>() |
| Background service | Scoped via factory | AddDbContextFactory<T>() |
| Blazor Server | Scoped via factory | AddDbContextFactory<T>() |
| Console app | Transient or manual | new AppDbContext(options) |
Background services and Blazor Server circuits outlive a single scope. Use IDbContextFactory<T> to create short-lived contexts on demand:
public sealed class OrderProcessor(
IDbContextFactory<AppDbContext> contextFactory)
{
public async Task ProcessBatchAsync(CancellationToken ct)
{
// Each iteration gets its own short-lived DbContext
await using var db = await contextFactory.CreateDbContextAsync(ct);
var pending = await db.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync(ct);
foreach (var order in pending)
{
order.Status = OrderStatus.Processing;
}
await db.SaveChangesAsync(ct);
}
}
Register the factory:
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
Important: AddDbContextFactory<T>() also registers AppDbContext itself as scoped, so controllers and request-scoped services can still inject AppDbContext directly.
AddDbContextPool<T>() and AddPooledDbContextFactory<T>() reuse DbContext instances to reduce allocation overhead. Use pooling when throughput matters and your context has no injected scoped services:
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(connectionString),
poolSize: 128); // default is 1024
Pooling constraints: Pooled contexts are reset and reused. Do not store per-request state on the DbContext subclass. Do not inject scoped services into the constructor -- use IDbContextFactory<T> with pooling (AddPooledDbContextFactory<T>()) if you need factory semantics.
By default, EF Core tracks all entities returned by queries, enabling change detection on SaveChangesAsync(). For read-only queries, disable tracking to reduce memory and CPU overhead:
// Per-query opt-out
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
// Per-query with identity resolution (deduplicates entities in the result set)
var ordersWithItems = await db.Orders
.AsNoTrackingWithIdentityResolution()
.Include(o => o.Items)
.Where(o => o.Status == OrderStatus.Active)
.ToListAsync(ct);
For read-heavy services, set no-tracking as the default:
builder.Services.AddDbContext<ReadOnlyDbContext>(options =>
options.UseNpgsql(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Then opt-in to tracking only when needed:
var order = await readOnlyDb.Orders
.AsTracking()
.FirstAsync(o => o.Id == orderId, ct);
When loading collections via Include(), EF Core generates a single SQL query with JOINs by default. This produces a Cartesian explosion when multiple collections are included.
// Single query: produces Cartesian product of OrderItems x Payments
var orders = await db.Orders
.Include(o => o.Items) // N items
.Include(o => o.Payments) // M payments
.ToListAsync(ct);
// Result set: N x M rows per order
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync(ct);
// Executes 3 separate queries: Orders, Items, Payments
| Approach | Pros | Cons | |----------|------|------| | Single query (default) | Atomic snapshot, one round-trip | Cartesian explosion with multiple Includes | | Split query | No Cartesian explosion, less data transfer | Multiple round-trips, no atomicity guarantee |
Rule of thumb: Use AsSplitQuery() when including two or more collection navigations. Use the default single query for single-collection includes or when atomicity matters.
Set split queries as the default at the provider level:
options.UseNpgsql(connectionString, npgsql =>
npgsql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
Then opt-in to single queries where atomicity is needed:
var result = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSingleQuery()
.ToListAsync(ct);
# Create a migration after model changes
dotnet ef migrations add AddOrderStatus \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Review the generated SQL before applying
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--idempotent \
--output migrations.sql
# Apply in development
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Migration bundles produce a self-contained executable for CI/CD pipelines -- no dotnet ef tooling needed on the deployment server:
# Build the bundle
dotnet ef migrations bundle \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output efbundle \
--self-contained
# Run in production -- pass connection string explicitly via --connection
./efbundle --connection "Host=prod-db;Database=myapp;Username=deploy;Password=..."
# Alternatively, configure the bundle to read from an environment variable
# by setting the connection string key in your DbContext's OnConfiguring or
# appsettings.json, then pass the env var at runtime:
# ConnectionStrings__DefaultConnection="Host=..." ./efbundle
--idempotent flag).Database.Migrate() at application startup in production -- it races with horizontal scaling and lacks rollback. Use migration bundles or idempotent scripts applied from CI/CD.Up() and Down() methods.--project and --startup-project explicitly.Use HasData() for reference data that should be part of migrations:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrderStatus>().HasData(
new OrderStatus { Id = 1, Name = "Pending" },
new OrderStatus { Id = 2, Name = "Processing" },
new OrderStatus { Id = 3, Name = "Completed" },
new OrderStatus { Id = 4, Name = "Cancelled" });
}
Important: HasData() uses primary key values for identity. Changing a seed value's PK in a later migration deletes the old row and inserts a new one -- it does not update in place.
EF Core interceptors allow cross-cutting concerns to be injected into the database pipeline without modifying entity logic. Interceptors run for every operation of their type.
public sealed class AuditTimestampInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
if (eventData.Context is null)
return ValueTask.FromResult(result);
var now = DateTimeOffset.UtcNow;
foreach (var entry in eventData.Context.ChangeTracker.Entries<IAuditable>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
}
}
return ValueTask.FromResult(result);
}
}
public interface IAuditable
{
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset UpdatedAt { get; set; }
}
public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
if (eventData.Context is null)
return ValueTask.FromResult(result);
foreach (var entry in eventData.Context.ChangeTracker.Entries<ISoftDeletable>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTimeOffset.UtcNow;
}
}
return ValueTask.FromResult(result);
}
}
Combine with a global query filter so soft-deleted entities are excluded by default:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
}
// Bypass the filter when needed (e.g., admin queries)
var allProducts = await db.Products
.IgnoreQueryFilters()
.ToListAsync(ct);
public sealed class TenantConnectionInterceptor(
ITenantProvider tenantProvider) : DbConnectionInterceptor
{
public override ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result,
CancellationToken ct = default)
{
var tenant = tenantProvider.GetCurrentTenant();
connection.ConnectionString = tenant.ConnectionString;
return ValueTask.FromResult(result);
}
}
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
options.UseNpgsql(connectionString)
.AddInterceptors(
sp.GetRequiredService<AuditTimestampInterceptor>(),
sp.GetRequiredService<SoftDeleteInterceptor>()));
// Register interceptors in DI
builder.Services.AddSingleton<AuditTimestampInterceptor>();
builder.Services.AddSingleton<SoftDeleteInterceptor>();
For queries executed very frequently with the same shape, compiled queries eliminate the overhead of expression tree translation on every call:
public static class CompiledQueries
{
// Single-result compiled query -- delegate does NOT accept CancellationToken
public static readonly Func<AppDbContext, int, Task<Order?>>
GetOrderById = EF.CompileAsyncQuery(
(AppDbContext db, int orderId) =>
db.Orders
.AsNoTracking()
.Include(o => o.Items)
.FirstOrDefault(o => o.Id == orderId));
// Multi-result compiled query returns IAsyncEnumerable
public static readonly Func<AppDbContext, string, IAsyncEnumerable<Order>>
GetOrdersByCustomer = EF.CompileAsyncQuery(
(AppDbContext db, string customerId) =>
db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt));
}
// Usage
var order = await CompiledQueries.GetOrderById(db, orderId);
// IAsyncEnumerable results support cancellation via WithCancellation:
await foreach (var o in CompiledQueries.GetOrdersByCustomer(db, customerId)
.WithCancellation(ct))
{
// Process each order
}
When to use: Compiled queries provide measurable benefit for queries that execute thousands of times per second. For typical CRUD endpoints, standard LINQ is sufficient -- do not prematurely optimize.
Cancellation limitation: Single-result compiled query delegates (Task<T?>) do not accept CancellationToken. If per-call cancellation is required, use standard async LINQ (FirstOrDefaultAsync(ct)) instead of a compiled query. Multi-result compiled queries (IAsyncEnumerable<T>) support cancellation via .WithCancellation(ct) on the async enumerable.
Transient database failures (network blips, failovers) should be handled with automatic retry. Each provider has a built-in execution strategy:
// PostgreSQL
options.UseNpgsql(connectionString, npgsql =>
npgsql.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null));
// SQL Server
options.UseSqlServer(connectionString, sqlServer =>
sqlServer.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null));
When you need to wrap multiple SaveChangesAsync calls in a single logical transaction with retries:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
var order = await db.Orders.FindAsync([orderId], ct);
order!.Status = OrderStatus.Completed;
await db.SaveChangesAsync(ct);
var payment = new Payment { OrderId = orderId, Amount = order.Total };
db.Payments.Add(payment);
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
});
Important: The entire delegate is re-executed on retry, including the transaction. Ensure the logic is idempotent or uses database-level uniqueness constraints to prevent duplicates.
IDbContextFactory<T>DbContext into singleton services -- DbContext is scoped. Injecting it into a singleton captures a stale instance. Use IDbContextFactory<T> instead.CancellationToken propagation -- pass ct to all ToListAsync(), FirstOrDefaultAsync(), SaveChangesAsync(), and other async EF Core methods. Omitting it prevents graceful request cancellation.Database.EnsureCreated() alongside migrations -- EnsureCreated() creates the schema without migration history, making subsequent migrations fail. Use it only in test scenarios without migrations.SaveChangesAsync is implicitly transactional across multiple calls -- each SaveChangesAsync() is its own transaction. Wrap multiple saves in an explicit BeginTransactionAsync() / CommitAsync() block when atomicity is required.builder.Configuration.GetConnectionString("...")) and inject via environment variables in production.Microsoft.EntityFrameworkCore.SqlServer, Npgsql.EntityFrameworkCore.PostgreSQL) and the design-time package (Microsoft.EntityFrameworkCore.Design) must be referenced explicitly.testing
Get best practices for TUnit unit testing, including data-driven tests
development
Severity scoring, scorecard computation, confidence levels, and remediation tracking for web accessibility audits. Use when computing page accessibility scores (0-100 with A-F grades), tracking remediation progress across audits, or generating cross-page comparison scorecards.
development
Web content discovery, URL crawling, and page inventory for accessibility audits. Use when scanning web pages, crawling sites for audit scope, or building page inventories for multi-page audits.
development
Audit report formatting, severity scoring, scorecard computation, and compliance export for document accessibility audits. Use when generating DOCUMENT-ACCESSIBILITY-AUDIT.md reports, computing document severity scores (0-100 with A-F grades), creating VPAT/ACR compliance exports, or formatting remediation priorities.