A pergunta "Dapper ou EF Core?" aparece em praticamente todo projeto .NET com banco relacional. A resposta honesta é: depende do caso de uso — e, na maioria dos projetos sérios, você vai usar os dois. Este artigo compara as duas abordagens com critérios técnicos objetivos e mostra como combiná-las de forma elegante.
O que cada um é
EF Core é um ORM (Object-Relational Mapper) completo: gerencia o ciclo de vida de entidades, rastreia mudanças (change tracking), gera SQL automaticamente via LINQ, faz migrations e lida com relacionamentos complexos. É a ferramenta certa para operações de escrita (CRUD).
Dapper é um micro-ORM: você escreve o SQL, ele mapeia o resultado para objetos. Nada de change tracking, nada de migrations, nada de geração automática de SQL. É simplesmente o ADO.NET com mapeamento conveniente — e é por isso que é rápido.
Performance: os números reais
Os benchmarks do BenchmarkDotNet para busca de 1.000 registros simples:
| Abordagem | Tempo médio | Alocação |
|---|---|---|
| ADO.NET puro | ~1.0ms | Mínima |
| Dapper | ~1.2ms | Baixa |
| EF Core (AsNoTracking) | ~1.8ms | Moderada |
| EF Core (com tracking) | ~2.5ms | Alta |
| EF Core (com Include N+1) | ~15ms+ | Muito alta |
A diferença entre Dapper e EF Core bem configurado (AsNoTracking, sem N+1) é de 40-50% em queries simples. Significativo para hot paths de alta frequência — irrelevante para a maioria dos endpoints de negócio onde o gargalo é a query em si.
EF Core: quando é a escolha certa
Operações de escrita e CRUD
// EF Core brilha aqui: change tracking, cascading, eventos de domínio
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
=> _context = context;
public async Task<Order> CreateAsync(Order order, CancellationToken ct)
{
// EF cuida de: INSERT + relacionamentos + geração de ID
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
return order;
}
public async Task UpdateStatusAsync(Guid orderId, OrderStatus status, CancellationToken ct)
{
// ExecuteUpdateAsync: UPDATE direto sem carregar a entidade
await _context.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, status)
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow),
ct);
}
public async Task<Order?> GetWithItemsAsync(Guid orderId, CancellationToken ct)
{
// Relacionamentos complexos: EF gerencia os JOINs
return await _context.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.Include(o => o.Customer)
.Include(o => o.Payments)
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == orderId, ct);
}
}
Migrations e evolução de schema
# EF Core gerencia a evolução do banco de dados
dotnet ef migrations add AddOrderNotes
dotnet ef migrations bundle --self-contained
# Dapper não tem isso — você gerenciaria migrations manualmente
# (FluentMigrator, DbUp, ou scripts SQL versionados)
Dapper: quando é a escolha certa
Queries de leitura complexas (relatórios, dashboards)
// Dapper brilha aqui: SQL legível, CTEs, window functions, multi-mapping
public class OrderReportRepository : IOrderReportRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public OrderReportRepository(IDbConnectionFactory connectionFactory)
=> _connectionFactory = connectionFactory;
public async Task<IEnumerable<OrderSummaryDto>> GetMonthlySummaryAsync(
int year,
int month,
CancellationToken ct)
{
const string sql = """
WITH monthly_orders AS (
SELECT
DATE_TRUNC('day', o.created_at) AS order_date,
COUNT(*) AS order_count,
SUM(o.total) AS total_revenue,
AVG(o.total) AS avg_order_value,
COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE
EXTRACT(YEAR FROM o.created_at) = @Year
AND EXTRACT(MONTH FROM o.created_at) = @Month
AND o.status NOT IN ('cancelled', 'refunded')
GROUP BY DATE_TRUNC('day', o.created_at)
),
running_totals AS (
SELECT
*,
SUM(total_revenue) OVER (ORDER BY order_date) AS cumulative_revenue,
SUM(order_count) OVER (ORDER BY order_date) AS cumulative_orders
FROM monthly_orders
)
SELECT
order_date AS OrderDate,
order_count AS OrderCount,
total_revenue AS TotalRevenue,
avg_order_value AS AvgOrderValue,
unique_customers AS UniqueCustomers,
cumulative_revenue AS CumulativeRevenue,
cumulative_orders AS CumulativeOrders
FROM running_totals
ORDER BY order_date;
""";
await using var connection = _connectionFactory.CreateConnection();
var results = await connection.QueryAsync<OrderSummaryDto>(
sql,
new { Year = year, Month = month });
return results;
}
// Multi-mapping: query com JOIN mapeada para objetos aninhados
public async Task<IEnumerable<CustomerWithOrdersDto>> GetTopCustomersAsync(
int top,
CancellationToken ct)
{
const string sql = """
SELECT
c.id AS CustomerId,
c.name AS CustomerName,
c.email AS CustomerEmail,
o.id AS OrderId,
o.total AS OrderTotal,
o.created_at AS OrderDate
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
WHERE c.id IN (
SELECT customer_id
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
ORDER BY SUM(total) DESC
LIMIT @Top
)
ORDER BY c.id, o.created_at DESC;
""";
await using var connection = _connectionFactory.CreateConnection();
var customerDict = new Dictionary<Guid, CustomerWithOrdersDto>();
await connection.QueryAsync<CustomerWithOrdersDto, OrderDto, CustomerWithOrdersDto>(
sql,
(customer, order) =>
{
if (!customerDict.TryGetValue(customer.CustomerId, out var existing))
{
existing = customer;
existing.Orders = new List<OrderDto>();
customerDict[customer.CustomerId] = existing;
}
existing.Orders.Add(order);
return existing;
},
new { Top = top },
splitOn: "OrderId");
return customerDict.Values;
}
}
Bulk operations de alta performance
// Importar 100.000 registros — EF Core seria lento e pesado em memória
public async Task BulkImportAsync(IEnumerable<ProductImportDto> products)
{
const string sql = """
INSERT INTO products (id, name, sku, price, category_id, created_at)
VALUES (@Id, @Name, @Sku, @Price, @CategoryId, @CreatedAt)
ON CONFLICT (sku) DO UPDATE SET
name = EXCLUDED.name,
price = EXCLUDED.price,
updated_at = NOW();
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync();
using var transaction = await connection.BeginTransactionAsync();
try
{
// Dapper processa em lotes — muito mais eficiente que EF Core Add() em loop
await connection.ExecuteAsync(sql, products, transaction);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
O padrão híbrido: EF Core para escrita, Dapper para leitura
Este é o padrão recomendado para projetos que seguem CQRS ou que têm tanto operações de domínio quanto relatórios complexos:
// Repositório de escrita — usa EF Core
public class OrderCommandRepository : IOrderCommandRepository
{
private readonly AppDbContext _context;
public OrderCommandRepository(AppDbContext context)
=> _context = context;
public async Task<Order> AddAsync(Order order, CancellationToken ct)
{
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
return order;
}
public async Task<Order?> GetForUpdateAsync(Guid id, CancellationToken ct)
{
// Escrita: com tracking para detectar mudanças
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
}
// Repositório de leitura — usa Dapper
public class OrderQueryRepository : IOrderQueryRepository
{
private readonly IDbConnectionFactory _factory;
public OrderQueryRepository(IDbConnectionFactory factory)
=> _factory = factory;
public async Task<OrderDetailsDto?> GetDetailsByIdAsync(Guid id, CancellationToken ct)
{
const string sql = """
SELECT
o.id, o.status, o.total, o.created_at,
c.id AS customer_id, c.name AS customer_name, c.email,
oi.id AS item_id, oi.quantity, oi.unit_price,
p.id AS product_id, p.name AS product_name, p.sku
FROM orders o
INNER JOIN customers c ON c.id = o.customer_id
INNER JOIN order_items oi ON oi.order_id = o.id
INNER JOIN products p ON p.id = oi.product_id
WHERE o.id = @Id
""";
await using var conn = _factory.CreateConnection();
OrderDetailsDto? order = null;
await conn.QueryAsync<OrderDetailsDto, CustomerDto, OrderItemDto, ProductDto, OrderDetailsDto>(
sql,
(o, c, item, product) =>
{
order ??= o with { Customer = c, Items = [] };
item = item with { Product = product };
order.Items.Add(item);
return order;
},
new { Id = id },
splitOn: "customer_id,item_id,product_id");
return order;
}
}
// Registrar a fábrica de conexão
builder.Services.AddSingleton<IDbConnectionFactory>(sp =>
new NpgsqlConnectionFactory(
builder.Configuration.GetConnectionString("Default")!));
// IDbConnectionFactory — abstração simples
public interface IDbConnectionFactory
{
DbConnection CreateConnection();
}
public class NpgsqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public NpgsqlConnectionFactory(string connectionString)
=> _connectionString = connectionString;
public DbConnection CreateConnection()
=> new NpgsqlConnection(_connectionString);
}
Usando Dapper dentro do DbContext do EF Core
// Alternativa: usar a connection do EF Core para executar SQL com Dapper
// Vantagem: mesma connection/transaction, sem configuração extra
public class ProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
=> _context = context;
// EF Core para escrita
public async Task<Product> CreateAsync(Product product, CancellationToken ct)
{
_context.Products.Add(product);
await _context.SaveChangesAsync(ct);
return product;
}
// Dapper para leitura complexa — usando a mesma connection do EF Core
public async Task<IEnumerable<ProductSearchResult>> FullTextSearchAsync(
string query,
int limit = 20)
{
var connection = _context.Database.GetDbConnection();
const string sql = """
SELECT
p.id, p.name, p.sku, p.price,
ts_rank(to_tsvector('portuguese', p.name || ' ' || p.description),
plainto_tsquery('portuguese', @Query)) AS rank
FROM products p
WHERE to_tsvector('portuguese', p.name || ' ' || p.description)
@@ plainto_tsquery('portuguese', @Query)
ORDER BY rank DESC
LIMIT @Limit;
""";
// Garante que a connection está aberta
if (connection.State != ConnectionState.Open)
await connection.OpenAsync();
return await connection.QueryAsync<ProductSearchResult>(
sql, new { Query = query, Limit = limit });
}
}
Tabela de decisão final
| Operação | Use EF Core | Use Dapper |
|---|---|---|
| INSERT / UPDATE / DELETE | ✅ | Apenas bulk |
| Relacionamentos complexos (Include) | ✅ | Multi-mapping manual |
| Migrations de schema | ✅ | ❌ (usa outro mecanismo) |
| Relatórios com CTEs / window functions | ⚠️ Difícil | ✅ |
| Full-text search nativo do banco | ⚠️ Limitado | ✅ |
| Bulk import (100k+ registros) | ❌ Lento | ✅ |
| Queries simples com filtros dinâmicos | ✅ (LINQ) | ⚠️ SQL dinâmico é verboso |
| Stored procedures legadas | ⚠️ Possível | ✅ Natural |
| Hot path de alta frequência (>10k req/s) | ⚠️ AsNoTracking | ✅ |
A conclusão prática: use EF Core como padrão e adicione Dapper onde você precisa de SQL preciso, consultas complexas ou performance de leitura máxima. Não é uma escolha excludente — é uma escolha complementar.
A estratégia de acesso a dados é uma das decisões de arquitetura mais impactantes em sistemas .NET. Se você quer definir a abordagem certa para o seu projeto, a Neryx pode ajudar com uma consultoria técnica focada.