.NET Banco de Dados Entity Framework Core Dapper Performance SQL

Dapper vs EF Core: quando usar cada um (e como combiná-los no mesmo projeto)

Comparação técnica definitiva entre Dapper e Entity Framework Core: performance, produtividade, consultas complexas.

N
Neryx Digital Architects
18 de outubro de 2025
12 min de leitura
230 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

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.

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.