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.IDbConnectionIsso 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:
- Criação de IDbCommand
- Binding de parâmetros
- Execução de ExecuteReader
- Leitura via IDataReader
- Geração dinâmica de método de materialização
- Cache do delegate gerado
- 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 NpgsqlConnection 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
- Expressão LINQ
- Árvore de expressão
- Compilação de query
- Geração de SQL
- Execução
- Materialização
- Change tracking
Dapper
- SQL manual
- Execução direta
- 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 ParcialQuando 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_nameSe 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.