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.

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. 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.