One page for the decision behind the requirement “EF Core, Dapper, or another ORM/data-access framework”: what each option actually does, where its cost sits, and how to combine them without turning the data layer into two competing truths.
“ORM or Dapper” is really a choice between four levels of abstraction. Each one trades control for convenience, and the trade is not symmetrical: what you give up is not the same thing at every level.
| Family | What it does for you | What you still own | Typical cost |
|---|---|---|---|
Raw ADO.NET (DbConnection, DbCommand, DbDataReader) | Nothing beyond the provider | SQL, parameters, mapping, lifetime, transactions | Most code, least magic, no surprises |
| Micro-ORM (Dapper, RepoDB, SqlMapper) | Parameter binding and result mapping into objects | SQL text, query shape, and the schema contract | Tiny runtime overhead; SQL is hand-written |
| LINQ-based O/RM (EF Core, linq2db, NHibernate) | Query translation, change tracking, migrations, relationship fix-up | Model design, query shaping, and the decision of when to drop to SQL | Translation cost, tracking cost, plan-cache effects |
| Document / event mapper (Marten, EF Core Cosmos provider) | Serialization, identity, and querying over non-tabular stores | Document shape, versioning of stored payloads | Serializer coupling; weaker ad-hoc querying |
The decision is not “which is faster”. Dapper wins raw microbenchmarks almost always, because it does almost nothing: no expression tree to parse, no change tracker, no identity map. EF Core wins on the work it removes — migrations, relationship management, and the ability to express a query once in typed code. Both statements are true at the same time, which is why mature systems mix them.
EF Core maps a .NET object model to a relational schema and translates LINQ expression trees into SQL. Its value is not the SELECT it writes; it is the lifecycle around that SELECT.
Id/<Type>Id is the key, navigation properties become relationships, and IEntityTypeConfiguration<T> classes hold the rest.public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.ToTable("Orders");
b.HasKey(o => o.Id);
b.Property(o => o.Number).HasMaxLength(32).IsRequired();
b.HasIndex(o => new { o.CustomerId, o.CreatedAt });
// Complex type: value semantics, no identity, one JSON column
b.ComplexProperty(o => o.Shipping, c => c.ToJson());
}
}
Reads that go through the tracker pay for identity resolution and snapshotting. That is what makes SaveChanges able to compute a diff — and what makes a 50 000-row read in a request expensive for no reason.
// read-only API path: no tracking, no identity map
var list = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderDto(o.Id, o.Number, o.Total))
.Take(50)
.ToListAsync(ct);
// write path: tracked, one unit of work
var order = await db.Orders.Include(o => o.Lines).SingleAsync(o => o.Id == id, ct);
order.AddLine(sku, qty);
await db.SaveChangesAsync(ct);
AsNoTracking() plus Select removes most of the “EF is slow” anecdotes before they happen.| Capability | Why it matters in a real API |
|---|---|
| Migrations | Schema changes are versioned, reviewable and repeatable across environments |
| Relationship fix-up | Loading an aggregate graph does not require hand-written joins |
| Optimistic concurrency | [Timestamp]/rowversion plus DbUpdateConcurrencyException out of the box |
| Batching | Multiple inserts/updates are grouped into fewer round-trips |
| Provider portability | SQL Server, PostgreSQL, SQLite, Cosmos and others behind one model |
| Global query filters | Soft delete and multi-tenancy applied consistently by default |
Includes over collections multiply rows; AsSplitQuery() fixes it at the cost of more round-trips.EF Core 10 shipped in November 2025 as an LTS release supported until November 2028, and requires the .NET 10 runtime. The items below change day-to-day design decisions, not just micro-performance.
| Change | Practical consequence |
|---|---|
| Named query filters | Several filters per entity, disabled selectively: IgnoreQueryFilters(["SoftDeletionFilter"]) instead of turning everything off |
| Complex types (incl. optional, structs, JSON mapping) | Value-semantics replacement for owned types; assigning one to another copies instead of failing, and ExecuteUpdateAsync works on them |
| Parameterized collections: one parameter per value, padded | Solves the plan-cache bloat of inlined IN (...) while keeping cardinality visible to the planner; switchable per query with EF.Constant() |
LeftJoin/RightJoin | First-class LINQ operators from .NET 10, no more GroupJoin+DefaultIfEmpty gymnastics |
ExecuteUpdateAsync with a normal lambda | Conditional setters without hand-built expression trees |
| JSON column support (SQL Server 2025) | Native json type, JSON_VALUE translation, ExecuteUpdate against properties inside the document |
Vector type and VectorDistance | Similarity search in LINQ for Azure SQL / SQL Server 2025 embeddings |
| Redaction of inlined constants | Inlined values are logged as ? by default — PII stops leaking into logs |
| Split-query ordering fix | Related data loaded by split queries is deterministic under OrderBy + Take |
UseAzureSql, existing nvarchar JSON columns are converted to the new json type on the first migration. If that is not what you want, pin the column type or lower the compatibility level before generating it.Dapper adds three things on top of DbConnection: parameter binding from an object, mapping of the result set into objects, and a set of helpers for multi-mapping and multi-result queries. Everything else — SQL, schema coupling, transaction scope — stays yours.
const string sql = @"
SELECT o.Id, o.Number, o.Total, c.Name AS CustomerName
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
WHERE o.CustomerId = @customerId AND o.CreatedAt >= @from
ORDER BY o.CreatedAt DESC
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;";
var rows = await connection.QueryAsync<OrderRow>(sql, new
{
customerId,
from,
skip = (page - 1) * size,
take = size
});
var sql = @"
SELECT o.Id, o.Number, l.Sku, l.Quantity
FROM Orders o
LEFT JOIN OrderLines l ON l.OrderId = o.Id
WHERE o.Id = @id;";
var order = (await connection.QueryAsync<Order, Line, Order>(
sql,
(o, l) => { o.Lines.Add(l); return o; },
new { id },
splitOn: "Sku")).First();
// two result sets, one round-trip
using var multi = await connection.QueryMultipleAsync(
"SELECT * FROM Orders WHERE Id = @id; SELECT * FROM OrderLines WHERE OrderId = @id;",
new { id });
var head = await multi.ReadSingleAsync<Order>();
var lines = (await multi.ReadAsync<Line>()).ToList();
Query buffers the whole result by default. For large reads use QueryUnbufferedAsync and stream.MERGE.| Framework | Model | Strengths | Watch out for |
|---|---|---|---|
| ADO.NET | Raw commands and readers | Total control, zero abstraction, useful for DDL and provider-specific features | Boilerplate, manual mapping, easy to leak connections |
| NHibernate | Full O/RM, session-based, HQL/Criteria | Mature second-level cache, rich mapping, strong in legacy .NET Framework estates | Steeper learning curve; less common in new .NET Core work |
| linq2db | LINQ over SQL with explicit query building | Very predictable SQL, fast, supports CTEs and temp tables well | Smaller ecosystem than EF Core; manual schema mapping |
| Marten | Document DB and event store on PostgreSQL | Aggregates as documents, built-in event sourcing, LINQ querying | Ties the model to PostgreSQL; document versioning is on you |
| RepoDB | Hybrid micro-ORM | Fluent and raw operations, bulk merge, caching layer | Less community material; conventions must be learned |
| SqlKata / query builders | Composable SQL builder | Dynamic filters without string concatenation, multi-dialect | Still hand-mapped results; not an O/RM |
FromSql) for reporting reads. Two tools, one schema, clear boundary.| Criterion | EF Core | Dapper | Raw ADO.NET |
|---|---|---|---|
| Query language | LINQ translated to SQL | Hand-written SQL | Hand-written SQL |
| Change tracking | Yes, opt-out | No | No |
| Migrations | Built in | External tool | External tool |
| Mapping | Automatic, conventions + config | Automatic by column name | Manual reader loop |
| Overhead per row | Materialization + tracking (if enabled) | Reflection-emitted mapper, very low | Lowest |
| Complex SQL | Possible but fights the translator | Native | Native |
| Learning curve | High (model + translation rules) | Low | Low, but verbose |
| Testability | In-memory/SQLite providers, but behavior differs from production | Needs a real database | Needs a real database |
| Best fit | Domain writes, CRUD, aggregates | Reads, reports, bulk, legacy schema | DDL, maintenance jobs, provider-specific features |
DbContext is already a unit of work and a repository over its DbSets. Wrapping it in IRepository<T> usually produces a leaky abstraction: IQueryable escapes anyway, or you lose Include, projections and filters. Prefer a use-case-oriented interface over a generic one.
// Prefer this
public interface IOrderStore
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task<IReadOnlyList<OrderSummary>> ListRecentAsync(Guid customerId, int take, CancellationToken ct);
}
// Over this: it re-exposes the ORM with extra steps
public interface IRepository<T> where T : class
{
IQueryable<T> Query();
Task<T?> GetByIdAsync(object id);
// ...
}
Keep the write model expressive (EF Core, aggregates, invariants) and let reads go straight to SQL (Dapper) with DTOs shaped for the screen or the API contract. The boundary is a namespace, not a distributed system:
Orders/ // EF Core: aggregates, invariants, SaveChanges
Orders/Queries/ // Dapper: SELECTs returning DTOs
Infrastructure/ // DbContext, connection factory, migrations
Useful when the same filtering rule is reused across use cases; harmful when it becomes a second query language. If a specification needs three Includes and a projection, it is a query, not a specification.
Data-access cost is dominated by round-trips, not by mapping. If a request performs n queries at latency L, the floor is:
A 2 ms local query becomes 40 ms across an availability zone. This is why N+1 hurts more in production than on a laptop, and why batching beats micro-optimizing the mapper.
AsNoTracking() + Select into a DTO for read endpoints.AsSplitQuery() when two or more collection Includes multiply rows.OFFSET; OFFSET 100000 reads and discards 100 000 rows.EF.CompileAsyncQuery for a hot query built in a loop.ToList().ToQueryString() and EXPLAIN.QueryUnbufferedAsync for large result sets; buffering 500 000 rows is a memory incident.DbConnection from a pool; do not open per call without using discipline.IN lists properly; Dapper expands @ids into many parameters, which is good, but very large lists need a table-valued parameter or a temp table.SELECT * plus a join is how read models become slow.EF Core and Dapper can share a transaction: open it on the DbContext connection and pass the same connection and transaction to Dapper.
await using var tx = await db.Database.BeginTransactionAsync(ct);
var order = await db.Orders.SingleAsync(o => o.Id == id, ct);
order.MarkPaid();
await db.SaveChangesAsync(ct); // EF Core
await db.Database.GetDbConnection().ExecuteAsync(
"INSERT INTO AuditLog (EntityId, Action, At) VALUES (@id, 'paid', SYSUTCDATETIME())",
new { id }, tx); // Dapper, same transaction
await tx.CommitAsync(ct);
rowversion/xmin plus a retry loop on DbUpdateConcurrencyException. Default for APIs.SELECT ... FOR UPDATE or UPDLOCK, only inside short transactions; long locks turn into timeouts under load.dotnet ef migrations script --idempotent) and review them; do not run Database.Migrate() from application startup in production.| Approach | Good for | Not good for |
|---|---|---|
| EF Core InMemory provider | Fast unit tests of business logic | Anything about SQL, constraints, transactions — it is not a relational database |
| SQLite in-memory | Relational behavior in fast tests | Provider-specific SQL, concurrency, case sensitivity |
| Testcontainers (SQL Server/PostgreSQL) | Real plans, real constraints, migrations, Dapper queries | Speed — use it for integration suites, not for every test |
| WebApplicationFactory + Testcontainers | End-to-end API tests with the real data layer | Sub-second feedback loops |
MERGE)? Dapper for that query, in the same project.AsNoTracking and projections, not full entities.Includes were checked for cartesian growth.LeftJoin, JSON and vector support.