EF Core or Dapper? A deliberate split instead of one ORM everywhere
Short answer: use EF Core for the write side, where you load an aggregate, change it under business rules and save it in a transaction. Use Dapper for the read side, where a screen or report needs a specific shape of data and you want to write that SQL yourself. You don't have to pick one library for the whole application; a deliberate split gives you EF Core's safety where data changes and Dapper's control where data is only read.
A real example: orders in, reports out
Picture a shop backend. Placing an order is a write: you create an Order with its lines, compute the total, enforce rules (no empty orders, stock checks) and save everything atomically. This is EF Core's home ground. Change tracking, relationships and SaveChanges in one transaction remove a whole class of bugs.
The finance team then wants "top customers this month": order count, total spent and last order date, grouped and sorted. You can express that in LINQ, but you will spend time checking which SQL it produces, and the moment you need a window function or a query hint you are fighting the abstraction. With Dapper you write the SQL the report needs and map it straight into a record.
The two jobs have different priorities. Writes need correctness: invariants, transactions, concurrency. Reads need shape and speed: exactly the columns the screen shows, nothing tracked, nothing extra. That is also the core idea of CQRS, without any framework: the model you write with doesn't have to be the model you read with.
Runnable code
Add Microsoft.EntityFrameworkCore.SqlServer and Dapper to a .NET 10 project. The write side is a normal EF Core aggregate:
using Dapper;
using Microsoft.EntityFrameworkCore;
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public long TotalRials { get; set; }
public DateTime CreatedAt { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
public sealed class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public required string Sku { get; set; }
public int Quantity { get; set; }
public long PriceRials { get; set; }
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public sealed record OrderItem(string Sku, int Quantity, long PriceRials);
public sealed class OrderService(ShopDb db)
{
public async Task<int> PlaceAsync(int customerId, IReadOnlyList<OrderItem> items, CancellationToken ct)
{
if (items.Count == 0)
{
throw new ArgumentException("An order needs at least one line.", nameof(items));
}
var order = new Order
{
CustomerId = customerId,
CreatedAt = DateTime.UtcNow,
Lines = [.. items.Select(i => new OrderLine { Sku = i.Sku, Quantity = i.Quantity, PriceRials = i.PriceRials })],
};
order.TotalRials = order.Lines.Sum(l => l.Quantity * l.PriceRials);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
The read side uses Dapper, but borrows EF Core's connection. One connection string and one owner, and in a request scope you don't open a second connection:
public sealed record CustomerSummary(int CustomerId, int Orders, long TotalRials, DateTime LastOrderAt);
public sealed class ReportQueries(ShopDb db)
{
public async Task<IReadOnlyList<CustomerSummary>> TopCustomersAsync(DateTime since, int take, CancellationToken ct)
{
var connection = db.Database.GetDbConnection();
const string sql = """
SELECT TOP (@take)
CustomerId,
COUNT(*) AS Orders,
SUM(TotalRials) AS TotalRials,
MAX(CreatedAt) AS LastOrderAt
FROM Orders
WHERE CreatedAt >= @since
GROUP BY CustomerId
ORDER BY SUM(TotalRials) DESC;
""";
var rows = await connection.QueryAsync<CustomerSummary>(
new CommandDefinition(sql, new { since, take }, cancellationToken: ct));
return rows.AsList();
}
}
A few details carry the weight:
- Parameters, always.
@sinceand@takeare SQL parameters, not string concatenation, so there is no injection risk and SQL Server can reuse the plan. - Column aliases match the record. Dapper maps by name, and here it uses
CustomerSummary's constructor.COUNT(*)is anintandSUMof abigintis abigint, which match the record's types exactly. CommandDefinitioncarries the cancellation token, so a cancelled request also cancels the query.- Money is a
longof Rials, never adouble.
How to choose, query by query
| The query… | Use |
|---|---|
| loads an aggregate to change it under business rules | EF Core |
| creates or updates several related rows atomically | EF Core |
| feeds a list screen with filters and paging | EF Core with AsNoTracking() + Select, or Dapper |
| is a report: grouping, window functions, CTEs, hints | Dapper |
| runs very hot and you need to see and tune its exact SQL | Dapper |
| bulk-updates many rows by a condition | EF Core ExecuteUpdateAsync (or Dapper) |
The middle row matters: EF Core reads are fine for most screens if you project to a DTO and turn off tracking. Reach for Dapper when you want to own the SQL, not by reflex.
Pros and cons
Pros
- Writes keep EF Core's guarantees: change tracking, relationships, transactions, concurrency tokens.
- Reads get exactly the SQL you intend, which is easy to read in a code review and easy to tune with an execution plan.
- No CQRS framework needed: two services, one connection.
Cons
- Two mapping styles in one codebase; the team needs to know both.
- Dapper queries are strings: renaming a column won't break the build. Cover them with integration tests against a real database.
- Read models can drift from the write model; that is the point, but someone has to own them.
The rule of thumb
If a query changes data, it goes through the model that enforces the rules: EF Core. If it only shows data, write the query that fits the screen, with EF Core projections or Dapper. That one sentence settles most "which ORM?" debates before they start.
Need a .NET backend where the data layer is fast and safe to change? See how I can help.
Frequently asked questions
Is Dapper faster than EF Core?
For simple reads the gap is small in recent EF Core versions, especially with AsNoTracking and projections. Dapper's real advantage is control: you write exactly the SQL you want, which matters for reporting queries with grouping, window functions or hints.
Can EF Core and Dapper share a transaction?
Yes. Get the connection with Database.GetDbConnection() and, if a transaction is open, pass Database.CurrentTransaction.GetDbTransaction() to Dapper's transaction parameter. Both then work on the same connection and transaction.
Doesn't using two data-access libraries make the code harder to maintain?
Only if the boundary is fuzzy. Keep a simple rule: commands and aggregates go through EF Core, read models for screens and reports go through Dapper. Then each query has an obvious home.