Dapper: Arquitetura interna, performance e boas práticas

Dapper: Arquitetura interna, performance e boas práticas

Uma visão técnica sobre como Dapper opera com PostgreSQL no .NET, explorando performance, estrutura interna e boas práticas de implementação.

O Dapper é um micro-ORM mantido pela equipe do Stack Overflow, criado para resolver um problema real de produção: executar consultas SQL de forma extremamente performática, reduzindo o custo de mapeamento manual do ADO.NET sem introduzir aquela complexidade que tem um ORM completo.

É importante entender que o Dapper não é uma versão simplificada do Entity Framework, material que ja vimos neste artigo. Ele é uma ferramenta com uma finalidade diferente.

Enquanto o Entity Framework Core tenta abstrair o banco através de:

  • Change tracking
  • Tradução de LINQ
  • Model metadata
  • Pipeline de compilação de queries
  • Geração automática de SQL

O Dapper assume que:

Você quer escrever SQL manualmente e apenas precisa de um materializador rápido e confiável.

Ele opera diretamente sobre IDataReader, sendo essencialmente um mapeador de resultados para objetos POCO(em outras palavras, classes simples com propriedades) com uso intensivo de geração dinâmica de IL(Linguagem intermediária) e cache interno.

Arquitetura e funcionamento interno

Dependência central: IDbConnection

O Dapper não cria conexão, não gerencia pool e não mantém contexto. Ele adiciona extension methods à interface:

System.Data.IDbConnection

Isso significa que:

  • Ele funciona com qualquer provider ADO.NET
  • Ele delega o gerenciamento de conexão ao driver
  • Ele não impõe modelo arquitetural

Essa decisão reduz acoplamento e preserva performance.

Fluxo interno de execução

Quando executamos:

connection.Query<User>("SELECT id, name FROM users");

Internamente ocorre:

  1. Criação de IDbCommand
  2. Binding de parâmetros
  3. Execução de ExecuteReader
  4. Leitura via IDataReader
  5. Geração dinâmica de método de materialização
  6. Cache do delegate gerado
  7. Iteração e retorno da coleção

O ponto crítico está na etapa 5.

Geração dinâmica via IL

O Dapper usa DynamicMethod e ILGenerator para construir um método em tempo de execução equivalente a:

var user = new User();
user.Id = reader.GetGuid(0);
user.Name = reader.GetString(1);
return user;

Esse delegate é compilado uma única vez e armazenado em cache.

Resultado:

  • Primeira execução: pequeno overhead
  • Execuções subsequentes: desempenho próximo ao ADO.NET puro

Isso elimina o custo repetitivo de um reflection tradicional.

Integração com PostgreSQL

Para nosso exemplo didático iremos utilizar o Dapper com PostgreSQL, para isso, utilizaremos o provider oficial Npgsql que é o driver ADO.NET oficial para PostgreSQL.

Ele fornece:

  • Pooling de conexão
  • Async real
  • Suporte a UUID
  • Suporte a JSONB
  • Arrays nativos
  • Prepared statements

Instalação

dotnet add package Dapper
dotnet add package Npgsql

Connection String

{
  "ConnectionStrings": {
    "Default": "Host=localhost;Port=5432;Database=appdb;Username=postgres;Password=123456;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=100"
  }
}

Para o exemplo atual o pool e controlado exclusivamente pelo Npgsql.

Implementação com Minimal API + PostgreSQL

Program.cs

using Dapper;
using Npgsql;
using System.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IDbConnection>(sp =>
{
    var configuration = sp.GetRequiredService<IConfiguration>();
    var connectionString = configuration.GetConnectionString("Default");

    return new NpgsqlConnection(connectionString);
});

builder.Services.AddScoped<UserRepository>();

var app = builder.Build();

app.MapGet("/users/{id:guid}", async (Guid id, UserRepository repo) =>
{
    var user = await repo.GetByIdAsync(id);
    return user is null ? Results.NotFound() : Results.Ok(user);
});

app.MapPost("/users", async (CreateUserRequest request, UserRepository repo) =>
{
    var id = await repo.CreateAsync(request);
    return Results.Created($"/users/{id}", new { id });
});

app.Run();

Repository

public class UserRepository
{
    private readonly IDbConnection _connection;

    public UserRepository(IDbConnection connection)
    {
        _connection = connection;
    }

    public async Task<Guid> CreateAsync(CreateUserRequest request)
    {
        var id = Guid.NewGuid();

        const string sql = """
            INSERT INTO users (id, name, email)
            VALUES (@Id, @Name, @Email)
        """;

        await _connection.ExecuteAsync(sql, new
        {
            Id = id,
            request.Name,
            request.Email
        });

        return id;
    }

    public async Task<User?> GetByIdAsync(Guid id)
    {
        const string sql = """
            SELECT id, name, email
            FROM users
            WHERE id = @Id
        """;

        return await _connection
            .QueryFirstOrDefaultAsync<User>(sql, new { Id = id });
    }
}

Comparação com EF Core

Pipeline interno

EF Core

  1. Expressão LINQ
  2. Árvore de expressão
  3. Compilação de query
  4. Geração de SQL
  5. Execução
  6. Materialização
  7. Change tracking

Dapper

  1. SQL manual
  2. Execução direta
  3. Materialização via IL

Comparativo estrutural

Comparativo Estrutural - Dapper vs EF Core

Aspecto			Dapper			EF Core
Performance		Muito alta		Alta
Change Tracking		Não			Sim
SQL automático		Não			Sim
LINQ			Não			Sim
Overhead de memória	Baixo			Médio
Controle de SQL		Total			Parcial

Quando usar EF Core

  • Domínio rico
  • Agregados complexos
  • Necessidade de change tracking
  • Produtividade maior que performance

Quando usar Dapper

  • Alta taxa de leitura
  • Queries complexas
  • Relatórios
  • Microservices
  • Cenários CQRS (lado de leitura)

Erros comuns de utilização

Apesar de ser simples conceitualmente, o Dapper exige um certo nível de maturidade técnica. A maior parte dos problemas em produção não está no Dapper em sí, mas na forma como ele é utilizado. Veremos quais são os erros mais recorrentes que serão explicados baseados em uma visão arquitetural e operacional.

Falta de controle explícito sobre SQL e segurança

O Dapper não protege você contra más decisões de escrita de SQL. Ele apenas executa o que você fornece.

Um erro extremamente comum é a concatenação de string para construção de query:

var sql = $"SELECT * FROM users WHERE email = '{email}'";

Esse padrão de utilização expõe o sistema a SQL Injection por exemplo. Como o Dapper não faz parsing nem validação semântica da query, ele simplesmente envia o texto ao provider (por exemplo, via Npgsql) que fica responsável por executar a instrução.

A forma correta é utilizar parâmetros:

connection.Query<User>(
    "SELECT * FROM users WHERE email = @Email",
    new { Email = email }
);

Quando usamos parâmetros:

  • O provider cria DbParameter
  • O valor não é concatenado na string
  • O banco trata como valor tipado
  • O plano de execução pode ser reutilizado

Além de segurança, isso melhora cache de plano no PostgreSQL.

Impacto rquitetural

Se sua equipe não domina SQL parametrizado, o Dapper se torna perigoso. Ele não possui camadas de proteção como ORMs que forçam parametrização implicitamente.

Má gestão do ciclo de vida da conexão

O Dapper depende de IDbConnection. Ele não controla pool de conexões, nem estado, nem concorrência.

Erros comuns:

  • Criar conexão como singleton
  • Manter conexão aberta indefinidamente
  • Abrir manualmente e esquecer de fechar
  • Compartilhar conexão entre múltiplas threads

Exemplo problemático:

builder.Services.AddSingleton<IDbConnection>(
    new NpgsqlConnection(connectionString)
);

Isso pode causar:

  • Exceções de concorrência
  • Estado inválido da conexão
  • Vazamento de recursos
  • Deadlocks

O correto é usar scoped ou criar por operação:

builder.Services.AddScoped<IDbConnection>(sp =>
{
    var cs = sp.GetRequiredService<IConfiguration>()
               .GetConnectionString("Default");

    return new NpgsqlConnection(cs);
});

O pool é gerenciado pelo Npgsql. Cada instância de NpgsqlConnection é leve e representa um handle lógico ao pool.

Impacto Arquitetural

Em ambientes de alta concorrência (API com centenas de requisições por segundo), uma má configuração de conexão pode gerar gargalos maiores que qualquer overhead de ORM.

Acreditar que o Dapper resolve problemas de performance

Outro erro comum é supor que apenas trocar de Entity Framework Core para Dapper vai resolver problemas de lentidão do ambiente.

Se uma query está lenta, normalmente o problema está em:

  • Índice inexistente
  • Índice inadequado
  • Plano de execução ruim
  • JOIN mal estruturado
  • Falta de análise via EXPLAIN ANALYZE

O Dapper executa exatamente o SQL que você escreveu. Ele não reescreve query, não adiciona otimizações e não cria índices.

Se você fizer:

SELECT * FROM large_table WHERE non_indexed_column = @value;

Será lento com Dapper, EF, ADO.NET ou qualquer outra ferramenta que você utilizar.

Impacto Arquitetural

Dapper transfere totalmente a responsabilidade de performance para o desenvolvedor e para o banco.

Ele elimina overhead de abstração, mas expõe a qualidade real do seu SQL.

Problemas de mapeamento e conversão de nomes

No caso em que usamos PostgreSQL normalmente utiliza snake_case. Já o padrão C# é PascalCase.

Classe:

public string UserName { get; set; }

Coluna:

user_name

Se você não configurar corretamente, o Dapper não conseguirá mapear.

Uma das soluções e aplicar o Alias explicito:

SELECT user_name AS "UserName"

Ou mesmo uma configuração definida globalmente:

Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;

Esse detalhe costuma gerar bugs perigosos e silenciosos, onde propriedades retornam null como valor sem erro explícito.

Impacto arquitetural

Em sistemas grandes, inconsistência de naming pode gerar falhas difíceis de rastrear. Dapper não faz validação automática de mapeamento.

Uso incorreto de multi-mapping

Ao fazer JOIN, o Dapper precisa saber onde dividir os objetos retornados.

Exemplo:

connection.Query<User, Role, User>(
    sql,
    (user, role) =>
    {
        user.Role = role;
        return user;
    },
    splitOn: "Id"
);

Se splitOn estiver incorreto:

  • Objetos podem ser preenchidos parcialmente
  • Propriedades podem ser sobrescritas
  • Dados podem ser inconsistentes

O Dapper não infere automaticamente relacionamento como um ORM completo faria.

Ausência de controle transacional explicito

O Dapper não implementa Unit of Work. Ele não controla as transações automaticamente.

Erro comum:

  • Executar múltiplos comandos dependentes sem transação

Exemplo problemático:

await connection.ExecuteAsync(sql1);
await connection.ExecuteAsync(sql2);

Se sql2 falhar, o primeiro comando já foi persistido.

Forma correta:

await connection.OpenAsync();
using var transaction = connection.BeginTransaction();

try
{
    await connection.ExecuteAsync(sql1, param1, transaction);
    await connection.ExecuteAsync(sql2, param2, transaction);

    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

Performance e Escalabilidade

Com PostgreSQL via Npgsql:

  • Pooling já otimizado
  • Async real reduz thread blocking
  • Prepared statements melhoram desempenho
  • JSONB permite consultas estruturadas

O gargalo raramente será o Dapper.
Normalmente será:

  • Banco mal indexado
  • Pool mal configurado
  • I/O de rede

Conclusão

Dapper é uma ferramenta de precisão.

Ele não veio e nem tenta substituir um ORM completo. Ele remove camadas de abstração para entregar:

  • Previsibilidade
  • Performance
  • Controle total sobre SQL
  • Baixo overhead

Com PostgreSQL via Npgsql, essa combinação é extremamente eficiente para:

  • APIs de alta concorrência
  • Sistemas multiempresa
  • Microservices
  • Dashboards
  • Serviços de leitura intensiva

A escolha entre Dapper e EF Core não é ideológica ela é puramente arquitetural.

Em muitos sistemas modernos, a abordagem híbrida é a mais utilizada e eficiente:

  • EF Core para escrita e domínio
  • Dapper para leitura otimizada

Isso permite aproveitar o melhor dos dois mundos sem sacrificar performance ou produtividade.

Código-fonte do projeto

Para facilitar o entendimento, disponibilizei o projeto completo com o exemplo de uso adequado do Dapper, já organizado com separação de responsabilidades e boas práticas:

👉 Baixar o projeto no GitHub neste link:

Quer ver mais conteúdos como este?

Se você trabalha com .NET, C# e backend, este blog seguirá trazendo conteúdos práticos e objetivos sobre o ecossistema Microsoft, com foco em código, boas práticas e decisões técnicas aplicadas ao mundo real.

Acompanhe para continuar aprofundando seu domínio sobre a plataforma .NET.

Confira mais:

Fique por dentro das novidades

Assine nossa newsletter e receba as últimas atualizações e artigos diretamente em seu email.

Assinar gratuitamente