Sometimes the best solution is the one that gets out of your way. Entity Framework is powerful, feature-rich, and handles 90% of database operations beautifully. But when you need raw speed, precise control over your SQL, or you're dealing with complex queries that make EF's query provider cry, you reach for Dapper.
The irony is that Dapper calls itself a "micro-ORM," but it powers some of the internet's highest-traffic sites. Stack Overflow, the very platform where developers debate ORM choices, runs on Dapper. When you're serving hundreds of millions of page views and every millisecond counts, the "micro" ORM becomes the heavyweight champion.
This isn't to say you should abandon Entity Framework or other full ORMs. Think of it like choosing between a Swiss Army knife and a scalpel. EF is your Swiss Army knife—versatile, handles most situations, packed with features. Dapper is your scalpel—specialized, precise, incredibly efficient at its one job: mapping SQL results to objects with minimal overhead.
For instance: A reporting dashboard executing 50+ complex analytical queries per page load? Dapper will shine. A standard CRUD application with simple entities and relationships? EF Core will serve you better with less code.
Dapper is a micro-ORM (Object-Relational Mapper) created by Stack Overflow that focuses on performance and simplicity. Unlike Entity Framework, which generates SQL for you, Dapper requires you to write your own SQL queries. What Dapper does exceptionally well is map those query results to your C# objects with blazing speed.
Key characteristics:
Dapper is one of the fastest ORMs available. It's just a thin layer over ADO.NET, adding minimal overhead while saving you from manual mapping code.
When you need specific SQL features—CTEs, window functions, database-specific optimizations—Dapper doesn't fight you. You write exactly the SQL you need.
No configuration files, no fluent APIs, no migration generators. Install the package, write SQL, get objects. That's it.
If you know SQL and C#, you know Dapper. There's almost nothing else to learn.
Perfect for working with existing databases where you can't or don't want to restructure to fit an ORM's conventions.
First-class support for stored procedures without ceremony or workarounds.
Traditional ADO.NET:
using var connection = new SqlConnection(connectionString);
var command = new SqlCommand("SELECT * FROM Users WHERE Id = @Id", connection);
command.Parameters.AddWithValue("@Id", userId);
connection.Open();
var reader = command.ExecuteReader();
var user = new User();
while (reader.Read())
{
user.Id = (int)reader["Id"];
user.Name = reader["Name"].ToString();
user.Email = reader["Email"].ToString();
}
With Dapper:
using var connection = new SqlConnection(connectionString);
var user = connection.QuerySingle<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = userId }
);
Dapper handles the connection opening, parameter mapping, result reading, and object mapping—all with performance close to raw ADO.NET.
public class UserRepository
{
private readonly string _connectionString;
public async Task<User> GetByIdAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleAsync<User>(
@"SELECT Id, Username, Email, CreatedDate
FROM Users
WHERE Id = @Id",
new { Id = id }
);
}
// Returns null if not found
public async Task<User> GetByEmailOrDefaultAsync(string email)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Email = @Email",
new { Email = email }
);
}
}
public async Task<IEnumerable<Product>> GetByCategoryAsync(int categoryId)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
@"SELECT p.Id, p.Name, p.Price, p.StockQuantity
FROM Products p
WHERE p.CategoryId = @CategoryId
ORDER BY p.Name",
new { CategoryId = categoryId }
);
}
// With pagination
public async Task<IEnumerable<Product>> GetPagedAsync(int pageNumber, int pageSize)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
@"SELECT * FROM Products
ORDER BY Id
OFFSET @Offset ROWS
FETCH NEXT @PageSize ROWS ONLY",
new
{
Offset = (pageNumber - 1) * pageSize,
PageSize = pageSize
}
);
}
public async Task<IEnumerable<Order>> SearchOrdersAsync(OrderSearchCriteria criteria)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT o.Id, o.OrderNumber, o.OrderDate, o.TotalAmount,
o.Status, o.CustomerId
FROM Orders o
WHERE (@FromDate IS NULL OR o.OrderDate >= @FromDate)
AND (@ToDate IS NULL OR o.OrderDate <= @ToDate)
AND (@Status IS NULL OR o.Status = @Status)
AND (@MinAmount IS NULL OR o.TotalAmount >= @MinAmount)
AND (@CustomerId IS NULL OR o.CustomerId = @CustomerId)
ORDER BY o.OrderDate DESC";
return await connection.QueryAsync<Order>(sql, criteria);
}
public async Task<int> CreateUserAsync(User user)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
INSERT INTO Users (Username, Email, PasswordHash, CreatedDate)
VALUES (@Username, @Email, @PasswordHash, @CreatedDate);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
return await connection.QuerySingleAsync<int>(sql, user);
}
// Bulk insert
public async Task<int> CreateUsersAsync(IEnumerable<User> users)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
INSERT INTO Users (Username, Email, PasswordHash, CreatedDate)
VALUES (@Username, @Email, @PasswordHash, @CreatedDate)";
return await connection.ExecuteAsync(sql, users);
}
public async Task<bool> UpdateUserAsync(User user)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
UPDATE Users
SET Username = @Username,
Email = @Email,
UpdatedDate = @UpdatedDate
WHERE Id = @Id";
var rowsAffected = await connection.ExecuteAsync(sql, user);
return rowsAffected > 0;
}
// Partial update
public async Task<bool> UpdateEmailAsync(int userId, string email)
{
using var connection = new SqlConnection(_connectionString);
var rowsAffected = await connection.ExecuteAsync(
"UPDATE Users SET Email = @Email, UpdatedDate = GETUTCDATE() WHERE Id = @Id",
new { Id = userId, Email = email }
);
return rowsAffected > 0;
}
public async Task<bool> DeleteUserAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
var rowsAffected = await connection.ExecuteAsync(
"DELETE FROM Users WHERE Id = @Id",
new { Id = id }
);
return rowsAffected > 0;
}
// Soft delete
public async Task<bool> SoftDeleteUserAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
var rowsAffected = await connection.ExecuteAsync(
@"UPDATE Users
SET IsDeleted = 1,
DeletedDate = GETUTCDATE()
WHERE Id = @Id",
new { Id = id }
);
return rowsAffected > 0;
}
Dapper excels at mapping joined queries to related objects.
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; }
public decimal TotalAmount { get; set; }
public Customer Customer { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
public async Task<Order> GetOrderWithCustomerAsync(int orderId)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT o.Id, o.OrderNumber, o.TotalAmount,
c.Id, c.Name, c.Email
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
WHERE o.Id = @OrderId";
var order = await connection.QueryAsync<Order, Customer, Order>(
sql,
(order, customer) =>
{
order.Customer = customer;
return order;
},
new { OrderId = orderId },
splitOn: "Id" // Where Customer properties start
);
return order.FirstOrDefault();
}
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public int Id { get; set; }
public int ProductId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
public async Task<Order> GetOrderWithItemsAsync(int orderId)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT o.Id, o.OrderNumber,
oi.Id, oi.ProductId, oi.Quantity, oi.Price,
p.Name AS ProductName
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
LEFT JOIN Products p ON oi.ProductId = p.Id
WHERE o.Id = @OrderId";
var orderDictionary = new Dictionary<int, Order>();
await connection.QueryAsync<Order, OrderItem, Order>(
sql,
(order, orderItem) =>
{
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
{
existingOrder = order;
orderDictionary.Add(order.Id, existingOrder);
}
if (orderItem != null)
{
existingOrder.Items.Add(orderItem);
}
return existingOrder;
},
new { OrderId = orderId },
splitOn: "Id"
);
return orderDictionary.Values.FirstOrDefault();
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public List<Category> Categories { get; set; } = new();
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public async Task<IEnumerable<Product>> GetProductsWithCategoriesAsync()
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT p.Id, p.Name,
c.Id, c.Name
FROM Products p
LEFT JOIN ProductCategories pc ON p.Id = pc.ProductId
LEFT JOIN Categories c ON pc.CategoryId = c.Id
ORDER BY p.Id";
var productDictionary = new Dictionary<int, Product>();
await connection.QueryAsync<Product, Category, Product>(
sql,
(product, category) =>
{
if (!productDictionary.TryGetValue(product.Id, out var existingProduct))
{
existingProduct = product;
productDictionary.Add(product.Id, existingProduct);
}
if (category != null)
{
existingProduct.Categories.Add(category);
}
return existingProduct;
},
splitOn: "Id"
);
return productDictionary.Values;
}
public async Task<IEnumerable<SalesReport>> GetSalesReportAsync(
DateTime startDate,
DateTime endDate)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<SalesReport>(
"usp_GetSalesReport",
new { StartDate = startDate, EndDate = endDate },
commandType: CommandType.StoredProcedure
);
}
// With output parameters
public async Task<(int TotalCount, IEnumerable<Order> Orders)> GetOrdersPagedAsync(
int pageNumber,
int pageSize)
{
using var connection = new SqlConnection(_connectionString);
var parameters = new DynamicParameters();
parameters.Add("@PageNumber", pageNumber);
parameters.Add("@PageSize", pageSize);
parameters.Add("@TotalCount", dbType: DbType.Int32, direction: ParameterDirection.Output);
var orders = await connection.QueryAsync<Order>(
"usp_GetOrdersPaged",
parameters,
commandType: CommandType.StoredProcedure
);
var totalCount = parameters.Get<int>("@TotalCount");
return (totalCount, orders);
}
public async Task<DashboardData> GetDashboardDataAsync(int userId)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT * FROM Users WHERE Id = @UserId;
SELECT * FROM Orders
WHERE CustomerId = @UserId
ORDER BY OrderDate DESC;
SELECT SUM(TotalAmount) AS TotalSpent,
COUNT(*) AS OrderCount
FROM Orders
WHERE CustomerId = @UserId;";
using var multi = await connection.QueryMultipleAsync(sql, new { UserId = userId });
var dashboard = new DashboardData
{
User = await multi.ReadSingleAsync<User>(),
RecentOrders = (await multi.ReadAsync<Order>()).ToList(),
Statistics = await multi.ReadSingleAsync<OrderStatistics>()
};
return dashboard;
}
// When you don't have a specific type
public async Task<IEnumerable<dynamic>> GetTableDataAsync(string tableName)
{
using var connection = new SqlConnection(_connectionString);
// DANGEROUS - Only use with validated input!
return await connection.QueryAsync(
$"SELECT * FROM {tableName}"
);
}
// Safer dynamic usage
public async Task<dynamic> GetUserStatsAsync(int userId)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleAsync(
@"SELECT
COUNT(DISTINCT o.Id) AS OrderCount,
SUM(o.TotalAmount) AS TotalSpent,
MAX(o.OrderDate) AS LastOrderDate
FROM Orders o
WHERE o.CustomerId = @UserId",
new { UserId = userId }
);
}
public async Task<bool> TransferFundsAsync(
int fromAccountId,
int toAccountId,
decimal amount)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var transaction = await connection.BeginTransactionAsync();
try
{
// Debit from source account
var debitSql = @"
UPDATE Accounts
SET Balance = Balance - @Amount,
UpdatedDate = GETUTCDATE()
WHERE Id = @AccountId
AND Balance >= @Amount";
var debitRows = await connection.ExecuteAsync(
debitSql,
new { AccountId = fromAccountId, Amount = amount },
transaction
);
if (debitRows == 0)
throw new InsufficientFundsException();
// Credit to destination account
var creditSql = @"
UPDATE Accounts
SET Balance = Balance + @Amount,
UpdatedDate = GETUTCDATE()
WHERE Id = @AccountId";
await connection.ExecuteAsync(
creditSql,
new { AccountId = toAccountId, Amount = amount },
transaction
);
// Record transaction
var recordSql = @"
INSERT INTO Transactions
(FromAccountId, ToAccountId, Amount, TransactionDate)
VALUES (@FromAccountId, @ToAccountId, @Amount, GETUTCDATE())";
await connection.ExecuteAsync(
recordSql,
new { FromAccountId = fromAccountId, ToAccountId = toAccountId, Amount = amount },
transaction
);
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
For mapping custom types that Dapper doesn't handle by default:
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
{
public override T Parse(object value)
{
var json = value.ToString();
return string.IsNullOrEmpty(json)
? default
: JsonSerializer.Deserialize<T>(json);
}
public override void SetValue(IDbDataParameter parameter, T value)
{
parameter.Value = value == null
? DBNull.Value
: JsonSerializer.Serialize(value);
parameter.DbType = DbType.String;
}
}
// Registration (in application startup)
SqlMapper.AddTypeHandler(new JsonTypeHandler<Dictionary<string, object>>());
// Usage
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public Dictionary<string, object> Metadata { get; set; }
}
// Dapper will now automatically handle the JSON conversion
var product = await connection.QuerySingleAsync<Product>(
"SELECT Id, Name, Metadata FROM Products WHERE Id = @Id",
new { Id = productId }
);
For complex dynamic queries:
public class ProductQueryBuilder
{
private readonly List<string> _whereClauses = new();
private readonly DynamicParameters _parameters = new();
private string _orderBy = "p.Name";
public ProductQueryBuilder WithCategory(int? categoryId)
{
if (categoryId.HasValue)
{
_whereClauses.Add("p.CategoryId = @CategoryId");
_parameters.Add("CategoryId", categoryId.Value);
}
return this;
}
public ProductQueryBuilder WithPriceRange(decimal? minPrice, decimal? maxPrice)
{
if (minPrice.HasValue)
{
_whereClauses.Add("p.Price >= @MinPrice");
_parameters.Add("MinPrice", minPrice.Value);
}
if (maxPrice.HasValue)
{
_whereClauses.Add("p.Price <= @MaxPrice");
_parameters.Add("MaxPrice", maxPrice.Value);
}
return this;
}
public ProductQueryBuilder WithSearchTerm(string searchTerm)
{
if (!string.IsNullOrWhiteSpace(searchTerm))
{
_whereClauses.Add("(p.Name LIKE @Search OR p.Description LIKE @Search)");
_parameters.Add("Search", $"%{searchTerm}%");
}
return this;
}
public ProductQueryBuilder OrderBy(string column, bool descending = false)
{
_orderBy = $"p.{column}" + (descending ? " DESC" : "");
return this;
}
public async Task<IEnumerable<Product>> ExecuteAsync(IDbConnection connection)
{
var sql = "SELECT p.* FROM Products p";
if (_whereClauses.Any())
{
sql += " WHERE " + string.Join(" AND ", _whereClauses);
}
sql += $" ORDER BY {_orderBy}";
return await connection.QueryAsync<Product>(sql, _parameters);
}
}
// Usage
var products = await new ProductQueryBuilder()
.WithCategory(5)
.WithPriceRange(10, 100)
.WithSearchTerm("wireless")
.OrderBy("Price", descending: false)
.ExecuteAsync(connection);
// Buffered (default) - Loads all results into memory immediately
public async Task<IEnumerable<Order>> GetAllOrdersBufferedAsync()
{
using var connection = new SqlConnection(_connectionString);
// All results loaded into memory
return await connection.QueryAsync<Order>(
"SELECT * FROM Orders",
buffered: true // Default
);
}
// Unbuffered - Yields results as they're read (better for large datasets)
public async IAsyncEnumerable<Order> GetAllOrdersStreamedAsync()
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
// Results streamed one at a time
var orders = await connection.QueryAsync<Order>(
"SELECT * FROM Orders",
buffered: false
);
foreach (var order in orders)
{
yield return order;
}
}
public class DapperRepository
{
private readonly string _connectionString;
// BAD - Creates new connection string builder each time
public async Task<User> GetUserBadAsync(int id)
{
var builder = new SqlConnectionStringBuilder();
builder.DataSource = "server";
builder.InitialCatalog = "database";
// ... more configuration
using var connection = new SqlConnection(builder.ToString());
return await connection.QuerySingleAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });
}
// GOOD - Reuses connection string, benefits from pooling
public async Task<User> GetUserGoodAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });
}
// EXCELLENT - Async all the way
public async Task<User> GetUserExcellentAsync(int id)
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
return await connection.QuerySingleAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
}
}
public interface IRepository<T> where T : class
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<int> InsertAsync(T entity);
Task<bool> UpdateAsync(T entity);
Task<bool> DeleteAsync(int id);
}
public abstract class DapperRepository<T> : IRepository<T> where T : class
{
protected readonly string ConnectionString;
protected abstract string TableName { get; }
protected abstract string IdColumn { get; }
protected DapperRepository(string connectionString)
{
ConnectionString = connectionString;
}
public virtual async Task<T> GetByIdAsync(int id)
{
using var connection = new SqlConnection(ConnectionString);
return await connection.QuerySingleOrDefaultAsync<T>(
$"SELECT * FROM {TableName} WHERE {IdColumn} = @Id",
new { Id = id }
);
}
public virtual async Task<IEnumerable<T>> GetAllAsync()
{
using var connection = new SqlConnection(ConnectionString);
return await connection.QueryAsync<T>(
$"SELECT * FROM {TableName}"
);
}
public abstract Task<int> InsertAsync(T entity);
public abstract Task<bool> UpdateAsync(T entity);
public virtual async Task<bool> DeleteAsync(int id)
{
using var connection = new SqlConnection(ConnectionString);
var rowsAffected = await connection.ExecuteAsync(
$"DELETE FROM {TableName} WHERE {IdColumn} = @Id",
new { Id = id }
);
return rowsAffected > 0;
}
}
public class UserRepository : DapperRepository<User>
{
protected override string TableName => "Users";
protected override string IdColumn => "Id";
public UserRepository(string connectionString) : base(connectionString) { }
public override async Task<int> InsertAsync(User user)
{
using var connection = new SqlConnection(ConnectionString);
var sql = @"
INSERT INTO Users (Username, Email, PasswordHash, CreatedDate)
VALUES (@Username, @Email, @PasswordHash, @CreatedDate);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
return await connection.QuerySingleAsync<int>(sql, user);
}
public override async Task<bool> UpdateAsync(User user)
{
using var connection = new SqlConnection(ConnectionString);
var sql = @"
UPDATE Users
SET Username = @Username,
Email = @Email,
UpdatedDate = @UpdatedDate
WHERE Id = @Id";
var rowsAffected = await connection.ExecuteAsync(sql, user);
return rowsAffected > 0;
}
// Custom methods
public async Task<User> GetByEmailAsync(string email)
{
using var connection = new SqlConnection(ConnectionString);
return await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Email = @Email",
new { Email = email }
);
}
public async Task<bool> EmailExistsAsync(string email)
{
using var connection = new SqlConnection(ConnectionString);
var count = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM Users WHERE Email = @Email",
new { Email = email }
);
return count > 0;
}
}
public interface IUnitOfWork : IDisposable
{
IDbConnection Connection { get; }
IDbTransaction Transaction { get; }
void BeginTransaction();
void Commit();
void Rollback();
}
public class UnitOfWork : IUnitOfWork
{
private IDbConnection _connection;
private IDbTransaction _transaction;
private bool _disposed;
public UnitOfWork(string connectionString)
{
_connection = new SqlConnection(connectionString);
_connection.Open();
}
public IDbConnection Connection => _connection;
public IDbTransaction Transaction => _transaction;
public void BeginTransaction()
{
_transaction = _connection.BeginTransaction();
}
public void Commit()
{
try
{
_transaction?.Commit();
}
catch
{
_transaction?.Rollback();
throw;
}
finally
{
_transaction?.Dispose();
_transaction = null;
}
}
public void Rollback()
{
_transaction?.Rollback();
_transaction?.Dispose();
_transaction = null;
}
public void Dispose()
{
if (!_disposed)
{
_transaction?.Dispose();
_connection?.Dispose();
_disposed = true;
}
}
}
// Usage
public class OrderService
{
private readonly string _connectionString;
public async Task<bool> CreateOrderAsync(Order order, List<OrderItem> items)
{
using var uow = new UnitOfWork(_connectionString);
try
{
uow.BeginTransaction();
// Insert order
var orderId = await uow.Connection.QuerySingleAsync<int>(
@"INSERT INTO Orders (CustomerId, OrderDate, TotalAmount)
VALUES (@CustomerId, @OrderDate, @TotalAmount);
SELECT CAST(SCOPE_IDENTITY() AS INT);",
order,
uow.Transaction
);
// Insert order items
foreach (var item in items)
{
item.OrderId = orderId;
}
await uow.Connection.ExecuteAsync(
@"INSERT INTO OrderItems (OrderId, ProductId, Quantity, Price)
VALUES (@OrderId, @ProductId, @Quantity, @Price)",
items,
uow.Transaction
);
// Update inventory
await uow.Connection.ExecuteAsync(
@"UPDATE Products
SET StockQuantity = StockQuantity - @Quantity
WHERE Id = @ProductId",
items.Select(i => new { i.ProductId, i.Quantity }),
uow.Transaction
);
uow.Commit();
return true;
}
catch
{
uow.Rollback();
throw;
}
}
}
// INEFFICIENT - String concatenation
public async Task<IEnumerable<Product>> GetProductsBadAsync(List<int> ids)
{
using var connection = new SqlConnection(_connectionString);
var idsString = string.Join(",", ids);
// SQL injection risk!
return await connection.QueryAsync<Product>(
$"SELECT * FROM Products WHERE Id IN ({idsString})"
);
}
// EFFICIENT - Parameterized
public async Task<IEnumerable<Product>> GetProductsGoodAsync(List<int> ids)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
"SELECT * FROM Products WHERE Id IN @Ids",
new { Ids = ids }
);
}
// SLOW - Individual inserts
public async Task InsertProductsSlowAsync(List<Product> products)
{
using var connection = new SqlConnection(_connectionString);
foreach (var product in products)
{
await connection.ExecuteAsync(
"INSERT INTO Products (Name, Price) VALUES (@Name, @Price)",
product
);
}
}
// FAST - Bulk insert
public async Task InsertProductsFastAsync(List<Product> products)
{
using var connection = new SqlConnection(_connectionString);
// Single database round-trip
await connection.ExecuteAsync(
"INSERT INTO Products (Name, Price) VALUES (@Name, @Price)",
products
);
}
// FASTEST - SQL Server specific bulk copy
public async Task InsertProductsFastestAsync(List<Product> products)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var bulkCopy = new SqlBulkCopy(connection);
bulkCopy.DestinationTableName = "Products";
var dataTable = CreateDataTable(products);
await bulkCopy.WriteToServerAsync(dataTable);
}
// BAD - SELECT *
public async Task<IEnumerable<Product>> GetProductsBadAsync()
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
"SELECT * FROM Products"
);
}
// GOOD - Select only needed columns
public async Task<IEnumerable<ProductListDto>> GetProductsGoodAsync()
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<ProductListDto>(
"SELECT Id, Name, Price FROM Products"
);
}
// EXCELLENT - With proper indexing hints
public async Task<IEnumerable<ProductListDto>> GetProductsExcellentAsync()
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<ProductListDto>(
@"SELECT Id, Name, Price
FROM Products WITH (INDEX(IX_Products_Name))
WHERE IsActive = 1
ORDER BY Name"
);
}
public class CachedProductRepository
{
private readonly IMemoryCache _cache;
private readonly string _connectionString;
public async Task<Product> GetByIdAsync(int id)
{
var cacheKey = $"product_{id}";
if (_cache.TryGetValue(cacheKey, out Product cachedProduct))
{
return cachedProduct;
}
using var connection = new SqlConnection(_connectionString);
var product = await connection.QuerySingleOrDefaultAsync<Product>(
"SELECT * FROM Products WHERE Id = @Id",
new { Id = id }
);
if (product != null)
{
_cache.Set(cacheKey, product, TimeSpan.FromMinutes(10));
}
return product;
}
public async Task<bool> UpdateAsync(Product product)
{
using var connection = new SqlConnection(_connectionString);
var rowsAffected = await connection.ExecuteAsync(
"UPDATE Products SET Name = @Name, Price = @Price WHERE Id = @Id",
product
);
if (rowsAffected > 0)
{
// Invalidate cache
_cache.Remove($"product_{product.Id}");
}
return rowsAffected > 0;
}
}
public interface IDbConnectionFactory
{
IDbConnection CreateConnection();
}
public class UserRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public UserRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<User> GetByIdAsync(int id)
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
}
}
// Test
[Fact]
public async Task GetByIdAsync_ReturnsUser_WhenUserExists()
{
// Arrange
var mockConnection = new Mock<IDbConnection>();
var mockFactory = new Mock<IDbConnectionFactory>();
mockFactory
.Setup(f => f.CreateConnection())
.Returns(mockConnection.Object);
var repository = new UserRepository(mockFactory.Object);
// Act & Assert would require more complex setup
// Consider integration tests instead
}
public class UserRepositoryIntegrationTests : IDisposable
{
private readonly SqlConnection _connection;
private readonly UserRepository _repository;
public UserRepositoryIntegrationTests()
{
var connectionString = "Server=(localdb)\\mssqllocaldb;Database=TestDb;";
_connection = new SqlConnection(connectionString);
_connection.Open();
// Setup test database
_connection.Execute(@"
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
CREATE TABLE Users (
Id INT PRIMARY KEY IDENTITY,
Username NVARCHAR(100),
Email NVARCHAR(255)
)
");
_repository = new UserRepository(connectionString);
}
[Fact]
public async Task InsertAsync_AddsUserToDatabase()
{
// Arrange
var user = new User
{
Username = "testuser",
Email = "[email protected]"
};
// Act
var userId = await _repository.InsertAsync(user);
// Assert
Assert.True(userId > 0);
var retrievedUser = await _repository.GetByIdAsync(userId);
Assert.Equal("testuser", retrievedUser.Username);
Assert.Equal("[email protected]", retrievedUser.Email);
}
[Fact]
public async Task GetByEmailAsync_ReturnsNull_WhenUserDoesNotExist()
{
// Act
var user = await _repository.GetByEmailAsync("[email protected]");
// Assert
Assert.Null(user);
}
public void Dispose()
{
_connection.Execute("DROP TABLE IF EXISTS Users");
_connection.Dispose();
}
}
// BAD - Connection leak
public async Task<User> GetUserBadAsync(int id)
{
var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
// Connection never disposed!
}
// GOOD - Proper disposal
public async Task<User> GetUserGoodAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
}
// DANGEROUS - SQL injection vulnerability
public async Task<IEnumerable<User>> SearchUsersDangerousAsync(string searchTerm)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<User>(
$"SELECT * FROM Users WHERE Username LIKE '%{searchTerm}%'"
);
}
// SAFE - Parameterized query
public async Task<IEnumerable<User>> SearchUsersSafeAsync(string searchTerm)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<User>(
"SELECT * FROM Users WHERE Username LIKE @SearchTerm",
new { SearchTerm = $"%{searchTerm}%" }
);
}
// WRONG - QueryAsync for single result
public async Task<User> GetUserWrongAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
var users = await connection.QueryAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
return users.FirstOrDefault(); // Unnecessary enumeration
}
// RIGHT - QuerySingleOrDefaultAsync
public async Task<User> GetUserRightAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
}
// BAD - N+1 queries
public async Task<IEnumerable<OrderWithItems>> GetOrdersBadAsync()
{
using var connection = new SqlConnection(_connectionString);
var orders = await connection.QueryAsync<Order>("SELECT * FROM Orders");
foreach (var order in orders)
{
// N additional queries!
order.Items = (await connection.QueryAsync<OrderItem>(
"SELECT * FROM OrderItems WHERE OrderId = @OrderId",
new { OrderId = order.Id }
)).ToList();
}
return orders;
}
// GOOD - Single query with join
public async Task<IEnumerable<Order>> GetOrdersGoodAsync()
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
SELECT o.*, oi.*
FROM Orders o
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId";
var orderDictionary = new Dictionary<int, Order>();
await connection.QueryAsync<Order, OrderItem, Order>(
sql,
(order, item) =>
{
if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
{
existingOrder = order;
existingOrder.Items = new List<OrderItem>();
orderDictionary.Add(order.Id, existingOrder);
}
if (item != null)
{
existingOrder.Items.Add(item);
}
return existingOrder;
},
splitOn: "Id"
);
return orderDictionary.Values;
}
Use Dapper when:
Use Entity Framework when:
Use Both when:
using statements or await usingDynamicParameters for complex scenariosQueryFirstOrDefaultAsync for single resultspublic async Task<User> GetUserWithErrorHandlingAsync(int id)
{
try
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Id = @Id",
new { Id = id }
);
}
catch (SqlException ex)
{
_logger.LogError(ex, "Database error retrieving user {UserId}", id);
throw new DataAccessException("Failed to retrieve user", ex);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Multiple users found with ID {UserId}", id);
throw;
}
}
.Result or .Wait()ConfigureAwait(false) in library code[Key] attribute or conventions for identity columnspublic class OrderService
{
private readonly string _connectionString;
private readonly ILogger<OrderService> _logger;
public async Task<OrderDetailsDto> GetOrderDetailsAsync(int orderId)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
-- Order info
SELECT o.Id, o.OrderNumber, o.OrderDate, o.Status, o.TotalAmount,
c.Id, c.FirstName, c.LastName, c.Email,
a.Street, a.City, a.State, a.ZipCode
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
INNER JOIN Addresses a ON o.ShippingAddressId = a.Id
WHERE o.Id = @OrderId;
-- Order items
SELECT oi.Id, oi.Quantity, oi.UnitPrice,
p.Id, p.Name, p.SKU
FROM OrderItems oi
INNER JOIN Products p ON oi.ProductId = p.Id
WHERE oi.OrderId = @OrderId;
-- Payment info
SELECT PaymentMethod, PaymentStatus, TransactionId
FROM Payments
WHERE OrderId = @OrderId;";
using var multi = await connection.QueryMultipleAsync(sql, new { OrderId = orderId });
var order = await multi.ReadAsync<OrderDetailsDto, Customer, Address, OrderDetailsDto>(
(o, c, a) =>
{
o.Customer = c;
o.ShippingAddress = a;
return o;
},
splitOn: "Id,Street"
);
var orderDto = order.First();
orderDto.Items = (await multi.ReadAsync<OrderItemDto, Product, OrderItemDto>(
(oi, p) =>
{
oi.Product = p;
return oi;
},
splitOn: "Id"
)).ToList();
orderDto.Payment = await multi.ReadSingleOrDefaultAsync<PaymentDto>();
return orderDto;
}
public async Task<int> CreateOrderAsync(CreateOrderDto orderDto)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var transaction = await connection.BeginTransactionAsync();
try
{
// 1. Create order
var orderSql = @"
INSERT INTO Orders (OrderNumber, CustomerId, OrderDate, Status, TotalAmount, ShippingAddressId)
VALUES (@OrderNumber, @CustomerId, GETUTCDATE(), 'Pending', @TotalAmount, @ShippingAddressId);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
var orderId = await connection.QuerySingleAsync<int>(orderSql, orderDto, transaction);
// 2. Create order items
var itemsSql = @"
INSERT INTO OrderItems (OrderId, ProductId, Quantity, UnitPrice)
VALUES (@OrderId, @ProductId, @Quantity, @UnitPrice)";
var items = orderDto.Items.Select(i => new
{
OrderId = orderId,
i.ProductId,
i.Quantity,
i.UnitPrice
});
await connection.ExecuteAsync(itemsSql, items, transaction);
// 3. Update inventory
var inventorySql = @"
UPDATE Products
SET StockQuantity = StockQuantity - @Quantity
WHERE Id = @ProductId AND StockQuantity >= @Quantity";
foreach (var item in orderDto.Items)
{
var affected = await connection.ExecuteAsync(
inventorySql,
new { item.ProductId, item.Quantity },
transaction
);
if (affected == 0)
{
throw new InsufficientStockException($"Product {item.ProductId} out of stock");
}
}
// 4. Create payment record
var paymentSql = @"
INSERT INTO Payments (OrderId, PaymentMethod, PaymentStatus, Amount)
VALUES (@OrderId, @PaymentMethod, 'Pending', @Amount)";
await connection.ExecuteAsync(
paymentSql,
new
{
OrderId = orderId,
orderDto.PaymentMethod,
Amount = orderDto.TotalAmount
},
transaction
);
await transaction.CommitAsync();
_logger.LogInformation("Order {OrderId} created successfully", orderId);
return orderId;
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Failed to create order");
throw;
}
}
}
// Single result
var user = await connection.QuerySingleAsync<User>(sql, parameters);
var user = await connection.QuerySingleOrDefaultAsync<User>(sql, parameters);
var user = await connection.QueryFirstAsync<User>(sql, parameters);
var user = await connection.QueryFirstOrDefaultAsync<User>(sql, parameters);
// Multiple results
var users = await connection.QueryAsync<User>(sql, parameters);
// Scalar value
var count = await connection.ExecuteScalarAsync<int>(sql, parameters);
// Non-query
var rowsAffected = await connection.ExecuteAsync(sql, parameters);
// Multi-mapping
var results = await connection.QueryAsync<Order, Customer, Order>(
sql,
(order, customer) => { order.Customer = customer; return order; },
splitOn: "Id"
);
// Multiple result sets
using var multi = await connection.QueryMultipleAsync(sql, parameters);
var users = await multi.ReadAsync<User>();
var orders = await multi.ReadAsync<Order>();
// Anonymous object
new { Id = 1, Name = "John" }
// DynamicParameters
var p = new DynamicParameters();
p.Add("@Id", 1);
p.Add("@Name", "John");
p.Add("@Output", dbType: DbType.Int32, direction: ParameterDirection.Output);
// List parameters
new { Ids = new[] { 1, 2, 3 } }
// Custom object
var user = new User { Id = 1, Name = "John" };
Dapper strikes an elegant balance between the raw power of ADO.NET and the convenience of full-featured ORMs. By requiring you to write SQL but automating the tedious mapping work, it delivers exceptional performance while keeping your codebase clean and maintainable.
It's not trying to be everything to everyone—and that's exactly why it excels. When you need speed, SQL control, and minimal overhead, Dapper is the scalpel in your toolkit. When you need rapid development with change tracking and migrations, reach for Entity Framework. And when you need both? Use them together.
The best tool is the one that fits your specific problem. Dapper's popularity and longevity prove that sometimes, doing one thing exceptionally well is better than doing everything adequately.
Dapper
Microsoft
ORM Benchmarks
Stack Overflow
Essential Reading
Blogs and Articles
Video Tutorials
Dapper was created by Sam Saffron and Marc Gravell at Stack Overflow and is maintained by the .NET community. This guide draws upon community best practices and real-world usage patterns.