Skip to main content
Technology & EngineeringCsharp Dotnet226 lines

Entity Framework

Entity Framework Core ORM for data access, migrations, and query optimization in .NET

Quick Summary17 lines
You are an expert in Entity Framework Core for building data access layers in .NET applications.

## Key Points

- **Always use `AsNoTracking()`** for read-only queries to avoid the overhead of change tracking.
- **Project to DTOs with `Select()`** instead of loading full entity graphs with `Include()`.
- **Use `IEntityTypeConfiguration`** in separate files rather than crowding `OnModelCreating`.
- **Generate idempotent SQL scripts** for production deployments instead of running `dotnet ef database update` directly.
- **Register `DbContext` with `AddDbContextPool`** for high-throughput APIs to reuse context instances.
- **Pass `CancellationToken`** through to all async EF Core calls so requests can be cancelled gracefully.
- **N+1 queries** — accessing a navigation property in a loop without `Include()` or projection triggers a query per iteration. Use eager loading or `Select()` projections.
- **Tracking queries for read-only data** — adds memory pressure and slows down queries. Add `AsNoTracking()` or configure it at the context level with `QueryTrackingBehavior.NoTracking`.
- **Calling `ToList()` before filtering** — materializes the entire table into memory. Always apply `Where()` and pagination before `ToListAsync()`.
- **Mixing `DbContext` across threads** — `DbContext` is not thread-safe. Use a scoped lifetime and never share a context between concurrent operations.
- **Ignoring migration bundles** — relying on `EnsureCreated()` in production skips the migration history entirely and will break on schema changes.
skilldb get csharp-dotnet-skills/Entity FrameworkFull skill: 226 lines
Paste into your CLAUDE.md or agent config

Entity Framework Core — C#/.NET

You are an expert in Entity Framework Core for building data access layers in .NET applications.

Core Philosophy

Overview

Entity Framework Core (EF Core) is the official ORM for .NET. It maps C# classes to database tables, translates LINQ queries to SQL, tracks entity changes, and manages schema evolution through migrations. EF Core supports SQL Server, PostgreSQL, SQLite, MySQL, and other databases through provider packages.

Core Concepts

DbContext and Entity Configuration

The DbContext is the unit-of-work that coordinates queries, change tracking, and persistence.

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Fluent Configuration with IEntityTypeConfiguration

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        builder.Property(o => o.OrderNumber)
            .IsRequired()
            .HasMaxLength(50);

        builder.Property(o => o.Total)
            .HasPrecision(18, 2);

        builder.HasMany(o => o.Items)
            .WithOne(i => i.Order)
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasIndex(o => o.OrderNumber).IsUnique();
    }
}

Migrations

# Create a migration
dotnet ef migrations add AddOrdersTable

# Apply migrations
dotnet ef database update

# Generate SQL script for production
dotnet ef migrations script --idempotent -o migrate.sql

Implementation Patterns

Repository Pattern (Thin Wrapper)

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<List<T>> GetAllAsync(CancellationToken ct = default);
    void Add(T entity);
    void Remove(T entity);
}

public class Repository<T>(AppDbContext db) : IRepository<T> where T : class
{
    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default)
        => await db.Set<T>().FindAsync([id], ct);

    public async Task<List<T>> GetAllAsync(CancellationToken ct = default)
        => await db.Set<T>().ToListAsync(ct);

    public void Add(T entity) => db.Set<T>().Add(entity);
    public void Remove(T entity) => db.Set<T>().Remove(entity);
}

public class UnitOfWork(AppDbContext db) : IUnitOfWork
{
    public Task<int> SaveChangesAsync(CancellationToken ct = default)
        => db.SaveChangesAsync(ct);
}

Querying with Projections

// Bad: loads entire entity graph
var orders = await db.Orders
    .Include(o => o.Items)
    .ThenInclude(i => i.Product)
    .ToListAsync();

// Good: project to DTO, only selects needed columns
var orderDtos = await db.Orders
    .Where(o => o.Status == OrderStatus.Active)
    .Select(o => new OrderDto
    {
        Id = o.Id,
        OrderNumber = o.OrderNumber,
        Total = o.Total,
        ItemCount = o.Items.Count,
        ProductNames = o.Items.Select(i => i.Product.Name).ToList()
    })
    .ToListAsync();

Compiled Queries for Hot Paths

public static class ProductQueries
{
    public static readonly Func<AppDbContext, decimal, IAsyncEnumerable<Product>>
        GetExpensiveProducts = EF.CompileAsyncQuery(
            (AppDbContext db, decimal minPrice) =>
                db.Products.Where(p => p.Price >= minPrice));
}

// Usage
await foreach (var product in ProductQueries.GetExpensiveProducts(db, 100m))
{
    Console.WriteLine(product.Name);
}

Interceptors for Cross-Cutting Concerns

public class AuditInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        var context = eventData.Context!;
        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
                entry.Entity.CreatedAt = DateTime.UtcNow;

            if (entry.State == EntityState.Modified)
                entry.Entity.UpdatedAt = DateTime.UtcNow;
        }

        return base.SavingChangesAsync(eventData, result, ct);
    }
}

// Registration
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .AddInterceptors(new AuditInterceptor()));

Pagination

public record PagedResult<T>(List<T> Items, int TotalCount, int Page, int PageSize);

public static async Task<PagedResult<T>> ToPagedAsync<T>(
    this IQueryable<T> query, int page, int pageSize, CancellationToken ct = default)
{
    var totalCount = await query.CountAsync(ct);
    var items = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync(ct);

    return new PagedResult<T>(items, totalCount, page, pageSize);
}

Best Practices

  • Always use AsNoTracking() for read-only queries to avoid the overhead of change tracking.
  • Project to DTOs with Select() instead of loading full entity graphs with Include().
  • Use IEntityTypeConfiguration in separate files rather than crowding OnModelCreating.
  • Generate idempotent SQL scripts for production deployments instead of running dotnet ef database update directly.
  • Register DbContext with AddDbContextPool for high-throughput APIs to reuse context instances.
  • Pass CancellationToken through to all async EF Core calls so requests can be cancelled gracefully.

Common Pitfalls

  • N+1 queries — accessing a navigation property in a loop without Include() or projection triggers a query per iteration. Use eager loading or Select() projections.
  • Tracking queries for read-only data — adds memory pressure and slows down queries. Add AsNoTracking() or configure it at the context level with QueryTrackingBehavior.NoTracking.
  • Calling ToList() before filtering — materializes the entire table into memory. Always apply Where() and pagination before ToListAsync().
  • Mixing DbContext across threadsDbContext is not thread-safe. Use a scoped lifetime and never share a context between concurrent operations.
  • Ignoring migration bundles — relying on EnsureCreated() in production skips the migration history entirely and will break on schema changes.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add csharp-dotnet-skills

Get CLI access →