Destino Dev

Blog

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:

  1. IWriteRepository: Add, Update, Delete, SaveChangesAsync (ef core)
  2. 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:

  1. Habilite logging SQL do EF Core para comparar queries geradas vs queries Dapper.
  2. Use Query Store (SQL Server) ou extensão de monitoramento do banco para identificação de queries lentas.
  3. 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:

  1. Monitoramento de queries e alertas de regressão.
  2. Testes de integração que cobrem cenários de transação cruzada (EF + Dapper).
  3. 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