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. @since and @take are 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 an int and SUM of a bigint is a bigint, which match the record's types exactly.
  • CommandDefinition carries the cancellation token, so a cancelled request also cancels the query.
  • Money is a long of Rials, never a double.

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.