//JorgenHoc
← Todos os artigos
EF CorePor Jorge CalderónAtualizado 19 min read

EF Core vs Dapper — Qual ORM Você Deve Usar?

Compare EF Core e Dapper em desempenho, controle de consultas, migrações e produtividade. Inclui benchmarks, código lado a lado e uma matriz de decisão.

#entity-framework#dotnet#database

Duas bibliotecas dominam o acesso a dados em .NET: EF Core, um ORM completo que gerencia todo o ciclo de vida do seu banco de dados, e Dapper, um micro-ORM que oferece SQL puro com apenas o necessário de mapeamento. A escolha entre eles — ou a combinação de ambos — depende da complexidade das suas consultas, da proficiência SQL da equipe e de quanto do seu domínio vive no esquema do banco de dados.

Filosofia

EF Core: ORM Completo

EF Core trata o banco de dados como um detalhe de implementação. Você modela seu domínio em classes C#, define relacionamentos com propriedades de navegação e deixa o framework gerar SQL, rastrear mudanças e gerenciar migrações. A abstração permite trocar de provedor de banco de dados sem alterar o código da aplicação.

O custo é a complexidade: a camada ORM introduz convenções, configurações e comportamentos que você precisa entender para evitar armadilhas de desempenho como consultas N+1 ou carregamentos completos de tabela não intencionais.

Dapper: Micro-ORM

Dapper estende IDbConnection com alguns métodos de extensão (Query<T>, Execute, QueryMultiple). Você escreve SQL; Dapper mapeia o conjunto de resultados para seus tipos. Essa é toda a superfície da API. Não há rastreamento de mudanças, sistema de migrações ou carregamento lazy — e tampouco mágica.

O ganho é previsibilidade: o SQL que você escreve é o SQL que é executado. A sobrecarga de desempenho acima do ADO.NET puro é insignificante.

Código Lado a Lado

Os exemplos abaixo usam uma entidade Product com uma Category associada. Eles não são apenas ilustrações: uma versão executável desta comparação vive em samples/ef-core-vs-dapper, que executa ambas as bibliotecas contra o mesmo banco SQL Server semeado e verifica com asserções cada afirmação desta seção — mesmas linhas em ambas, JOINs de uma única instrução, atualizações direcionadas de uma única coluna. Se algo deixar de ser verdade em uma versão futura do EF Core ou do Dapper, esse sample falha ruidosamente.

// Modelo compartilhado usado em todos os exemplos
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}
 
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public ICollection<Product> Products { get; set; } = [];
}

Consulta Simples — Buscar Todos os Produtos

EF Core

// DbContext resolve a conexão, rastreia as entidades retornadas por padrão
var products = await context.Products
    .AsNoTracking()          // desabilita o rastreamento para consultas somente leitura
    .ToListAsync();

Dapper

// Você controla completamente o SQL — seleção explícita de colunas evita SELECT *
const string sql = "SELECT Id, Name, Price, CategoryId FROM Products";
 
using var conn = new SqlConnection(connectionString);
var products = await conn.QueryAsync<Product>(sql);

Inserção

EF Core

var product = new Product { Name = "Widget", Price = 9.99m, CategoryId = 1 };
 
context.Products.Add(product);
await context.SaveChangesAsync();
// product.Id é preenchido após SaveChanges — EF lê a chave gerada

Dapper

const string sql = @"
    INSERT INTO Products (Name, Price, CategoryId)
    VALUES (@Name, @Price, @CategoryId);
    SELECT CAST(SCOPE_IDENTITY() AS INT);";
 
using var conn = new SqlConnection(connectionString);
int newId = await conn.ExecuteScalarAsync<int>(sql, new
{
    Name = "Widget",
    Price = 9.99m,
    CategoryId = 1
});

Atualização

EF Core

var product = await context.Products.FindAsync(id);
if (product is null) return;
 
product.Price = 14.99m;
await context.SaveChangesAsync();
// O rastreamento de mudanças detecta a modificação de Price e emite um UPDATE específico

Dapper

const string sql = "UPDATE Products SET Price = @Price WHERE Id = @Id";
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Price = 14.99m, Id = id });

Exclusão

EF Core

// ExecuteDelete evita carregar a entidade na memória primeiro (EF Core 7+)
await context.Products
    .Where(p => p.Id == id)
    .ExecuteDeleteAsync();

Dapper

const string sql = "DELETE FROM Products WHERE Id = @Id";
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Id = id });

Consulta com JOIN — Produtos com Nome da Categoria

EF Core

var results = await context.Products
    .AsNoTracking()
    .Include(p => p.Category)
    .Where(p => p.Price > 10)
    .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
    .ToListAsync();
// EF gera uma única consulta com JOIN quando você projeta com Select

Dapper

const string sql = @"
    SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName
    FROM   Products p
    JOIN   Categories c ON c.Id = p.CategoryId
    WHERE  p.Price > @MinPrice";
 
using var conn = new SqlConnection(connectionString);
var results = await conn.QueryAsync<ProductDto>(sql, new { MinPrice = 10m });

Multi-Mapping (Dividir Resultado em Dois Tipos)

O parâmetro splitOn do Dapper mapeia uma linha de resultado para múltiplos objetos:

const string sql = @"
    SELECT p.Id, p.Name, p.Price, p.CategoryId,
           c.Id, c.Name
    FROM   Products p
    JOIN   Categories c ON c.Id = p.CategoryId";
 
using var conn = new SqlConnection(connectionString);
 
var products = await conn.QueryAsync<Product, Category, Product>(
    sql,
    // splitOn diz ao Dapper onde as colunas de Category começam
    (product, category) =>
    {
        product.Category = category;
        return product;
    },
    splitOn: "Id"   // a segunda coluna "Id" aciona a divisão
);

EF Core lida com isso automaticamente quando você usa Include ou projeta com Select.

Stored Procedure

EF Core

// FromSqlRaw mapeia o resultado de um stored procedure para um tipo de entidade
var products = await context.Products
    .FromSqlRaw("EXEC GetProductsByCategory @CategoryId = {0}", categoryId)
    .AsNoTracking()
    .ToListAsync();

Dapper

using var conn = new SqlConnection(connectionString);
 
var products = await conn.QueryAsync<Product>(
    "GetProductsByCategory",
    new { CategoryId = categoryId },
    commandType: CommandType.StoredProcedure
);

Segurança em Consultas Parametrizadas

Ambas as bibliotecas parametrizam por padrão, prevenindo SQL injection. Nunca interpole entrada do usuário diretamente em strings SQL.

// SEGURO — Dapper usa consultas parametrizadas para todas as propriedades do objeto anônimo
var results = await conn.QueryAsync<Product>(
    "SELECT * FROM Products WHERE Name LIKE @Search",
    new { Search = $"%{userInput}%" }
);
 
// INSEGURO — nunca faça isso
var results = await conn.QueryAsync<Product>(
    $"SELECT * FROM Products WHERE Name LIKE '%{userInput}%'"
);

Aqui está o sample verificando toda esta seção em uma única execução — o SQL gerado pelo EF impresso ao lado do equivalente escrito à mão, e cada afirmação de equivalência comprovada contra o banco em vez de afirmada em prosa:

Saída de console do sample: o SELECT gerado pelo EF Core ao lado do SQL do Dapper escrito à mão, ambos retornando 1.000 linhas com checksums coincidentes; a projeção JOIN retornando 500 DTOs iguais elemento por elemento de ambas as bibliotecas; o change tracking emitindo exatamente uma instrução; e a transação compartilhada EF mais Dapper revertida atomicamente. Todas as verificações passaram.
samples/ef-core-vs-dapper: cada afirmação deste artigo é uma asserção, não uma frase — a execução falha se EF Core e Dapper deixarem de concordar.

Benchmarks de Desempenho

Os números abaixo foram medidos com BenchmarkDotNet, não citados do folclore. A suíte é EfCoreVsDapperBenchmark — execute-a você mesmo contra o banco semeado do sample (1.000 produtos, exatamente 500 com preço acima de 10 para o cenário JOIN). Cada método paga sua própria configuração realista: os benchmarks de EF constroem um DbContext por invocação como uma requisição web faria, Dapper e ADO.NET abrem uma SqlConnection do pool por invocação, e todas as leituras materializam uma List.

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.9106)
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.303 — .NET 10.0.11, X64 RyuJIT AVX-512
SQL Server LocalDB, pool de conexões aquecido
CenárioMétodoMédiaAlocadovs base
SELECT 1.000 linhasEF Core (tracking)2.210,5 µs1.079,7 KB1,00×
EF Core AsNoTracking1.309,6 µs427,4 KB0,60×
EF Core compiled query1.673,2 µs415,6 KB0,76×
Dapper793,9 µs201,5 KB0,36×
Raw ADO.NET779,7 µs163,8 KB0,36×
Busca por PK (1 linha)EF Core FirstOrDefaultAsync370,4 µs74,7 KB1,00×
EF Core compiled query313,2 µs70,3 KB0,87×
Dapper151,9 µs6,8 KB0,42×
Raw ADO.NET229,6 µs6,0 KB0,64×
Projeção JOIN, 500 linhasEF Core projeção Select1.451,8 µs283,8 KB1,00×
Dapper1.125,0 µs116,6 KB0,78×
INSERT individualEF Core Add + SaveChanges2.516,2 µs138,9 KB1,00×
Dapper1.409,7 µs8,6 KB0,58×
Tabela de resultados do BenchmarkDotNet para EF Core versus Dapper versus ADO.NET puro no .NET 10 contra SQL Server LocalDB: Dapper lê 1.000 linhas em 794 microssegundos contra 2.211 do EF Core com tracking, EF Core AsNoTracking fica em 1.310, e a diferença da projeção JOIN se reduz a 1.452 contra 1.125.
A execução em si, com cabeçalho de ambiente incluído. Relatório completo commitado sob BenchmarkDotNet.Artifacts no repositório de samples.

Quatro coisas que essas medições realmente dizem — duas delas contradizem as proporções que uma versão anterior deste artigo citava (eram aproximadas e não reproduzíveis; já não estão):

AsNoTracking é a vitória mais barata da tabela. A diferença do folclore — "Dapper é 2× o EF" — só existe contra o EF Core com tracking (2,8× aqui, e 5× a alocação de memória). Adicionar uma chamada de método a reduz a 1,6× e corta a alocação em 60%. Se uma leitura nunca precisa de SaveChanges, essa chamada já deveria estar lá.

As projeções convergem. No JOIN para um DTO, o Dapper é só 1,3× mais rápido. Quando o EF Core projeta com Select em vez de materializar entidades com tracking, as duas bibliotecas fazem quase o mesmo trabalho — o instinto de "reescreva em Dapper" rende muito menos em consultas EF bem escritas do que nas descuidadas.

Compiled queries são situacionais, não mágica. No SELECT de 1.000 linhas a compiled query rendeu pior que um simples AsNoTracking na média (com muito mais variância) — o custo de tradução se amortiza quando a materialização domina. Ela se pagou na busca por PK (313 vs 370 µs), que é exatamente o formato de caminho crítico para o qual o recurso existe.

Dapper venceu o ADO.NET puro na busca por PK (152 vs 230 µs). Nessa escala você está medindo a maquinaria de comandos em cache, não a sobrecarga de mapeamento — trate Dapper e ADO.NET escrito à mão como o mesmo piso de desempenho e escolha Dapper pela legibilidade.

Duas notas de honestidade. Esses são tempos de LocalDB: os round trips são quase de graça, o que maximiza a sobrecarga visível do mapper — contra um banco remoto, a latência de rede domina e cada proporção acima se comprime em direção a 1. E os números de inserção em massa saíram desta tabela porque um benchmark em massa defensável (TVP vs AddRange vs SqlBulkCopy) precisa de metodologia própria; o padrão TVP abaixo continua sendo a ferramenta certa, só que sem um multiplicador inventado anexado.

Compiled Queries no EF Core

Pré-compile uma expressão de consulta para pular a sobrecarga de tradução LINQ em cada chamada:

// Definir uma vez no nível de classe ou estático — a compilação ocorre apenas uma vez
private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdQuery =
    EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
        ctx.Products
           .AsNoTracking()
           .FirstOrDefault(p => p.Id == id));
 
// Usar em qualquer lugar — sem tradução LINQ→SQL em chamadas subsequentes
var product = await GetByIdQuery(context, 42);
💡

Use compiled queries para caminhos críticos que retornam resultados pequenos — o formato de busca por PK, onde mediram 15% mais rápido acima. Em consultas que materializam centenas de linhas o custo de tradução já é ruído perto da materialização, e o benefício medido desaparece. A primeira chamada ainda paga o custo de compilação; as subsequentes o ignoram.

Dapper com TVP para Operações em Massa

// Parâmetro de valor de tabela para inserções em massa — muito mais rápido que linha por linha
var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
table.Columns.Add("CategoryId", typeof(int));
 
foreach (var p in products)
    table.Rows.Add(p.Name, p.Price, p.CategoryId);
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(
    "INSERT INTO Products SELECT Name, Price, CategoryId FROM @Products",
    new { Products = table.AsTableValuedParameter("dbo.ProductType") }
);

O EF Core não tem uma API própria de inserção em massa — ExecuteUpdate/ExecuteDelete (EF Core 7+) cobrem atualizações e exclusões baseadas em conjuntos, mas as inserções passam pelo batching do SaveChanges. Para volumes realmente grandes, desça para SqlBulkCopy ou um pacote de terceiros como EFCore.BulkExtensions.

Pontos Fortes do EF Core

Migrações

EF Core gerencia o ciclo de vida do seu esquema. dotnet ef migrations add gera uma classe de migração C# a partir do diff do seu modelo; dotnet ef database update a aplica.

dotnet ef migrations add AddProductDiscountColumn
dotnet ef database update

Você obtém um histórico completo de migrações no controle de versões, suporte a rollback e seeding através de HasData.

Rastreamento de Mudanças

O mapa de identidade do EF Core significa que você pode carregar uma entidade, mutá-la no código da aplicação e chamar SaveChanges — o ORM emite um UPDATE direcionado apenas para as colunas modificadas.

// EF rastreia o snapshot original e compara no SaveChanges
var product = await context.Products.FindAsync(id);
product!.Price = newPrice;          // apenas Price mudou
product.Name = newName;             // Name também mudou
await context.SaveChangesAsync();
// SQL gerado: UPDATE Products SET Price=@p0, Name=@p1 WHERE Id=@p2

Esse comentário sobre o SQL gerado é verificável, então o sample o verifica. Mude uma propriedade e registre o que realmente executa:

SQL registrado pelo EF Core após mudar apenas a propriedade Price: um SELECT TOP(1) para carregar o produto, depois UPDATE Products SET Price = @p0 sem nenhuma outra coluna na cláusula SET, seguido das instruções da transação compartilhada da seção final do sample.
O sample executado com --sql: modifique uma propriedade e o SaveChanges emite um único UPDATE cuja cláusula SET nomeia apenas [Price].

Composição LINQ

Construa consultas dinamicamente sem manipulação de strings:

IQueryable<Product> query = context.Products.AsNoTracking();
 
if (minPrice.HasValue)
    query = query.Where(p => p.Price >= minPrice.Value);
 
if (!string.IsNullOrEmpty(category))
    query = query.Where(p => p.Category!.Name == category);
 
if (inStockOnly)
    query = query.Where(p => p.Stock > 0);
 
// O SQL só é gerado aqui, combinando todos os filtros em uma única cláusula WHERE
var results = await query.ToListAsync();

Carregamento de Relacionamentos

// Eager loading — uma única consulta com JOIN
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .ToListAsync();
 
// Explicit loading — carregar navegação depois
await context.Entry(order).Collection(o => o.Lines).LoadAsync();
 
// Split query — SQL separado por Include, evita explosão cartesiana
var orders = await context.Orders
    .Include(o => o.Lines)
    .AsSplitQuery()
    .ToListAsync();
⚠️

Nunca use lazy loading em Web APIs. Ele dispara uma nova ida e volta ao banco de dados para cada acesso a propriedade de navegação, multiplicando silenciosamente a contagem de consultas a cada requisição HTTP.

Independência de Banco de Dados

Troque de provedor alterando a chamada UseXxx no registro DI:

// SQL Server
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(connectionString));
 
// PostgreSQL
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(connectionString));
 
// SQLite (testes / embarcado)
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlite("Data Source=app.db"));

Pontos Fortes do Dapper

Controle Total do SQL

Você escreve exatamente o SQL que é executado. Isso importa para:

  • Consultas usando recursos específicos do banco de dados (CTEs, funções de janela, operadores JSON)
  • Consultas de relatórios onde planos de execução finamente ajustados são críticos
  • Stored procedures com parâmetros de saída complexos
// Função de janela — difícil de expressar em LINQ
const string sql = @"
    SELECT
        p.Id,
        p.Name,
        p.Price,
        RANK() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank
    FROM Products p
    WHERE p.Price > @MinPrice";
 
var ranked = await conn.QueryAsync<ProductRankDto>(sql, new { MinPrice = 0m });

Teto de Desempenho

Quando o throughput é a restrição — leituras de alta frequência, endpoints de relatórios servindo milhares de usuários simultâneos — a sobrecarga mínima do Dapper permite extrair cada milissegundo.

Parâmetros de Saída de Stored Procedures

var parameters = new DynamicParameters();
parameters.Add("@CustomerId", customerId);
parameters.Add("@TotalSpend", dbType: DbType.Decimal, direction: ParameterDirection.Output);
parameters.Add("@OrderCount", dbType: DbType.Int32, direction: ParameterDirection.Output);
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync("GetCustomerStats", parameters,
    commandType: CommandType.StoredProcedure);
 
decimal totalSpend = parameters.Get<decimal>("@TotalSpend");
int orderCount = parameters.Get<int>("@OrderCount");

O FromSqlRaw do EF Core não suporta parâmetros de saída diretamente — você precisaria descer para DbCommand manualmente.

Modelo Mental Mais Simples

O Dapper tem um README de três páginas. Não há convenções para aprender, sem shadow properties, sem estados de entidade, sem classes proxy. Um desenvolvedor que conhece SQL pode ser produtivo em minutos.

Usando Ambos no Mesmo Projeto

EF Core e Dapper não são mutuamente exclusivos. Um padrão híbrido comum usa EF Core para escritas e gerenciamento de relacionamentos, e Dapper para consultas de leitura complexas e relatórios.

Registro no DI

// Registrar ambos — DbContext para escritas, fábrica IDbConnection para leituras
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
// Registrar uma fábrica para que repositórios possam abrir conexões Dapper sob demanda
builder.Services.AddScoped<IDbConnection>(_ =>
    new SqlConnection(builder.Configuration.GetConnectionString("Default")));

Padrão Repository — Híbrido

public class ProductRepository
{
    private readonly AppDbContext _context;
    private readonly IDbConnection _db;
 
    public ProductRepository(AppDbContext context, IDbConnection db)
    {
        _context = context;
        _db = db;
    }
 
    // Escritas passam pelo EF Core — rastreamento de mudanças, validação, eventos
    public async Task<Product> CreateAsync(CreateProductRequest request)
    {
        var product = new Product
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        return product;
    }
 
    // Consulta de leitura complexa passa pelo Dapper — SQL puro, sem sobrecarga
    public async Task<IEnumerable<ProductSalesDto>> GetTopSellersByCategoryAsync(
        int categoryId, int top)
    {
        const string sql = @"
            SELECT TOP (@Top)
                p.Id,
                p.Name,
                p.Price,
                SUM(ol.Quantity) AS TotalSold,
                SUM(ol.Quantity * p.Price) AS Revenue
            FROM   Products p
            JOIN   OrderLines ol ON ol.ProductId = p.Id
            JOIN   Orders o ON o.Id = ol.OrderId
            WHERE  p.CategoryId = @CategoryId
              AND  o.PlacedAt >= DATEADD(month, -3, GETUTCDATE())
            GROUP  BY p.Id, p.Name, p.Price
            ORDER  BY TotalSold DESC";
 
        return await _db.QueryAsync<ProductSalesDto>(sql, new { CategoryId = categoryId, Top = top });
    }
 
    // Buscas simples ainda usam EF Core com compiled query
    private static readonly Func<AppDbContext, int, Task<Product?>> FindQuery =
        EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
            ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
 
    public Task<Product?> FindByIdAsync(int id) => FindQuery(_context, id);
}

Compartilhando Transações

Quando você precisa que EF Core e Dapper participem da mesma transação:

using var transaction = await _context.Database.BeginTransactionAsync();
 
try
{
    // Escrita do EF Core
    _context.Orders.Add(newOrder);
    await _context.SaveChangesAsync();
 
    // Operação Dapper usando a mesma conexão e transação subjacentes
    var conn = _context.Database.GetDbConnection();
    await conn.ExecuteAsync(
        "UPDATE Inventory SET Reserved = Reserved + @Qty WHERE ProductId = @ProductId",
        new { Qty = newOrder.Quantity, ProductId = newOrder.ProductId },
        transaction: _context.Database.CurrentTransaction!.GetDbTransaction()
    );
 
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}
💡

Ao compartilhar uma transação, use _context.Database.GetDbConnection() em vez de abrir um novo IDbConnection. Isso garante que o Dapper opere na exata mesma conexão à qual a transação do EF Core está vinculada.

Esse padrão é a quarta verificação do sample executável: uma inserção do EF Core e uma atualização do Dapper dentro de uma mesma transação, ambas visíveis antes do rollback, ambas desaparecidas depois — uma transação genuinamente governando as duas bibliotecas, verificado com asserções em vez de assumido.

Quando Escolher EF Core

  • Aplicações greenfield onde você possui o esquema e quer migrações no controle de versões
  • Modelos de domínio ricos com relacionamentos complexos, lógica de validação e eventos de domínio
  • Equipes com expertise SQL misto — LINQ reduz a barreira para acesso a dados correto e seguro
  • Iteração rápida — adicionar uma coluna é uma alteração de uma linha no modelo mais uma migração
  • Múltiplos alvos de banco de dados — um codebase, múltiplos provedores
  • Testabilidade unitáriaUseInMemoryDatabase ou SQLite em memória para testes rápidos sem BD real

Quando Escolher Dapper

  • Caminhos de leitura críticos para desempenho — dashboards de relatórios, endpoints de API de alta frequência
  • Banco de dados existente com esquema que você não controla
  • Grande investimento em SQL — equipe já possui stored procedures, views e consultas otimizadas
  • Consultas analíticas complexas — funções de janela, CTEs, consultas recursivas, JSON shredding
  • Microsserviços com acesso a dados simples — sem necessidade de um ORM completo
  • Read replicas / lado de leitura CQRS — Dapper é uma escolha natural para o lado de consultas do CQRS

Matriz de Decisão

CritérioEF CoreDapperHíbrido
Migrações de esquemaNativasManual / FlywayEF Core gerencia o esquema
Rastreamento de mudançasSimNãoEF Core para escritas
Composição LINQSimNãoEF Core para escritas
Controle SQL puroLimitadoTotalDapper para leituras
Suporte a stored proceduresParcialTotalDapper
Desempenho (leituras)Bom (AsNoTracking fecha quase toda a diferença)ExcelenteExcelente
Desempenho (escritas)BomBomBom
Operações em massaVia extensõesTVP nativoDapper
Curva de aprendizadoMédia-AltaBaixaMédia
Suporte multi-BDSimManualMisto
Test doublesInMemory / SQLitePrecisa de BD real ou mockPrecisa de BD real ou mock
Expertise SQL necessárioBaixoAltoMédio
Melhor paraGreenfield, apps de domínioLeituras críticas, BD existenteApps grandes com cargas mistas

Resumo

EF Core e Dapper resolvem problemas diferentes. EF Core é o padrão correto para novas aplicações — gerencia migrações, mantém seu esquema e código sincronizados, e permite que desenvolvedores com menos experiência SQL escrevam consultas seguras e corretas. Dapper merece seu lugar em caminhos de leitura críticos para desempenho e quando sua equipe possui SQL complexo que um ORM apenas obscureceria.

Para a maioria das aplicações médias a grandes, a resposta é ambos: EF Core para operações do lado de comando e leituras simples, Dapper para relatórios e endpoints de consulta de alta frequência. O padrão de transação compartilhada mostrado acima significa que você nunca precisa escolher entre correção e desempenho.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados