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 testing repositories and integration tests see Testing .NET.

01

Why Entity Framework Core?

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()));
02

DbContext & DbSet

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.

03

Migrations

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.

3.1

CLI workflow

// 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);
    }
}
04

Fluent API & Configurations

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));
05

Queries, Include & AsNoTracking

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
06

Interceptors & Auditing

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);
    }
}
07

Global Filters & Multi-Tenancy

Global query filters automatically append a WHERE clause to every query — perfect for soft-delete (IsDeleted = false) and tenant isolation (TenantId = current).

7.1

Soft delete filter

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);
7.2

Multi-tenant filter

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
08

ExecuteUpdate & ExecuteDelete

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
09

Performance & Compiled Queries

Hot-path queries benefit from compiled queries (cached query plans), proper indexing, and logging translated SQL during development.

9.1

Compiled queries

// Cache the compiled delegate — avoids re-parsing LINQ every call
private static readonly Func<AppDbContext, int, Task<Customer?>> GetCustomerById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Customers.FirstOrDefault(c => c.Id == id));

public async Task<Customer?> GetByIdAsync(int id, CancellationToken ct)
    => await GetCustomerById(db, id);

// Enable SQL logging in dev
options.LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging(isDev);
9.2

Pagination, indexes & pooling

// Always paginate — never ToList() on unbounded tables
var page = await db.Products
    .AsNoTracking()
    .OrderBy(p => p.Id)
    .Skip((pageNum - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync(ct);

// DbContext pooling — reuse internal state (still scoped per request)
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseSqlServer(connString));

// Index columns in WHERE, JOIN, ORDER BY — verify with actual execution plans
10

Production — Pitfalls & Operations

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. Testing .NET — Testcontainers SQL and repository integration tests.

2. Blazor In Depth — EF Core in interactive Server/WASM apps.

3. Minimal API — wire DbContext into HTTP endpoints with validation.

4. How-To Cookbook — copy-paste EF Core recipes for everyday tasks.