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