Entity Framework

Entity Framework Core

Entity Framework Core (EF Core) is Microsoft's official ORM for .NET. It maps C# classes to database tables, translates LINQ queries to SQL, tracks changes to loaded entities, and manages schema migrations. EF Core supports SQL Server, PostgreSQL, SQLite, MySQL, and Cosmos DB through provider packages.

EF Core is the default data access layer for most .NET applications. Understanding its change tracking, query translation, and migration system is essential for building correct and performant data layers.

Core Concepts

DbContext and DbSet

DbContext is the unit of work and the entry point for all database operations. DbSet<T> represents a table and is the starting point for queries.

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Order>    Orders    => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(b =>
        {
            b.HasKey(o => o.Id);
            b.Property(o => o.Total).HasPrecision(18, 2);
            b.HasMany(o => o.LineItems).WithOne().HasForeignKey(li => li.OrderId);
        });
    }
}

Change Tracking

EF Core tracks all entities loaded through a DbContext. When you call SaveChangesAsync(), it generates INSERT/UPDATE/DELETE SQL for all tracked changes.

// Load → modify → save: EF Core detects the change automatically
var order = await db.Orders.FindAsync(orderId);
order.Status = OrderStatus.Confirmed;  // EF Core marks this as Modified
await db.SaveChangesAsync();           // generates: UPDATE Orders SET Status = 'Confirmed' WHERE Id = @id

For read-only queries where you don't need change tracking, use .AsNoTracking() to reduce memory and CPU overhead:

var orders = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .ToListAsync();

Migrations

EF Core migrations track schema changes as C# code, enabling version-controlled, repeatable schema evolution.

# Create a migration after changing the model
dotnet ef migrations add AddOrderStatus

# Apply pending migrations to the database
dotnet ef database update

Generated migration:

public partial class AddOrderStatus : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "Status",
            table: "Orders",
            nullable: false,
            defaultValue: "Draft");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "Status", table: "Orders");
    }
}

Performance Patterns

Projection Instead of Full Entity Load

Loading full entities when you only need a few columns wastes bandwidth and memory. Project to a DTO:

// BAD: loads all columns including large blobs
var orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync();

// GOOD: project to only needed columns
var summaries = await db.Orders
    .Where(o => o.CustomerId == id)
    .Select(o => new OrderSummary(o.Id, o.Total, o.Status))
    .ToListAsync();

Avoiding N+1 Queries

Loading a list of orders and then accessing order.Customer for each one triggers N additional queries.

// BAD: N+1 — one query for orders, one per order for customer
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
    Console.WriteLine(order.Customer.Name);  // lazy load per order

// GOOD: eager load with Include
var orders = await db.Orders
    .Include(o => o.Customer)
    .ToListAsync();

Pitfalls

Lazy Loading in Production

What goes wrong: lazy loading is enabled and navigation properties are accessed in loops, causing N+1 queries. A page that loads 100 orders and accesses order.Customer for each fires 101 queries.

Why it happens: lazy loading is convenient in development but hides query patterns.

Mitigation: disable lazy loading in production (it's off by default in EF Core). Use explicit Include() for eager loading or split queries for large result sets.

Code First vs Database First

Decision rule: use Code First for new projects. Use Database First when integrating with an existing database you don't own.

Zero-Downtime Migrations

Adding a non-nullable column without a default value locks the table during migration. For large tables, this causes downtime.

Mitigation: use the expand-contract pattern:

  1. Add the column as nullable (no lock).
  2. Backfill existing rows in batches.
  3. Add a NOT NULL constraint after backfill completes.
  4. Remove the old column in a later migration.

Questions

References


Whats next