Dapper
Esta estrutura reflete como costumo usar Dapper em projetos C#. Os exemplos são referências conceituais e podem não cobrir todos os detalhes de implementação; conforme as tecnologias evoluem, alguns podem ficar desatualizados. O que importa é o princípio: procedures para operações de domínio, queries abertas para casos simples.
O Dapper fica entre escrever ADO.NET na mão e adotar um ORM completo: você entrega o SQL, e ele transforma cada linha do resultado num objeto tipado. Neste guia, as operações de domínio moram em stored procedures (procedimentos armazenados no banco), e o C# apenas chama e mapeia o retorno. O SQL escrito direto no repositório fica para as consultas simples, como buscar por chave ou contar registros, em que uma procedure só acrescentaria um arquivo a manter.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| Dapper (micro-ORM para .NET) | Biblioteca leve que mapeia linhas para tipos sem o peso de um ORM completo |
| ORM (Object-Relational Mapper, Mapeador Objeto-Relacional) | Camada que mapeia tabelas para classes; Dapper fica entre ADO.NET cru e Entity Framework |
| stored procedure (procedimento armazenado) | Operação SQL nomeada e versionada no banco; encapsula lógica de acesso a dados |
| parameterized query (consulta parametrizada) | SQL com @param; previne SQL injection e permite cache de plano |
| DbConnection (conexão com o banco) | Abstração ADO.NET; Dapper estende com Query, Execute, QueryAsync |
| multi-mapping (mapeamento múltiplo) | Recurso do Dapper para popular grafo de objetos a partir de uma única query |
| CommandType.StoredProcedure (tipo de comando: procedure) | Flag que indica ao Dapper executar via EXEC em vez de SQL inline |
| SQL injection (injeção de SQL) | Vulnerabilidade ao concatenar entrada do usuário em SQL; parametrização elimina o risco |
Procedure por operação de domínio
Cada operação de domínio ganha a própria procedure, e o repositório fica com duas responsabilidades: chamar e mapear o resultado. O SQL (Structured Query Language · Linguagem de Consulta Estruturada) montado dentro do repositório espalha a consulta pelo código C#, onde o time de banco não a revisa e nenhuma ferramenta de banco a enxerga para analisar o plano de execução.
O nome da procedure segue a convenção do banco: SP_ seguido do verbo e da tabela, em maiúsculas, como SP_LIST_ORDERS_BY_CUSTOMER_ID. O método do repositório que a chama continua em PascalCase (FindByCustomerAsync), porque ele é código C#. Os verbos disponíveis e o formato completo estão em sql/conventions/naming.md.
❌ Ruim: SQL de domínio inline no repositório
public async Task<IReadOnlyList<OrderSummary>> FindByCustomerAsync(Guid customerId, CancellationToken ct)
{
var sql = @"
SELECT o.Id, o.Total, o.CreatedAt, s.Name AS Status
FROM Orders o
INNER JOIN OrderStatuses s ON s.Id = o.StatusId
WHERE o.CustomerId = @CustomerId
AND o.DeletedAt IS NULL
ORDER BY o.CreatedAt DESC"; // lógica de domínio acoplada ao C#
var summaries = await _connection.QueryAsync<OrderSummary>(sql, new { customerId });
return summaries.ToList();
}
✅ Bom: procedure encapsula a lógica, repositório só mapeia
-- SP_LIST_ORDERS_BY_CUSTOMER_ID.sql
CREATE OR ALTER PROCEDURE SP_LIST_ORDERS_BY_CUSTOMER_ID
(
@CustomerId UNIQUEIDENTIFIER
)
AS
BEGIN
SELECT
Orders.Id,
Orders.Total,
Orders.CreatedAt,
OrderStatuses.Name AS Status
FROM
Orders
INNER JOIN
OrderStatuses ON Orders.StatusId = OrderStatuses.Id
WHERE
Orders.CustomerId = @CustomerId AND
Orders.DeletedAt IS NULL
ORDER BY
Orders.CreatedAt DESC;
END;
-- EXEC SP_LIST_ORDERS_BY_CUSTOMER_ID @CustomerId = '9585E296-1114-4F35-9B34-1130987BA6D0';
public async Task<IReadOnlyList<OrderSummary>> FindByCustomerAsync(Guid customerId, CancellationToken ct)
{
var parameters = new DynamicParameters();
parameters.Add("CustomerId", customerId);
var summaries = await _connection.QueryAsync<OrderSummary>(
"SP_LIST_ORDERS_BY_CUSTOMER_ID",
parameters,
commandType: CommandType.StoredProcedure);
var result = summaries.ToList();
return result;
}
✅ Bom: procedure de escrita com OUTPUT param
-- SP_ADD_ORDER.sql
CREATE OR ALTER PROCEDURE SP_ADD_ORDER
(
@CustomerId UNIQUEIDENTIFIER,
@Total DECIMAL(18, 2),
@NewId UNIQUEIDENTIFIER OUTPUT
)
AS
BEGIN
SET @NewId = NEWID();
INSERT INTO Orders (Id, CustomerId, Total, CreatedAt)
VALUES (@NewId, @CustomerId, @Total, GETUTCDATE());
END;
-- EXEC SP_ADD_ORDER
-- @CustomerId = '9585E296-1114-4F35-9B34-1130987BA6D0',
-- @Total = 99.90,
-- @NewId = NULL OUTPUT;
public async Task<Guid> CreateAsync(Guid customerId, decimal total, CancellationToken ct)
{
var parameters = new DynamicParameters();
parameters.Add("CustomerId", customerId);
parameters.Add("Total", total);
parameters.Add("NewId", dbType: DbType.Guid, direction: ParameterDirection.Output);
await _connection.ExecuteAsync(
"SP_ADD_ORDER",
parameters,
commandType: CommandType.StoredProcedure);
var newId = parameters.Get<Guid>("NewId");
return newId;
}
Consulta escrita no repositório
Buscar por chave, contar linhas, checar se um registro existe: nesses casos a consulta cabe numa linha e não guarda regra nenhuma. Criar uma procedure para cada uma acrescentaria arquivos ao banco sem nada em troca. Escreva o SQL ali mesmo, sempre com parâmetro.
✅ Bom: lookup simples por chave primária
public async Task<Customer?> FindByIdAsync(Guid id, CancellationToken ct)
{
const string sql = "SELECT Id, Name, Email FROM Customers WHERE Id = @Id";
var customer = await _connection.QueryFirstOrDefaultAsync<Customer>(sql, new { id });
return customer;
}
✅ Bom: verificação de existência
public async Task<bool> ExistsAsync(string email, CancellationToken ct)
{
const string sql = "SELECT COUNT(1) FROM Customers WHERE Email = @Email";
var count = await _connection.ExecuteScalarAsync<int>(sql, new { email });
var exists = count > 0;
return exists;
}
SQL injection
Quando você junta o valor digitado pelo usuário ao texto da consulta, o banco recebe uma frase só e executa tudo o que ela contém. Um nome como '; DROP TABLE Orders; -- deixa de ser um nome e passa a ser um comando. O parâmetro nomeado (@Email) resolve isso: o driver envia o valor separado do texto da consulta, e o banco o trata como dado, qualquer que seja o conteúdo.
❌ Ruim: concatenação deixa o atacante escrever SQL
// email recebido: ' OR '1'='1
// SQL gerado: SELECT Id, Name FROM Customers WHERE Email = '' OR '1'='1'
// resultado: retorna todos os clientes
var sql = $"SELECT Id, Name FROM Customers WHERE Email = '{email}'";
// email recebido: '; DROP TABLE Customers; --
// SQL gerado: SELECT ... WHERE Email = ''; DROP TABLE Customers; --'
// resultado: tabela deletada
var sql = $"SELECT Id, Name FROM Customers WHERE Email = '{email}'";
❌ Ruim: LIKE com concatenação, wildcard no SQL permite injeção
public async Task<IReadOnlyList<Customer>> SearchByNameAsync(string term, CancellationToken ct)
{
var sql = $"SELECT Id, Name FROM Customers WHERE Name LIKE '%{term}%'";
// term = "'; DROP TABLE Customers; --" → executa instrução arbitrária
var customers = await _connection.QueryAsync<Customer>(sql);
var result = customers.ToList();
return result;
}
✅ Bom: parâmetro nomeado, valor tratado como dado pelo banco
public async Task<Customer?> FindByEmailAsync(string email, CancellationToken ct)
{
const string sql = "SELECT Id, Name FROM Customers WHERE Email = @Email";
var customer = await _connection.QueryFirstOrDefaultAsync<Customer>(sql, new { email });
return customer;
}
✅ Bom: LIKE com parâmetro, wildcard no valor não no SQL
// tentação comum: $"WHERE Name LIKE '%{term}%'": SQL injection
public async Task<IReadOnlyList<Customer>> SearchByNameAsync(string term, CancellationToken ct)
{
const string sql = "SELECT Id, Name FROM Customers WHERE Name LIKE @Term";
var customers = await _connection.QueryAsync<Customer>(sql, new { Term = $"%{term}%" });
var result = customers.ToList();
return result;
}
Injeção de conexão
O repositório recebe a IDbConnection pelo construtor. Criá-la lá dentro, com a connection string escrita no código, prende o repositório a um banco específico e leva a senha para o repositório de código. Quem cria a conexão também decide quando fechá-la, e é assim que várias operações conseguem compartilhar a mesma transação.
❌ Ruim: conexão instanciada dentro do repositório
public class OrderRepository
{
public async Task<IReadOnlyList<OrderSummary>> FindByCustomerAsync(Guid customerId, CancellationToken ct)
{
using var connection = new SqlConnection("Server=...;Database=..."); // hardcoded
// ...
}
}
✅ Bom: IDbConnection injetado via construtor
public class OrderRepository(IDbConnection connection)
{
public async Task<IReadOnlyList<OrderSummary>> FindByCustomerAsync(Guid customerId, CancellationToken ct)
{
var parameters = new DynamicParameters();
parameters.Add("CustomerId", customerId);
var summaries = await connection.QueryAsync<OrderSummary>(
"SP_LIST_ORDERS_BY_CUSTOMER_ID",
parameters,
commandType: CommandType.StoredProcedure);
var result = summaries.ToList();
return result;
}
}
// Infrastructure/DatabaseExtensions.cs
builder.Services.AddScoped<IDbConnection>(_ =>
new SqlConnection(connectionString));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.