EF Core Fundamentals
Key Points
DbContextis unit-of-work + identity map + change tracker, all in one. Lifetime: scoped per request in ASP.NET Core; pooled viaAddDbContextPoolfor hot paths.DbSet<TEntity>is the queryable collection per entity type. Tracking is automatic by default.- Change tracker monitors loaded entities and computes UPDATE/INSERT/DELETE on
SaveChanges. - Conventions drive most of the model: PK is
Idor<Type>Id, FKs by name, table = pluralized class name. Override viaOnModelCreatingor attributes. - Migrations evolve the schema as the model changes. Add via CLI; apply via
Database.Migrate()or out-of-band scripts. - Don't share
DbContextacross threads — it's not thread-safe.
Concepts (deep dive)
Defining the model
public class Order
{
public int Id { get; set; }
public Guid CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public List<OrderLine> Lines { get; set; } = new();
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
public int Quantity { get; set; }
public decimal Price { get; set; }
}
public class AppDb : DbContext
{
public AppDb(DbContextOptions<AppDb> opts) : base(opts) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>(o =>
{
o.Property(x => x.Total).HasColumnType("decimal(18, 2)");
o.Property(x => x.Status).HasConversion<string>();
o.HasIndex(x => x.CreatedAt);
});
}
}
Registration
builder.Services.AddDbContext<AppDb>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Db")));
// Or pooled (faster for high RPS):
builder.Services.AddDbContextPool<AppDb>(opt =>
opt.UseSqlServer(connStr));
AddDbContext is scoped (one per request). AddDbContextPool reuses contexts from a pool — cuts allocation and metadata setup cost. Pool requires a parameterless OnConfiguring or constructor accepting only DbContextOptions<>.
Querying
public class OrdersService(AppDb db)
{
public async Task<Order?> GetAsync(int id)
=> await db.Orders.FindAsync(id); // PK lookup; uses identity map
public async Task<List<Order>> GetActiveAsync()
=> await db.Orders
.Where(o => o.Status == OrderStatus.Active)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
public async Task<Order?> GetWithLinesAsync(int id)
=> await db.Orders
.Include(o => o.Lines)
.SingleOrDefaultAsync(o => o.Id == id);
}
FindAsync checks the identity map first (cheaper for already-loaded entities); LINQ queries always go to the DB.
Change tracking
var order = await db.Orders.FindAsync(id);
order.Status = OrderStatus.Shipped; // tracked automatically
await db.SaveChangesAsync(); // emits UPDATE
Default behavior tracks every loaded entity. For read-only queries, AsNoTracking() (next topic) skips tracking — significant perf win.
Adding entities
var order = new Order { CustomerId = customerId, Total = 100m };
db.Orders.Add(order); // marks as Added
await db.SaveChangesAsync(); // INSERT; populates order.Id
Removing
var order = await db.Orders.FindAsync(id);
db.Orders.Remove(order!); // marks as Deleted
await db.SaveChangesAsync();
Or, batch delete without loading (.NET 7+):
await db.Orders.Where(o => o.Status == OrderStatus.Cancelled).ExecuteDeleteAsync();
// SQL: DELETE FROM Orders WHERE Status = 'Cancelled' — single round-trip
Lifetime hazards
public class Service(AppDb db) // scoped: one per HTTP request
{
public Task<Order?> GetAsync(int id) => db.Orders.FindAsync(id).AsTask();
}
public class HostedService(AppDb db) : BackgroundService // ❌ singleton holding scoped
Fix for hosted services / singletons: inject IDbContextFactory<AppDb> and create contexts on demand.
Conventions
EF Core infers: - Primary key: Id or <TypeName>Id. - Foreign keys: <NavName>Id or <NavTypeName>Id. - Table name: pluralized class name. - Column name: property name. - Required vs optional: non-nullable types are required; nullable types are optional. - String max length: nvarchar(max) by default — explicitly set for production.
Override via Fluent API or [DataAnnotations]:
[Table("Customer")]
public class Customer
{
[Key, Column("CustomerKey")]
public Guid Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; } = "";
}
Owned types and value objects
public class Order
{
public Address ShippingAddress { get; set; } = new();
}
public class Address
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().OwnsOne(o => o.ShippingAddress); // flattens into Order's table
}
OwnsOne makes Address a value object owned by Order — properties become columns in Orders table.
Complex types (.NET 8+)
Lighter than OwnsOne for plain value objects (no separate change-tracking entity). Prefer for value objects in EF 8+.
Loading strategies
// Eager: included up-front
var orders = db.Orders.Include(o => o.Lines).ToList();
// Explicit: load related on demand
var order = db.Orders.Find(id);
db.Entry(order).Collection(o => o.Lines).Load();
// Lazy: navigation property fetches on access (requires UseLazyLoadingProxies + virtual)
// Generally avoid in modern code — explicit is clearer.
See EF Core — Querying & Projections.
Code-First vs Database-First
Two ways to bridge classes and schema. EF Core supports both; the EF6-era "Model-First" (EDMX designer) is dead — only Code-First and Database-First exist in EF Core.
Code-First Database-First
classes ──> migrations ──> DDL DDL ──> scaffold ──> classes
(source of truth: code) (source of truth: database)
Code-First — classes are the source of truth; dotnet ef migrations add Foo generates DDL; Database.Migrate() (or a deploy-time script) applies it. The default for greenfield .NET projects.
✅ Schema lives in version control alongside code; refactor-safe (rename a property → migration); code-review-friendly. Branching/merging schema changes is a normal PR. ❌ Requires migration discipline (no out-of-band schema edits). Some DB-side features — filtered indexes, computed columns, CHECK constraints, triggers — need explicit Fluent API or raw SQL inside the migration.
Database-First — DB exists already; scaffold the model from it.
dotnet ef dbcontext scaffold "Server=.;Database=Legacy;Trusted_Connection=True" \
Microsoft.EntityFrameworkCore.SqlServer \
-o Models --context AppDb
✅ Integrates with existing DBs where DBAs control schema; zero-friction onboarding to a legacy database. ❌ Re-scaffold overwrites customizations (use partial classes / OnModelCreatingPartial to keep your code separate). No EF-side migrations — the database owns the change pipeline.
Mixed pattern — scaffold once to bootstrap, then switch to code-first for ongoing changes. Common when migrating an existing system into a .NET codebase.
| Situation | Pick |
|---|---|
| Greenfield, app team owns schema | Code-First |
| Existing DB owned by DBA team | Database-First (or hybrid) |
| Migrating legacy system into .NET | Scaffold once → Code-First |
| One-off reverse-engineering / analytics | Scaffold once, treat as read-only |
💡 "Model-First" (EDMX designer-driven) was EF6-only and is dead in EF Core. If a stack-overflow answer references EDMX, it's pre-2016 advice.
Code: correct vs wrong
❌ Wrong: sharing context across threads
public class Worker(AppDb db)
{
public async Task RunAsync()
{
await Task.WhenAll(
db.Orders.ToListAsync(),
db.OrderLines.ToListAsync() // ❌ concurrent operations on same context
);
}
}
✅ Correct: serial or one context per task
public async Task RunAsync()
{
var orders = await db.Orders.ToListAsync();
var lines = await db.OrderLines.ToListAsync();
}
❌ Wrong: AddTransient<DbContext>
builder.Services.AddTransient<AppDb>(); // ❌ each resolve creates a new context — change-tracker chaos
✅ Correct: AddDbContext (scoped) or AddDbContextPool
❌ Wrong: not awaiting SaveChangesAsync
db.Orders.Add(order);
db.SaveChangesAsync(); // ❌ fire-and-forget; data may not be saved
return Ok();
✅ Correct
Design patterns for this topic
Pattern 1 — "Scoped DbContext per request"
- Intent: unit-of-work scope = HTTP request scope.
Pattern 2 — "Pooled context for high RPS"
- Intent: reuse contexts; cut metadata setup cost.
Pattern 3 — "IDbContextFactory<TContext> in singletons / workers"
- Intent: explicit lifetime control.
Pattern 4 — "Owned/complex types for value objects"
- Intent: keep value objects table-mates of their owner.
Pattern 5 — "Bulk operations via ExecuteUpdate / ExecuteDelete"
- Intent: single SQL round-trip; no entity loading.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
AddDbContext (scoped) | Simple | Allocation per request |
AddDbContextPool | Less allocation | Restrictions on configuration |
IDbContextFactory | Explicit lifetime | More code |
| Change tracking | Auto-detect updates | Memory cost; overhead |
| Conventions | Less ceremony | Surprises |
When to use / when to avoid
- Use scoped for typical web apps.
- Use pooled for high-RPS services.
- Use factory for hosted services / singletons.
- Avoid sharing context across threads.
- Avoid lazy loading — explicit
Includeis clearer.
Interview Q&A
Q1. What lifetime is DbContext in ASP.NET Core? Scoped (one per request). Use AddDbContextPool for pooling.
Q2. Difference between AddDbContext and AddDbContextPool? The pooled variant reuses context instances from a pool to cut allocation cost. Restriction: configuration must not depend on request-scoped state.
Q3. Why is DbContext not thread-safe? The change tracker and underlying connection state aren't designed for concurrent access. Use one context per task.
Q4. What's the change tracker? Internal mechanism that tracks loaded entity states (Added/Modified/Unchanged/Deleted) so SaveChanges can compute the SQL.
Q5. Why might AddTransient<DbContext> be bad? Each resolve creates a new context — different services see different change-tracker state; saves don't see each other.
Q6. What's Owned vs Complex types? Both flatten value objects into the parent's table. Owned is older, has identity, is tracked. Complex (EF 8+) is lighter, no identity, no tracking — prefer for pure value objects.
Q7. How do you bulk delete without loading entities? db.Orders.Where(...).ExecuteDeleteAsync(). Single SQL round-trip.
Q8. What is FindAsync vs FirstOrDefaultAsync? FindAsync looks up by primary key, hitting the identity map first (cheap if already loaded). FirstOrDefaultAsync always queries.
Q9. How do you handle DbContext in a singleton? IDbContextFactory<AppDb> (registered via AddDbContextFactory). Create + dispose contexts on demand.
Q10. What's the identity map? Per-context cache mapping (EntityType, PK) → instance. Ensures the same row maps to the same in-memory object across queries within the context's lifetime.
Gotchas / common mistakes
- ⚠️ Sharing context across threads — race conditions.
- ⚠️
AddTransient<DbContext>— fragmented state. - ⚠️ Long-lived contexts — change tracker grows; memory bloat.
- ⚠️ Not disposing in non-DI scenarios — connection leaks.
- ⚠️ Lazy loading without
virtual— silent failure. - ⚠️ Default string column =
nvarchar(max)— performance trap.