Entity Framework Core

Entity Framework Core is .NET's object-relational mapper (ORM): you work with C# classes and LINQ, and EF Core generates SQL, materializes objects and tracks changes.

Concept

Entity Framework Core is .NET's object-relational mapper (ORM): you work with C# classes and LINQ, and EF Core generates SQL, materializes objects and tracks changes.

A session with the database: queries, change tracking, SaveChanges. Short-lived (one per request/unit of work), not thread-safe

A table-like queryable collection of entities A class mapped to a table; Id/<Type>Id is the key by convention

Navigation properties + foreign keys: one-to-many (Ecu.Dtcs / Dtc.Ecu), one-to-one, many-to-many

Conventions, data annotations ([MaxLength]), or the fluent API in OnModelCreating (indexes, unique constraints, delete behaviour)

Versioned schema changes: dotnet ef migrations add AddFirmware, dotnet ef database update (tool: dotnet tool install -g dotnet-ef). EnsureCreated is for demos and tests only Change tracking

Entities loaded normally are *tracked*; SaveChanges compares them with original values and writes only changes. States: Added, Unchanged, Modified, Deleted, Detached No-tracking queries

AsNoTracking() for read-only queries — faster, less memory

Eager (Include), explicit (Entry(e).Collection(...).Load()), lazy (proxies package; loads on first access — beware of N+1 queries)

SaveChanges is transactional by itself; Database.BeginTransaction() spans several calls

Optimistic concurrency with a concurrency token ([ConcurrencyCheck], IsConcurrencyToken, or a SQL Server rowversion) → DbUpdateConcurrencyException Bulk operations

ExecuteUpdate / ExecuteDelete run set-based SQL without loading entities

Query optimization: project only needed columns with Select, filter in the database (keep IQueryable until the end), avoid N+1 with Include or projections, use AsNoTracking, add indexes, inspect SQL with ToQueryString() and logging, and use AsSplitQuery() for large multi-collection includes.

Why It Matters

Most line-of-business .NET applications — test management systems, fleet portals, manufacturing execution systems — use EF Core. Knowing what SQL it generates and how tracking works separates fast, correct applications from ones with N+1 queries and lost updates.

Syntax

public class GarageContext(DbContextOptions<GarageContext> o) : DbContext(o) { public DbSet<Ecu> Ecus => Set<Ecu>(); }
builder.Services.AddDbContext<GarageContext>(o => o.UseSqlite("Data Source=garage.db"));
var active = await db.Dtcs.Where(d => d.Active).Include(d => d.Ecu).AsNoTracking().ToListAsync();
db.Ecus.Add(ecu); await db.SaveChangesAsync();
await db.Dtcs.Where(d => !d.Active).ExecuteDeleteAsync();

Example

50.1 Model, queries, loading, tracking, transactions and concurrency

The program uses Microsoft.EntityFrameworkCore.Sqlite with a temporary SQLite file.

#:package [email protected]
#:property PublishAot=false
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;

var sqlLog = new List<string>();
string dbPath = Path.Combine(Path.GetTempPath(), "eh_efcore_demo.db");
File.Delete(dbPath);
var options = new DbContextOptionsBuilder<GarageContext>()
    .UseSqlite($"Data Source={dbPath}")
    .LogTo(s => sqlLog.Add(s), [Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.CommandExecuted])
    .Options;

// ---- Create schema and seed ----
using (var db = new GarageContext(options))
{
    db.Database.EnsureCreated();           // demos/tests; real apps use migrations (dotnet ef migrations add Initial)
    var vcu = new Ecu { Code = "VCU", Firmware = "2.4.1" };
    vcu.Dtcs.Add(new Dtc { Code = "P0A80", Active = true });
    vcu.Dtcs.Add(new Dtc { Code = "U0100", Active = false });
    var bms = new Ecu { Code = "BMS", Firmware = "1.9.0", Dtcs = { new Dtc { Code = "P0AFA", Active = true } } };
    db.Vehicles.Add(new Vehicle { Vin = "EHBAUREV250000001", Ecus = { vcu, bms } });
    Console.WriteLine($"state before SaveChanges: {db.Entry(vcu).State}");
    int saved = db.S
…
erDiagram
    Vehicles ||--o{ Ecus : contains
    Ecus ||--o{ Dtcs : reports
    Vehicles {
        int Id PK
        string Vin
    }
    Ecus {
        int Id PK
        string Code UK
        string Firmware
        int Revision
        int VehicleId FK
    }
    Dtcs {
        int Id PK
        string Code
        bool Active
        int EcuId FK
    }

Expected Result

One SaveChanges inserted the vehicle, two ECUs and three DTCs (6 rows) in the right order, filling in generated keys and foreign keys. The LINQ query became a single SQL JOIN. Without Include the navigation collection stayed empty. The no-tracking entity was Detached, so its change was never saved. User B's update was rejected because the Revision concurrency token no longer matched.

state before SaveChanges: Added
SaveChanges wrote 6 rows; generated key VCU.Id = 1, state now Unchanged
SQL for the query:
SELECT "e"."Code" || ':' || "d"."Code"
FROM "Dtcs" AS "d"
INNER JOIN "Ecus" AS "e" ON "d"."EcuId" = "e"."Id"
WHERE "d"."Active"
ORDER BY "d"."Code"
result: VCU:P0A80, BMS:P0AFA
projection: { Code = BMS, Active = 1 }, { Code = VCU, Active = 1 }
without Include: VCU.Dtcs.Count = 0 (navigation not loaded)
eager Include:   VCU.Dtcs.Count = 2
explicit Load:   BMS.Dtcs.Count = 1
tracked entity after edit: Modified, modified: Firmware, original 2.4.1
no-tracking entity state: Detached; SaveChanges wrote 0 rows
after rollback ABS exists: False
concurrency conflict detected: user B's update was rejected (row changed since it was read)
ExecuteDelete removed 1 inactive DTC(s)
SQL commands executed: 29

Common Mistakes

Sharing one DbContext across threads or keeping it alive for the whole application.

N+1 queries: looping over entities and touching a lazy-loaded navigation in each iteration.

Calling ToList() too early, then filtering in memory.

Using EnsureCreated in production instead of migrations.

Loading entities just to delete/update them in bulk (use ExecuteDelete/ExecuteUpdate).

Ignoring DbUpdateConcurrencyException — silently losing another user's changes.

Best Practices

One DbContext per unit of work (scoped in ASP.NET Core).

AsNoTracking() and projections (Select) for read-only queries.

Migrations checked into source control and applied by a deployment step.

Log generated SQL in development and review it; add indexes for frequent filters.

Use concurrency tokens for data edited by several users.

Interview Relevance

Very high: "what does DbContext do?", "tracking vs no-tracking?", "eager vs lazy vs explicit loading?", "what is the N+1 problem?", "how do migrations work?", "how does EF Core handle concurrency?", "EF Core vs Dapper?".

Practice Questions

Add a many-to-many relationship between Ecu and TestCase.

Rewrite the summary projection to include the newest DTC code per ECU.

Create a migration with dotnet ef migrations add Initial in a console project and inspect the generated code.

Use ExecuteUpdate to bump the firmware of all ECUs of one vehicle.

Quick Quiz

Q1. (Multiple choice) Which call prevents the loaded entities from being tracked? A) Include B) AsNoTracking C) AsSplitQuery D) ToQueryString

Answer: B (verified: state Detached, SaveChanges wrote 0 rows).

Q2. (Predict the output) An Ecu is loaded without Include. What is ecu.Dtcs.Count?

Answer: 0 — the navigation was not loaded (verified), even though the database contains its DTCs.

Q3. (True/False) SaveChanges wraps its inserts and updates in a transaction.

Answer: True. Q4. (Find the bug) foreach (var e in db.Ecus.ToList()) Console.WriteLine(e.Dtcs.Count(d => d.Active)); with lazy loading enabled.

Answer: N+1 queries (one per ECU). Use a projection: db.Ecus.Select(e => new { e.Code, Active = e.Dtcs.Count(d => d.Active) }).

Coding Challenge

With EF Core and SQLite in memory, model TestRun (Id, Name, Passed), add three runs, and print the pass rate computed by the database, together with the SQL of the query.

#:package [email protected]
#:property PublishAot=false
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();                                   // an in-memory database lives as long as its connection
var options = new DbContextOptionsBuilder<BenchContext>().UseSqlite(connection).Options;

using var db = new BenchContext(options);
db.Database.EnsureCreated();
db.Runs.AddRange(new TestRun { Name = "FlashVcu", Passed = true },
                 new TestRun { Name = "ReadDtcs", Passed = true },
                 new TestRun { Name = "CanLoad", Passed = false });
db.SaveChanges();

var query = db.Runs.Select(r => r.Passed ? 1.0 : 0.0);
Console.WriteLine(query.ToQueryString());
Console.WriteLine($"pass rate: {query.Average():P0}");

public class TestRun { public int Id { get; set; } public required string Name { get; set; } public bool Passed { get; set; } }
public class BenchContext(DbContextOptions<BenchContext> options) : DbContext(options)
{
    public DbSet<TestRun> Runs => Set<TestRun>();
}