EF Core Fundamentals — ORM Concepts and the DbContext

Entity Framework Core is an ORM (Object-Relational Mapper) — it maps C# classes to database tables and handles the translation between C# operations (LINQ queries, property assignments) and SQL (SELECT, INSERT, UPDATE, DELETE). The DbContext is the central class: it represents a database session, tracks changes to entities, and translates those changes to SQL when SaveChangesAsync() is called. Understanding the DbContext’s role as a combined Unit of Work and Repository pattern foundation is the key to using EF Core correctly.

Core Concepts

// ── AppDbContext — the entry point to the database ─────────────────────────
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    // DbSet = table. EF Core maps Post class → Posts table
    public DbSet<Post>    Posts    => Set<Post>();
    public DbSet<Comment> Comments => Set<Comment>();
    public DbSet<Tag>     Tags     => Set<Tag>();
    public DbSet<User>    Users    => Set<User>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply all IEntityTypeConfiguration classes from this assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }
}

// ── Register in Program.cs ─────────────────────────────────────────────────
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseSqlServer(
        builder.Configuration.GetConnectionString("Default"),
        sqlOpts => sqlOpts
            .EnableRetryOnFailure(maxRetryCount: 3,
                maxRetryDelay: TimeSpan.FromSeconds(5),
                errorNumbersToAdd: null)
            .CommandTimeout(30)));   // 30-second query timeout

// ── appsettings.Development.json ──────────────────────────────────────────
// {
//   "ConnectionStrings": {
//     "Default": "Server=(localdb)\\mssqllocaldb;Database=BlogApp_Dev;Trusted_Connection=True"
//   }
// }

// ── Change tracking — how EF Core knows what changed ──────────────────────
await using var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var post = await db.Posts.FindAsync(42);   // EntityState.Unchanged
post!.Title = "New Title";                  // EntityState.Modified (tracked!)
await db.SaveChangesAsync();               // → UPDATE Posts SET Title='New Title' WHERE Id=42
Note: EF Core’s change tracker monitors all entities loaded through the DbContext. When you modify a tracked entity’s property, EF Core records the change in memory. When SaveChangesAsync() is called, EF Core generates SQL for only the changed properties — not a full UPDATE of all columns. This is efficient but has a trade-off: loading entities just to read them (without modifying) adds change tracking overhead. Use AsNoTracking() for read-only queries to eliminate this overhead.
Tip: Enable SQL logging in development to see the actual SQL EF Core generates. Add to appsettings.Development.json: "Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }. This logs every SQL query with parameters — the single most valuable tool for understanding EF Core performance. Seeing a list endpoint generate 51 SQL statements instead of 1 instantly reveals an N+1 problem that would otherwise be invisible.
Warning: The DbContext is not thread-safe — never use one DbContext instance from multiple threads simultaneously. In ASP.NET Core, DbContext is registered as Scoped (one per HTTP request), which ensures each request has its own instance. The common mistake is capturing a DbContext in a lambda that runs on a background thread, or injecting a Scoped DbContext into a Singleton service — both cause concurrent access to the same DbContext instance and produce confusing errors.

EF Core vs Dapper — When to Use Each

Scenario EF Core Dapper
Standard CRUD ✅ Less code, automatic mapping More verbose SQL
Complex reporting queries Can struggle with complex SQL ✅ Raw SQL, full control
Migrations ✅ Built-in migrations Manual schema management
Performance-critical reads Good with AsNoTracking+Select ✅ Faster for pure reads
Existing stored procedures Can call via ExecuteSqlRaw ✅ Natural fit
Team familiarity ✅ Widely known in .NET Smaller learning curve

Common Mistakes

Mistake 1 — Injecting DbContext into a Singleton service (thread-safety violation)

❌ Wrong — Scoped DbContext captured in a Singleton; concurrent requests share one DbContext:

// Singleton service with Scoped DbContext — runtime error on concurrent requests!
public class CacheSingletonService(AppDbContext db) { }

✅ Correct — inject IDbContextFactory<AppDbContext> into Singleton services; create a context per operation.

Mistake 2 — Not calling SaveChangesAsync() (changes silently lost)

❌ Wrong — modifying entities without saving; changes disappear when the DbContext scope ends.

✅ Correct — always call await db.SaveChangesAsync(ct) to persist changes to the database.

🧠 Test Yourself

A Post entity is loaded with db.Posts.FindAsync(42). Its title is modified. No SaveChangesAsync() is called. The DbContext goes out of scope (request ends). What happened to the change?