EF Core + Dapper: padrão híbrido (Read/Write) para consultas de alta performance
Por John Pires · 10 de março de 2026

EF Core + Dapper: pattern híbrido (Read/Write) para consultas de alta performance
Motivacao e trade-offs
Por que misturar ORMs?
Aplicações reais precisam de consistência e produtividade nas operações de escrita e resposta rápida e eficiente nas consultas de leitura. EF Core oferece mapeamento robusto, change tracking e migrações — ideal para writes. Dapper é micro-ORM orientado a alta performance em leitura. O padrão híbrido Read/Write usa EF Core para writes (insere/atualiza/exclui) e Dapper para reads (consultas complexas/relatórios), reduzindo latência sem perder a produtividade das alterações.
Essa combinação não é mágica: existe custo operacional (conhecimento de ambos, integração, manutenção de SQL hand-written). Mas o ganho em throughput e controle de queries compensa em cenários de alta leitura ou reporting.
Trade-offs práticos
Antes de adotar, entenda os trade-offs:
- Consistência: Writes usando EF Core continuam dentro de seu contexto; Dapper lê diretamente do banco. Eventual consistency pode ocorrer se há replicação ou caching entre camadas.
- Complexidade: Duas formas de acessar dados; exige padrões claros (ex.: repositórios separados para read/write).
- Manutenção: SQL escrito manualmente precisa ser testado e versionado.
Em resumo: use o híbrido quando consultas críticas impactam latência e EF Core não entrega performance aceitável.
Fundamentos tecnicos
Como integrar EF Core com Dapper
O ponto central é reusar a conexão do DbContext para Dapper, evitando criar conexões separadas. Use DbContext.Database.GetDbConnection() e, quando necessário, a transação via DbContext.Database.CurrentTransaction ou BeginTransactionAsync.
Exemplo de fluxo: abrir a conexão, executar QueryAsync do Dapper, mapear DTOs, fechar (ou não) a conexão. Evite Keep-Open desnecessário; o DbContext gerencia conexões, mas você abre explicitamente para Dapper.
Padrões organizacionais: Read Repository vs Write Repository
Separe responsabilidades via interfaces: IWriteRepository usando EF Core e IReadRepository usando Dapper/SQL. Isso deixa claro onde escrever SQL manual. Também facilita testes e troca de implementação.
Exemplo de responsabilidades:
- IWriteRepository: Add, Update, Delete, SaveChangesAsync (ef core)
- IReadRepository: QueryListAsync, QuerySingleAsync retornando DTOs (dapper)
Exemplo 1: caso simples
Contexto: entidade Order e consulta de resumo
Objetivo: inserir/atualizar pedidos com EF Core e ler um resumo por cliente com Dapper em uma API minimal. O resumo agrega totais e conta de pedidos — query adequada para Dapper.
Estrutura simplificada: Order entity, OrderDto (read), AppDbContext, repositories e endpoints.
Code: DbContext, entidade e DTO
O bloco abaixo define a entidade, o DbContext e o DTO usado para leitura. Serve como base para os próximos trechos.
// AppModels.cs
using System;
using Microsoft.EntityFrameworkCore;
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
}
public class OrderSummaryDto
{
public int CustomerId { get; set; }
public int OrderCount { get; set; }
public decimal TotalSpent { get; set; }
}
public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
O que faz: define a entidade Order e o DTO OrderSummaryDto. O DbContext contém o DbSet para EF Core.
O que acontece: esses tipos são usados tanto para persistência (EF Core) quanto para projeção (Dapper).
Code: repositório híbrido minimal
Aqui separamos IWriteRepository e IReadRepository. IWriteRepository usa EF Core; IReadRepository usa Dapper com a conexão do DbContext.
// Repositories.cs
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
using Dapper;
using Microsoft.EntityFrameworkCore;
public interface IWriteRepository
{
Task AddOrderAsync(Order order);
Task SaveChangesAsync();
}
public interface IReadRepository
{
Task<IEnumerable<OrderSummaryDto>> GetOrderSummaryByCustomerAsync(int customerId);
}
public class EfWriteRepository : IWriteRepository
{
private readonly AppDbContext _db;
public EfWriteRepository(AppDbContext db) => _db = db;
public async Task AddOrderAsync(Order order)
{
await _db.Orders.AddAsync(order);
}
public async Task SaveChangesAsync()
{
await _db.SaveChangesAsync();
}
}
public class DapperReadRepository : IReadRepository
{
private readonly AppDbContext _db;
public DapperReadRepository(AppDbContext db) => _db = db;
public async Task<IEnumerable<OrderSummaryDto>> GetOrderSummaryByCustomerAsync(int customerId)
{
var sql = @"
SELECT CustomerId,
COUNT(*) AS OrderCount,
SUM(Total) AS TotalSpent
FROM Orders
WHERE CustomerId = @customerId
GROUP BY CustomerId";
var conn = _db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync();
return await conn.QueryAsync<OrderSummaryDto>(sql, new { customerId });
}
}
O que faz: implementa duas responsabilidades. O write bufferiza via EF Core e o read usa Dapper para executar uma agregação eficiente.
O que acontece: a leitura evita tracking, mapeia direto para DTO e reduz overhead.
Code: endpoints minimal API
Exemplo de endpoints em Program.cs para criar pedido (EF) e ler resumo (Dapper).
// Program.cs (trecho)
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IWriteRepository, EfWriteRepository>();
builder.Services.AddScoped<IReadRepository, DapperReadRepository>();
var app = builder.Build();
app.MapPost("/orders", async (OrderDto dto, IWriteRepository wr) =>
{
var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total, CreatedAt = DateTime.UtcNow };
await wr.AddOrderAsync(order);
await wr.SaveChangesAsync();
return Results.Created($"/orders/{order.Id}", order.Id);
});
app.MapGet("/customers/{id}/summary", async (int id, IReadRepository rr) =>
await rr.GetOrderSummaryByCustomerAsync(id));
app.Run();
O que faz: insere e lê via endpoints. A leitura retorna DTO sem tracking.
O que acontece: endpoint de leitura é rápido porque Dapper executa query agregada direta no banco.
Exemplo 2: caso avancado do mundo real
Contexto: relatórios com joins, paginação e filtros dinâmicos
Situação comum: relatórios que juntam várias tabelas, precisam de paginação e filtros opcionais. EF Core pode gerar SQL ineficiente se você tentar traduzir consultas LINQ complexas com projeções customizadas. Dapper permite escrever SQL ajustado, usar window functions e índices adequados.
Vamos construir um endpoint de pesquisa de pedidos com filtros, paginação e leitura por Dapper em produção usando parâmetros dinâmicos e segurança contra SQL injection.
Code: query dinâmica otimizada com Dapper
Este bloco constrói a SQL dinamicamente com parâmetros, executa QueryAsync e devolve página e total em uma query eficiente usando COUNT() OVER() ou duas queries dependendo do cenário.
// AdvancedQuery.cs
using System.Text;
using Dapper;
using Microsoft.Data.SqlClient;
using System.Threading.Tasks;
using System.Collections.Generic;
public class OrderSearchParams { public int? CustomerId { get; set; } public DateTime? From { get; set; } public DateTime? To { get; set; } public int Page { get; set; } = 1; public int PageSize { get; set; } = 20; }
public class PagedResult<T> { public IEnumerable<T> Items { get; set; } public int Total { get; set; } }
public class DapperAdvancedRepository
{
private readonly AppDbContext _db;
public DapperAdvancedRepository(AppDbContext db) => _db = db;
public async Task<PagedResult<Order>> SearchOrdersAsync(OrderSearchParams p)
{
var sb = new StringBuilder();
sb.AppendLine("SELECT *, COUNT(1) OVER() AS TotalCount FROM Orders WHERE 1=1");
var parameters = new DynamicParameters();
if (p.CustomerId.HasValue) { sb.AppendLine("AND CustomerId = @CustomerId"); parameters.Add("CustomerId", p.CustomerId.Value); }
if (p.From.HasValue) { sb.AppendLine("AND CreatedAt >= @From"); parameters.Add("From", p.From.Value); }
if (p.To.HasValue) { sb.AppendLine("AND CreatedAt <= @To"); parameters.Add("To", p.To.Value); }
sb.AppendLine("ORDER BY CreatedAt DESC");
sb.AppendLine("OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
parameters.Add("Offset", (p.Page - 1) * p.PageSize);
parameters.Add("PageSize", p.PageSize);
var conn = _db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync();
var rows = await conn.QueryAsync<OrderWithTotal>(sb.ToString(), parameters);
var list = rows.AsList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
return new PagedResult<Order> { Items = list.Select(r => new Order { Id = r.Id, CustomerId = r.CustomerId, Total = r.Total, CreatedAt = r.CreatedAt }), Total = total };
}
private class OrderWithTotal : Order { public int TotalCount { get; set; } }
}
O que faz: constrói uma query parametrizada, usa COUNT() OVER() para obter o total na mesma leitura e aplica paginação eficiente com OFFSET/FETCH.
O que acontece: reduz roundtrips e evita sincronização extra para obter total separado. Mapeia para tipo anônimo interno para recuperar TotalCount.
Integrando transações entre EF Core e Dapper
Quando writes e reads precisam acontecer na mesma transação (ex.: criar registro e ler estado resultante para resposta), é crucial passar a mesma transação para Dapper. Abaixo, um exemplo: cria order via EF Core, usa Dapper para consulta custom e tudo dentro de uma transação.
// TransactionExample.cs
using System.Data;
using Dapper;
public async Task<CreateAndQueryResult> CreateAndQueryAsync(AppDbContext db, Order order)
{
await using var tran = await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
await db.Orders.AddAsync(order);
await db.SaveChangesAsync();
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync();
var sql = "SELECT CustomerId, COUNT(*) AS OrderCount, SUM(Total) AS TotalSpent FROM Orders WHERE CustomerId = @cid GROUP BY CustomerId";
var dto = await conn.QueryFirstOrDefaultAsync<OrderSummaryDto>(sql, new { cid = order.CustomerId }, tran.GetDbTransaction());
await tran.CommitAsync();
return new CreateAndQueryResult { OrderId = order.Id, Summary = dto };
}
catch
{
await tran.RollbackAsync();
throw;
}
}
O que faz: garante atomicidade entre EF Core e Dapper usando a mesma transação subjacente.
O que acontece: previne leitura de estados parcialmente persistidos e mantém integridade da ação composta.
Debugging e troubleshooting
Erros comuns e armadilhas
Listagem dos problemas mais frequentes ao adotar o padrão híbrido:
- Conexão não aberta: Dapper requer conexão aberta quando executa; verifique conn.State e abra quando necessário.
- Transação inconsistência: não passar tran.GetDbTransaction() para Dapper quebra atomicidade.
- Mapeamento de colunas: nomes diferentes entre SQL e DTO requerem aliases ou mapeamento manual.
- Tracking surprise: ler via EF Core com AsNoTracking quando estiver fazendo leitura; porém preferir Dapper elimina tracking.
- SQL injection: concatenar filtros sem parâmetros é perigoso — use DynamicParameters e placeholders (@param).
Performance pitfall: deixar EF Core gerar joins desnecessários ou retornar entidades grandes para consultas onde só precisa de 2 campos. Use projeções ou Dapper para ler apenas o necessário.
Dicas práticas de diagnóstico
Para identificar gargalos e mismatches, adote as seguintes práticas:
- Habilite logging SQL do EF Core para comparar queries geradas vs queries Dapper.
- Use Query Store (SQL Server) ou extensão de monitoramento do banco para identificação de queries lentas.
- Adicione indexes com base nos planos de execução; Dapper não cancela a necessidade de indexes bem desenhados.
Tip: em ambientes de teste, capture o SQL de EF Core e execute no banco com SET STATISTICS IO, TIME ON para comparar custo real entre LINQ gerado e SQL manual do Dapper.
Conclusao pratica
Resumo de ações recomendadas
Adotar EF Core + Dapper em padrão Read/Write traz ganho significativo quando há consultas críticas de leitura que exigem performance ou controle fino do SQL. As recomendações práticas:
- Separe interfaces de leitura e escrita: IWriteRepository com EF Core, IReadRepository com Dapper.
- Reutilize a conexão do DbContext para Dapper (Database.GetDbConnection()).
- Quando necessário, compartilhe transação com tran.GetDbTransaction().
- Escreva SQL parametrizado e teste planos de execução; prefira COUNT() OVER() para páginas quando útil.
Checklist antes de adotar em produção
Antes de migrar para o híbrido, valide estes pontos:
- Monitoramento de queries e alertas de regressão.
- Testes de integração que cobrem cenários de transação cruzada (EF + Dapper).
- Documentação clara de quem escreve SQL e padrões de revisão de queries.
Feito corretamente, o pattern híbrido entrega o melhor dos dois mundos: produtividade para writes e performance para reads. O artigo mostrou implementações concretas, controle de transação e exemplos práticos prontos para adaptação em sistemas reais.
Implementações fornecidas são pedaços integrais e funcionais; adapte connection strings, DI e migrations conforme seu ambiente. A estrutura proposta (repositórios separados, reuse da conexão, e transações compartilhadas) é o núcleo para escalar com segurança.
Boa prática final: registre benchmarks antes e depois da migração de queries críticas; nem sempre escrever SQL manual compensa se a consulta LINQ já é ótima. Mas quando for preciso, Dapper é a ferramenta pragmática para recuperar performance com baixo overhead.
Assuntos: C#, ef-core, dapper, Performance
Leia também
Importação massiva de CSV com Npgsql COPY em Minimal APIs e EF Core
Como projetar importação massiva de CSV com Npgsql COPY em Minimal APIs + EF Core: estratégias, exemplos práticos, staging, upsert e debugging para pipelines…
JWT em Minimal APIs (ASP.NET Core): access e refresh tokens do zero
Aprenda do zero como implementar access e refresh tokens com JWT em Minimal APIs do ASP.NET Core: código real, armazenamento de refresh token, endpoints…
Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo
Guia passo a passo para usar Minimal APIs + SSE e enviar progresso de jobs para clientes Blazor. Do setup ao código funcional: servidor com endpoint SSE…
Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database
Comparação prática entre discriminator, schema e database para multitenancy em ASP.NET Core com EF Core. Implementação de TenantProvider, DbContext dinâmico…
Soft delete no EF Core: filtros globais, auditoria e restauração
Soft delete com EF Core evita perda de dados, permite auditoria e restauração. Aprenda o jeito errado (filtros manuais) e o jeito certo (global query filters…
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…