Skip to content

EF Core Fundamentals

Key Points

  • DbContext is unit-of-work + identity map + change tracker, all in one. Lifetime: scoped per request in ASP.NET Core; pooled via AddDbContextPool for 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 Id or <Type>Id, FKs by name, table = pluralized class name. Override via OnModelCreating or attributes.
  • Migrations evolve the schema as the model changes. Add via CLI; apply via Database.Migrate() or out-of-band scripts.
  • Don't share DbContext across 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+)

b.Entity<Order>().ComplexProperty(o => o.ShippingAddress);   // EF Core 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.

dotnet ef migrations add InitialCreate
dotnet ef database update

✅ 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-FirstDB 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

builder.Services.AddDbContextPool<AppDb>(opt => opt.UseSqlServer(connStr));

❌ Wrong: not awaiting SaveChangesAsync

db.Orders.Add(order);
db.SaveChangesAsync();   // ❌ fire-and-forget; data may not be saved
return Ok();

✅ Correct

db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Ok();

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

Further reading