In-Depth Guide · 10 Sections · EF Core
Entity Framework Core In Depth
From DbContext and DbSet to migrations, fluent configurations, Include/AsNoTracking, interceptors, global filters, ExecuteUpdate/ExecuteDelete, compiled queries and production diagnostics. Plain English, copy-paste .NET 9 code.
DbContext & Migrations
Queries & Tracking
Interceptors & Filters
Performance & Production
Prerequisite: C# basics — C# for Dummies or Complete C# for Dummies.
SQL familiarity helps — see T-SQL Tutorials.
For wiring EF Core into HTTP endpoints see Minimal API or REST API.
For DDD aggregate mapping see EF Core Mapping for DDD — for Dummies.
For testing repositories and integration tests see Testing .NET.
Entity Framework Core (EF Core) is the modern ORM for .NET. It maps C# classes to database tables, translates LINQ to SQL, tracks changes, and generates migrations. You write business logic in C# — EF Core handles the persistence layer.
1.1
ORM vs raw SQL — when to use EF Core
// EF Core shines when:
// • CRUD + relationships (1:N, N:N) with change tracking
// • Rapid development, migrations, provider portability
// • LINQ composability in application code
// Raw SQL / Dapper shines when:
// • Complex reporting queries, bulk ETL, hand-tuned plans
// • Stored procedures already own the logic
// Hybrid is common — EF Core for 90%, FromSqlRaw for hot paths
var topCustomers = await db.Customers
.FromSqlRaw("EXEC dbo.GetTopCustomers @Year", yearParam)
.AsNoTracking()
.ToListAsync(ct);
1.2
Package setup — .NET 9
// dotnet add package Microsoft.EntityFrameworkCore
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
// dotnet add package Microsoft.EntityFrameworkCore.Design // CLI migrations
// dotnet add package Microsoft.EntityFrameworkCore.Tools // Package Manager
// Program.cs — register DbContext (scoped by default)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment())
.EnableDetailedErrors(builder.Environment.IsDevelopment()));
DbContext is the session with your database — it holds connection info, tracks entity changes, and executes queries. Each DbSet<T> represents a table (or view) you can query and mutate.
2.1
DbContext — entities and DbSets
public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Order> Orders { get; set; } = [];
}
2.2
Lifecycle — scoped, tracked, SaveChanges
// DbContext is SCOPED — one per HTTP request in ASP.NET Core
public class CustomerService(AppDbContext db)
{
public async Task<Customer> CreateAsync(string name, CancellationToken ct)
{
var customer = new Customer { Name = name };
db.Customers.Add(customer); // State = Added
await db.SaveChangesAsync(ct); // INSERT — Id populated
return customer;
}
public async Task UpdateAsync(int id, string name, CancellationToken ct)
{
var customer = await db.Customers.FindAsync([id], ct)
?? throw new KeyNotFoundException();
customer.Name = name; // State = Modified
await db.SaveChangesAsync(ct); // UPDATE changed columns only
}
}
// ❌ Don't inject DbContext into singletons — use IServiceScopeFactory
Watch Out — DbContext is not thread-safe
Never share one DbContext across parallel tasks. Each concurrent operation needs its own context (usually via a new DI scope). See Threading & Multitasking for async patterns.
Migrations version your database schema alongside your C# model. EF Core generates SQL scripts from model diffs — you review, commit, and apply them per environment.
// First migration
dotnet ef migrations add InitialCreate --project src/Infrastructure --startup-project src/Api
// Apply to local database
dotnet ef database update --project src/Infrastructure --startup-project src/Api
// Generate idempotent SQL script for CI/CD
dotnet ef migrations script --idempotent -o deploy/migrate.sql
// Remove last migration (if not applied to prod)
dotnet ef migrations remove
// List pending migrations
dotnet ef migrations list
3.2
Startup migration & design-time factory
// Apply migrations on startup (dev/small apps — use scripts in prod)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
// Design-time factory — EF CLI needs DbContext without running the app
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer("Server=.;Database=AppDev;Trusted_Connection=True;TrustServerCertificate=True")
.Options;
return new AppDbContext(options);
}
}
Data annotations ([Required], [MaxLength]) work for simple cases. The Fluent API in IEntityTypeConfiguration<T> classes keeps mapping logic out of domain entities — cleaner and more powerful.
4.1
IEntityTypeConfiguration pattern
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber)
.HasMaxLength(20)
.IsRequired();
builder.Property(o => o.Total)
.HasPrecision(18, 2);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(o => o.OrderNumber).IsUnique();
}
}
4.2
Owned types, enums & value conversions
// Owned type — Address columns live on Customer table
builder.OwnsOne(c => c.Address, a =>
{
a.Property(p => p.Street).HasMaxLength(200);
a.Property(p => p.City).HasMaxLength(100);
});
// Enum stored as string
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(32);
// Value object — Money as Amount + Currency columns
builder.Property<Money>("_total")
.HasConversion(
m => $"{m.Amount}|{m.Currency}",
s => ParseMoney(s));
LINQ queries translate to SQL at runtime. Understanding tracking, eager loading, and projection is the difference between fast APIs and N+1 query disasters.
5.1
Include, ThenInclude & split queries
// Eager load — one round-trip (or split to avoid cartesian explosion)
var orders = await db.Orders
.AsSplitQuery()
.Include(o => o.Customer)
.Include(o => o.Lines)
.ThenInclude(l => l.Product)
.Where(o => o.OrderDate >= since)
.ToListAsync(ct);
// Projection — best performance: SELECT only needed columns
var dtos = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderSummaryDto(
o.Id,
o.OrderNumber,
o.Total,
o.Lines.Count))
.ToListAsync(ct);
5.2
AsNoTracking & tracking behavior
// Read-only queries — skip change tracker overhead
var customers = await db.Customers
.AsNoTracking()
.Where(c => c.IsActive)
.ToListAsync(ct);
// Global default for read-heavy apps
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
// Explicit tracking when you need updates
var order = await db.Orders
.AsTracking()
.FirstAsync(o => o.Id == id, ct);
order.Status = OrderStatus.Shipped;
await db.SaveChangesAsync(ct);
// ❌ N+1 — loop calling db inside foreach
// ✅ Fix — Include, projection, or batch load IDs first
Interceptors hook into EF Core's pipeline — connection open, command execution, SaveChanges. Use them for cross-cutting concerns like audit columns, soft-delete enforcement, or slow-query logging.
6.1
SaveChangesInterceptor — audit fields
public interface IAuditable
{
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? ModifiedAt { get; set; }
string? CreatedBy { get; set; }
}
public sealed class AuditInterceptor(ICurrentUser user) : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in eventData.Context!.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = user.Id;
}
if (entry.State == EntityState.Modified)
entry.Entity.ModifiedAt = now;
}
return base.SavingChanges(eventData, result);
}
}
// Register: options.AddInterceptors(new AuditInterceptor(...));
6.2
DbCommandInterceptor — slow query logging
public sealed class SlowQueryInterceptor(ILogger<SlowQueryInterceptor> log)
: DbCommandInterceptor
{
private const int ThresholdMs = 200;
public override DbDataReader ReaderExecuted(
DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
if (eventData.Duration.TotalMilliseconds > ThresholdMs)
log.LogWarning("Slow query ({Ms}ms): {Sql}",
eventData.Duration.TotalMilliseconds, command.CommandText);
return base.ReaderExecuted(command, eventData, result);
}
}
Global query filters automatically append a WHERE clause to every query — perfect for soft-delete (IsDeleted = false) and tenant isolation (TenantId = current).
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
}
// In configuration
builder.HasQueryFilter(e => !e.IsDeleted);
// Soft delete instead of Remove()
public async Task DeleteAsync(int id, CancellationToken ct)
{
var entity = await db.Customers.FindAsync([id], ct)
?? throw new KeyNotFoundException();
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
}
// Bypass filter when needed (admin restore)
var all = await db.Customers.IgnoreQueryFilters().ToListAsync(ct);
public interface ITenantEntity
{
Guid TenantId { get; set; }
}
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenant) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == tenant.CurrentTenantId);
}
}
// ⚠️ Always set TenantId on INSERT — filter won't help on SaveChanges
// ⚠️ IgnoreQueryFilters() in background jobs that cross tenants
EF Core 7+ can run bulk UPDATE and DELETE in SQL without loading entities into memory — massive wins for batch operations and avoiding change-tracker overhead.
8.1
ExecuteUpdateAsync — bulk update
// Mark all expired sessions inactive — one UPDATE statement
var affected = await db.Sessions
.Where(s => s.ExpiresAt < DateTimeOffset.UtcNow && s.IsActive)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.IsActive, false)
.SetProperty(s => s.ModifiedAt, DateTimeOffset.UtcNow),
ct);
// ❌ No interceptors/SaveChanges on ExecuteUpdate
// ❌ No entity-level validation — business rules must be in SQL or pre-check
8.2
ExecuteDeleteAsync — bulk delete
// Purge audit logs older than retention — no memory spike
await db.AuditLogs
.Where(l => l.CreatedAt < cutoff)
.ExecuteDeleteAsync(ct);
// Compare to load-then-remove (bad at scale)
// var old = await db.AuditLogs.Where(...).ToListAsync(); // loads millions
// db.AuditLogs.RemoveRange(old); // N DELETE or one big txn
10.1
Common production mistakes
- N+1 queries — always profile with SQL logging or MiniProfiler.
- Tracking on read APIs — default to
AsNoTracking().
- Long-lived DbContext — scope per request/operation only.
- Migration drift — apply scripts in CI/CD; never hand-edit prod without a migration.
- Connection storms — use retry policies and connection pooling limits.
// Resilience — SQL retry on transient failures
options.UseSqlServer(connString, sql =>
sql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null));
// Health check
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database");
10.2
Resilience & observability
// OpenTelemetry — trace EF commands (Package: OpenTelemetry.Instrumentation.EntityFrameworkCore)
// Application Insights — dependency tracking auto-captures SQL
// Transaction boundaries — one SaveChanges per business operation
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
await db.SaveChangesAsync(ct);
await PublishDomainEventsAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }
Mastery Move — where to go next
1. EF Core Mapping for DDD — aggregates, value objects, owned types and backing fields.
2. Testing .NET — Testcontainers SQL and repository integration tests.
3. Blazor In Depth — EF Core in interactive Server/WASM apps.
4. Minimal API — wire DbContext into HTTP endpoints with validation.
5. How-To Cookbook — copy-paste EF Core recipes for everyday tasks.