Design & Architecture

EF Core

Intermediate

Entity Framework Core is our default object-relational mapper for the .NET stack: it maps C# entities to relational tables, tracks changes, and generates SQL so you write less data-access boilerplate. It is excellent for transactional, domain-shaped work — and it has sharp edges (N+1 queries, accidental tracking, leaky entities) that you must know to use it safely.

EF Core lets you query with LINQ and persist object graphs without hand-writing most SQL, and its change tracker and `SaveChanges` give you a clean unit-of-work and transaction boundary. That productivity is real, and it is why EF Core is our default for command-side, domain-centric persistence.

The cost of that abstraction is that it hides what the database is doing, and the hidden behaviour is where the bugs live: a lazy navigation property that fires a query per row, a read that needlessly tracks entities, an EF entity bound straight to an HTTP request and over-posted. This page is about using EF Core deliberately. Where you need raw read performance or full SQL control, reach for Dapper instead — the two coexist happily, often in the same codebase split along CQRS lines. See also SQL Performance Tuning and Data Integrity & Transactions.

Query deliberately

Tracked read + N+1 var cases = db.KycCases.ToList(); // tracked, all columns
foreach (var c in cases)
total += c.Documents.Count(); // lazy load -> 1 query per case

Every case triggers another round trip for its documents, and all of it is needlessly change-tracked. Fine for 3 rows, a disaster for 30,000.

No-tracking projection var rows = await db.KycCases
.AsNoTracking()
.Select(c => new CaseRow(c.Id, c.Reference, c.Documents.Count))
.ToListAsync(ct); // one query, only the columns needed

A single SQL statement returns exactly the shape the screen needs, with no tracking overhead and no per-row round trips.

Persist safely

Manage schema and migrations

Self-review checklist

Why it matters: EF Core removes most data-access boilerplate and gives you a clean unit-of-work, which makes it our default for transactional, domain-shaped persistence. But it works by hiding the database, and the things it hides — N+1 queries, needless tracking, over-posting, raw-SQL injection, a mis-scoped context — are exactly where correctness, performance, and security go wrong. Used deliberately, with no-tracking reads, explicit loading, DTO binding, and proper transactions, it is productive and safe; used on autopilot, it quietly creates slow, leaky, or vulnerable code.