Engineer Speed: Mastering Dapper for Raw SQL Power in C#
In the relentless pursuit of peak application performance, developers frequently confront a critical juncture: optimizing data access. Traditional Object-Relational Mappers (ORMs) like Entity Framework Core offer immense productivity, abstracting database interactions with powerful elegance. Yet, for microservices demanding surgical precision and raw speed, the overhead of ORMs can become a bottleneck, especially when milliseconds matter.
This resource decodes the strategic imperative for bypassing ORM abstractions and embracing micro-ORMs like Dapper. We activate a proactive strategy for scenarios where direct SQL execution provides an undeniable performance advantage. This article illuminates the path to leveraging Dapper with C# in ASP.NET Core, dissecting its mechanics, uncovering its performance benchmarks, and arming you with the architectural insights required to integrate it effectively into your high-throughput systems. Prepare to forge a data access layer that redefines efficiency and responsiveness, essential for any architect committed to building modern web applications and APIs with C# and .NET. We challenge the conventional, exploring when the pragmatic choice of raw SQL speed transforms into a competitive differentiator in the microservices landscape.
The Imperative for Speed: Dapper's Genesis in High-Performance Microservices
We initiate our journey into high-performance data access by understanding Dapper’s foundational role. In the realm of microservices, every computational cycle holds significance. ORMs, while powerful for abstracting complex data models, introduce layers of indirection and potential performance costs through their change tracking, query translation, and materialization pipelines. Dapper emerges as a lean, focused alternative—a micro-ORM. It strips away the extensive features of full-fledged ORMs to deliver direct, unadulterated SQL execution with minimal overhead.
Dapper's genesis directly addresses the need for speed. It extends the `IDbConnection` interface, providing a concise set of extension methods that streamline the execution of raw SQL queries and map results directly to C# objects. This direct mapping bypasses the often-heavy reflection and complex object graphs characteristic of larger ORMs, translating into significantly faster data retrieval and persistence. For microservices, where individual operations must be razor-sharp to maintain overall system responsiveness, Dapper is not merely an option; it is an architectural imperative. It enables developers to dictate precisely how data interacts with the database, leveraging specific SQL optimizations and database features that ORMs might obscure or restrict. We arm ourselves with Dapper to engineer applications where data access ceases to be a bottleneck and transforms into a competitive advantage.
Consider a scenario involving high-volume, read-heavy APIs where microservices serve millions of requests. The cumulative impact of even small overheads from an ORM becomes substantial. Dapper's ability to execute parameterized queries with near raw ADO.NET speed, coupled with its efficient object materialization, makes it the tool of choice. We strategically employ Dapper when our analysis reveals that the query patterns are well-defined, SQL optimization is paramount, and the domain model is less complex, thus not requiring the full-spectrum abstraction of an ORM. This targeted application ensures we deploy the right tool for the right task, maximizing efficiency and performance.
/*
* Dapper Setup in an ASP.NET Core Project
*
* This code block demonstrates how to install Dapper via NuGet and
* configure a basic database connection string in appsettings.json.
* It sets the foundation for Dapper usage by defining the database
* connection that Dapper will utilize for its operations.
*/
// 1. Install Dapper NuGet Package:
// Open your Package Manager Console and run:
// Install-Package Dapper
// Alternatively, use the .NET CLI:
// dotnet add package Dapper
// 2. Configure your Connection String in appsettings.json:
// This is a standard practice in ASP.NET Core to manage database connections.
// Replace 'YourDatabaseName', 'YourUser', and 'YourPassword' with actual values.
// For production, always use secure methods for credentials (e.g., Azure Key Vault, environment variables).
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=YourDatabaseName;User ID=YourUser;Password=YourPassword;TrustServerCertificate=True"
}
}
// 3. Example of how to retrieve the connection string in your C# code (e.g., in Startup.cs or Program.cs for .NET 6+):
// We will demonstrate DI setup in a later section, but this shows basic retrieval.
/*
using Microsoft.Extensions.Configuration;
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
string connectionString = Configuration.GetConnectionString("DefaultConnection");
// You would then use this connection string to create IDbConnection instances.
// Example: services.AddScoped<IDbConnection>(sp => new SqlConnection(connectionString));
}
}
*/
Forging Data Access: Core Dapper Operations and Advanced Querying
We move from Dapper's foundational principles to its practical application, forging robust data access layers through its core operations. Dapper extends `IDbConnection` with powerful, intuitive methods that directly translate raw SQL into C# objects. The `Query` and `QueryAsync` methods become our primary tools for retrieval, executing SELECT statements and efficiently mapping rows to `IEnumerable
Beyond simple reads, Dapper empowers us to execute data manipulation language (DML) operations—INSERT, UPDATE, DELETE—with the `Execute` and `ExecuteAsync` methods. These methods return the number of rows affected, providing crucial feedback on our operations' success. A critical aspect of secure and performant data access involves parameterized queries. Dapper natively supports anonymous objects or `DynamicParameters` for this, automatically sanitizing inputs and preventing SQL injection vulnerabilities while optimizing query plans. This is a non-negotiable best practice that we rigorously enforce.
Dapper's true elegance shines in advanced scenarios. Consider multi-mapping: when a single SQL query returns data from multiple joined tables, Dapper can map these results into distinct C# objects and even construct complex object graphs. We define a split-on column and a lambda function to guide Dapper in constructing these relationships. This capability is invaluable for denormalized reads or projections that optimize network round trips. Furthermore, Dapper provides robust support for executing stored procedures, allowing us to leverage database-side logic and performance optimizations directly without abstraction layers. We pass `CommandType.StoredProcedure` and any necessary parameters, activating a direct pipeline to the database’s pre-compiled routines. Mastering these operations transforms our approach to data interaction, delivering unparalleled control and speed.
/*
* Core Dapper Operations: CRUD and Advanced Querying
*
* This block demonstrates essential Dapper methods for common database operations
* (Select, Insert, Update, Delete) and introduces advanced features like
* parameterized queries and multi-mapping. It provides a practical guide
* to executing raw SQL efficiently.
*/
using Dapper;
using Microsoft.Data.SqlClient; // Or other database providers like Npgsql, MySql.Data
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
// Assume connectionString is configured as shown in the previous section.
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public int ProductId { get; set; }
}
public class OrderDetail
{
public int OrderId { get; set; }
public Product Product { get; set; }
public Order Order { get; set; }
}
public class DapperRepository
{
private readonly string _connectionString;
public DapperRepository(string connectionString)
{
_connectionString = connectionString;
}
// Method to get a single product by Id (SELECT)
public async Task<Product> GetProductByIdAsync(int id)
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "SELECT Id, Name, Price FROM Products WHERE Id = @Id;";
return await connection.QueryFirstOrDefaultAsync<Product>(sql, new { Id = id });
}
// Method to get all products (SELECT ALL)
public async Task<IEnumerable<Product>> GetAllProductsAsync()
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "SELECT Id, Name, Price FROM Products;";
return await connection.QueryAsync<Product>(sql);
}
// Method to insert a new product (INSERT)
public async Task<int> AddProductAsync(Product product)
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "INSERT INTO Products (Name, Price) VALUES (@Name, @Price); SELECT SCOPE_IDENTITY();";
// Using SCOPE_IDENTITY() for SQL Server to return the newly generated ID.
return await connection.ExecuteScalarAsync<int>(sql, product);
}
// Method to update an existing product (UPDATE)
public async Task<bool> UpdateProductAsync(Product product)
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "UPDATE Products SET Name = @Name, Price = @Price WHERE Id = @Id;";
int rowsAffected = await connection.ExecuteAsync(sql, product);
return rowsAffected > 0;
}
// Method to delete a product (DELETE)
public async Task<bool> DeleteProductAsync(int id)
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "DELETE FROM Products WHERE Id = @Id;";
int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id });
return rowsAffected > 0;
}
// Method demonstrating multi-mapping for complex objects (e.g., Order with Product)
// This maps one row to multiple objects, based on a split-on column.
public async Task<IEnumerable<OrderDetail>> GetOrderDetailsAsync()
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = @"SELECT o.Id AS OrderId, o.OrderDate, p.Id AS ProductId, p.Name, p.Price
FROM Orders o JOIN Products p ON o.ProductId = p.Id;";
return await connection.QueryAsync<Order, Product, OrderDetail>(
sql,
(order, product) =>
{
var orderDetail = new OrderDetail { Order = order, Product = product };
// You might need to set common IDs if they exist in both objects
// e.Product.Id = product.Id; // or similar mapping
return orderDetail;
},
splitOn: "ProductId"); // Specifies the column where the split between types occurs
}
// Method demonstrating dynamic parameters for more flexible queries
public async Task<IEnumerable<Product>> SearchProductsAsync(string searchTerm)
{
using IDbConnection connection = new SqlConnection(_connectionString);
var parameters = new DynamicParameters();
parameters.Add("@SearchTerm", "%" + searchTerm + "%");
string sql = "SELECT Id, Name, Price FROM Products WHERE Name LIKE @SearchTerm;";
return await connection.QueryAsync<Product>(sql, parameters);
}
// Method to execute a stored procedure
public async Task<IEnumerable<Product>> GetProductsByStoredProcedureAsync()
{
using IDbConnection connection = new SqlConnection(_connectionString);
// Ensure 'GetActiveProducts' stored procedure exists in your database.
string spName = "GetActiveProducts";
return await connection.QueryAsync<Product>(spName, commandType: CommandType.StoredProcedure);
}
}
/*
// Example of how to use the DapperRepository in an ASP.NET Core service or controller:
public class ProductService
{
private readonly DapperRepository _repository;
public ProductService(DapperRepository repository)
{
_repository = repository;
}
public async Task<Product> RetrieveProduct(int id)
{
return await _repository.GetProductByIdAsync(id);
}
public async Task AddNewProduct(Product newProduct)
{
await _repository.AddProductAsync(newProduct);
}
}
*/
Optimizing the Data Flow: Performance Techniques and Best Practices
Optimizing the data flow with Dapper requires a surgical approach to connection management, query execution, and transactional integrity. We engineer our applications for maximum throughput by embracing asynchronous programming. Dapper's `QueryAsync`, `ExecuteAsync`, and related methods are not merely syntactic sugar; they are fundamental for building scalable ASP.NET Core applications. By liberating threads during I/O operations, we prevent blocking the request pipeline, allowing our microservices to handle more concurrent requests and maintain responsiveness under heavy load. This is a critical leverage point in high-performance computing.
Transaction management stands as a cornerstone of data integrity. Dapper, by operating close to ADO.NET, provides direct access to `IDbTransaction`. We meticulously encapsulate multiple database operations within a single transaction to ensure atomicity—all operations succeed, or all roll back. This prevents data inconsistencies, particularly in complex business workflows. Activating explicit transaction control through `BeginTransaction()` and disciplined `Commit()` or `Rollback()` calls is an essential practice. Furthermore, we must consider connection pooling. While Dapper itself does not manage connections, the underlying ADO.NET providers do. We leverage the provider's connection pooling to minimize the overhead of opening and closing physical database connections, ensuring efficient resource utilization.
Advanced Dapper usage demands consideration of buffered versus unbuffered queries. By default, Dapper buffers query results, loading all data into memory before returning the `IEnumerable
/*
* Optimizing Data Flow with Dapper: Async Operations and Transactions
*
* This code demonstrates crucial techniques for maximizing Dapper's performance
* and ensuring data integrity: asynchronous query execution and robust transaction management.
* These practices are vital for scalable and reliable microservices.
*/
using Dapper;
using Microsoft.Data.SqlClient;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
public class PerformanceOptimizedRepository
{
private readonly string _connectionString;
public PerformanceOptimizedRepository(string connectionString)
{
_connectionString = connectionString;
}
// 1. Asynchronous Data Retrieval: Essential for non-blocking I/O in web applications.
// Using QueryAsync significantly improves application responsiveness under load.
public async Task<IEnumerable<Product>> GetProductsAsync()
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "SELECT Id, Name, Price FROM Products ORDER BY Name;";
return await connection.QueryAsync<Product>(sql);
}
// 2. Asynchronous Data Manipulation: Apply async to INSERT/UPDATE/DELETE as well.
public async Task<int> AddProductAsync(Product product)
{
using IDbConnection connection = new SqlConnection(_connectionString);
string sql = "INSERT INTO Products (Name, Price) VALUES (@Name, @Price); SELECT SCOPE_IDENTITY();";
return await connection.ExecuteScalarAsync<int>(sql, product);
}
// 3. Transaction Management: Ensuring Atomicity for multiple operations.
// This method demonstrates a transaction that inserts a product and updates another.
// If any operation fails, all changes are rolled back.
public async Task<bool> PerformTransactionalUpdateAsync(Product newProduct, Product productToUpdate)
{
using IDbConnection connection = new SqlConnection(_connectionString);
connection.Open(); // Open connection explicitly for transaction scope
using IDbTransaction transaction = connection.BeginTransaction();
try
{
// Insert new product within the transaction
string insertSql = "INSERT INTO Products (Name, Price) VALUES (@Name, @Price);";
await connection.ExecuteAsync(insertSql, newProduct, transaction: transaction);
// Update existing product within the transaction
string updateSql = "UPDATE Products SET Name = @Name, Price = @Price WHERE Id = @Id;";
await connection.ExecuteAsync(updateSql, productToUpdate, transaction: transaction);
transaction.Commit(); // Commit all changes if successful
return true;
}
catch (Exception ex)
{
// Log the exception (e.g., using a logger)
Console.WriteLine($"Transaction failed: {ex.Message}");
transaction.Rollback(); // Rollback all changes if an error occurs
return false;
}
}
// 4. Buffered vs. Unbuffered Queries (Streaming):
// Default Dapper Query methods are buffered (load all results into memory).
// Using CommandFlags.Buffered = false for large datasets to stream results.
// Note: This requires the connection to remain open until enumeration completes.
public async IAsyncEnumerable<Product> StreamLargeProductDatasetAsync()
{
using IDbConnection connection = new SqlConnection(_connectionString);
connection.Open(); // Keep connection open for streaming
// CommandFlags.None means it's unbuffered for Query, enabling streaming.
// Ensure to handle connection closure carefully when enumerating the results.
foreach (var product in await connection.QueryAsync<Product>("SELECT Id, Name, Price FROM Products WHERE Price > 1000 ORDER BY Id;", buffered: false))
{
yield return product;
}
// Connection will be closed when the 'using' block exits, after enumeration.
}
}
Architecting with Dapper: Strategic Integration in ASP.NET Core
Architecting an ASP.NET Core application with Dapper necessitates a strategic integration approach, focusing on modularity, testability, and maintainability. We leverage Dependency Injection (DI), a cornerstone of modern .NET development, to decouple our data access logic from business logic. This involves defining interfaces for our repositories (e.g., `IProductRepository`) and implementing them using Dapper. By injecting `IConfiguration` or directly the connection string into our repository, we ensure that database access is configured centrally and can be easily swapped for testing or different environments.
The Repository Pattern becomes our guiding principle. It encapsulates data access logic, presenting a collection-like interface to the domain layer, abstracting the underlying data store's specifics. Our Dapper-based repository implementations will instantiate `IDbConnection` objects within their methods, ensuring that connections are short-lived and properly disposed of using `using` statements. This pattern not only promotes a clean architecture but also simplifies unit testing, allowing us to mock the `IProductRepository` interface without needing an actual database connection. We forge a robust and testable data access layer that adheres to SOLID principles.
The decision matrix for choosing Dapper over a full-fledged ORM like Entity Framework Core is critical. We opt for Dapper when raw performance is the paramount concern, SQL queries are complex or highly optimized, and the overhead of an ORM is deemed unacceptable. This is particularly true for read-heavy microservices, legacy databases requiring direct SQL, or scenarios involving highly specialized query patterns (e.g., dynamic queries, bulk operations). Conversely, we consider EF Core when rapid development, complex object graphs, extensive change tracking, or database-agnostic code are higher priorities. Our strategy is not to replace ORMs entirely but to selectively apply Dapper where its strengths—speed, control, and minimal abstraction—provide the most significant leverage. We decode the nuances of each project and select the tool that propels our development mastery forward, understanding that strategic tool selection is as vital as code craftsmanship.
/*
* Architecting with Dapper: Strategic Integration in ASP.NET Core
*
* This code outlines how to integrate Dapper into an ASP.NET Core application
* using Dependency Injection (DI) and the Repository Pattern. This setup promotes
* maintainability, testability, and scalability by decoupling database access logic.
*/
using Dapper;
using Microsoft.Data.SqlClient;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
// 1. Define an Interface for your Repository (Contract-First Design)
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task<int> AddAsync(Product product);
Task<bool> UpdateAsync(Product product);
Task<bool> DeleteAsync(int id);
}
// 2. Implement the Repository using Dapper
public class ProductRepository : IProductRepository
{
private readonly string _connectionString;
// Inject the connection string (or IConfiguration) via constructor
public ProductRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
}
// Helper method to create a new IDbConnection instance
private IDbConnection CreateConnection() => new SqlConnection(_connectionString);
public async Task<Product> GetByIdAsync(int id)
{
using IDbConnection connection = CreateConnection();
return await connection.QueryFirstOrDefaultAsync<Product>(
"SELECT Id, Name, Price FROM Products WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
using IDbConnection connection = CreateConnection();
return await connection.QueryAsync<Product>("SELECT Id, Name, Price FROM Products");
}
public async Task<int> AddAsync(Product product)
{
using IDbConnection connection = CreateConnection();
string sql = "INSERT INTO Products (Name, Price) VALUES (@Name, @Price); SELECT SCOPE_IDENTITY();";
return await connection.ExecuteScalarAsync<int>(sql, product);
}
public async Task<bool> UpdateAsync(Product product)
{
using IDbConnection connection = CreateConnection();
string sql = "UPDATE Products SET Name = @Name, Price = @Price WHERE Id = @Id;";
int rowsAffected = await connection.ExecuteAsync(sql, product);
return rowsAffected > 0;
}
public async Task<bool> DeleteAsync(int id)
{
using IDbConnection connection = CreateConnection();
string sql = "DELETE FROM Products WHERE Id = @Id;";
int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id });
return rowsAffected > 0;
}
}
// 3. Register your Repository and Services in Program.cs (for .NET 6+) or Startup.cs
// This configures Dependency Injection to provide instances of IProductRepository.
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDapperDataAccess(this IServiceCollection services, IConfiguration configuration)
{
// Register IProductRepository to be resolved by DI.
// We use Scoped lifecycle for typical web request scenarios.
services.AddScoped<IProductRepository, ProductRepository>();
// If you need to inject IConfiguration directly into the repository, it's already available
// via default DI setup in ASP.NET Core.
return services;
}
}
// 4. Example Usage in an ASP.NET Core Controller or Service
/*
// In your Program.cs or Startup.cs:
// builder.Services.AddDapperDataAccess(builder.Configuration);
// In a controller:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _productRepository;
public ProductsController(IProductRepository productRepository)
{
_productRepository = productRepository;
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> Get(int id)
{
var product = await _productRepository.GetByIdAsync(id);
if (product == null) return NotFound();
return Ok(product);
}
[HttpPost]
public async Task<ActionResult<Product>> Post([FromBody] Product newProduct)
{
int newId = await _productRepository.AddAsync(newProduct);
newProduct.Id = newId; // Assign the new ID
return CreatedAtAction(nameof(Get), new { id = newId }, newProduct);
}
}
*/
Key Takeaways
Dapper's Core Purpose
Dapper is a high-performance micro-ORM for .NET that focuses on raw SQL execution with minimal overhead. It's engineered for speed and precise control over database interactions, making it ideal for performance-critical microservices where full ORMs introduce too much abstraction and overhead.
Key Dapper Operations
Dapper extends IDbConnection with methods like QueryAsync (SELECT), ExecuteAsync (INSERT/UPDATE/DELETE), QueryFirstOrDefaultAsync (single result), and ExecuteScalarAsync (single value). It supports parameterized queries for security (SQL injection prevention) and multi-mapping for complex object graphs from joined tables. Stored procedures are also fully supported.
Performance Best Practices
- Asynchronous Operations: Use
*Asyncmethods to prevent blocking I/O and improve application responsiveness. - Transaction Management: Leverage
IDbTransactionfor atomicity in multi-operation workflows. - Connection Pooling: Rely on ADO.NET provider's pooling to minimize connection overhead.
- Buffered vs. Unbuffered: Use unbuffered queries (
buffered: false) for large datasets to stream results and reduce memory footprint, but manage connection lifecycle carefully. - Avoid N+1 Problem: Construct efficient SQL joins and projections to fetch related data in a single round trip.
Strategic Integration in ASP.NET Core
Integrate Dapper using Dependency Injection (DI) and the Repository Pattern. Define interfaces for your repositories and implement them using Dapper to achieve modularity, testability, and maintainability. Choose Dapper strategically for performance-critical scenarios, complex SQL, or legacy databases, complementing rather than replacing full ORMs where appropriate.
When to Choose Dapper
Opt for Dapper when: raw performance is paramount, you require fine-grained SQL control, dealing with high-volume read operations, integrating with complex legacy databases, or working with microservices where minimal abstraction and memory footprint are critical. It excels where the overhead of a full ORM becomes a performance bottleneck.
FAQ
-
What are the primary benefits of using Dapper over a full ORM like Entity Framework Core?
The primary benefits of Dapper include significantly higher performance due to direct SQL execution and minimal overhead, enhanced control over SQL queries allowing for advanced optimizations, and a lighter footprint making it ideal for microservices where every resource counts. It’s also simpler for complex legacy database schemas or when stored procedures are heavily utilized.
-
Can Dapper protect against SQL injection attacks?
Yes, Dapper inherently protects against SQL injection when you consistently use parameterized queries. Instead of concatenating user input directly into SQL strings, pass parameters using anonymous objects (e.g.,
new { Id = productId }) orDynamicParameters. Dapper then handles parameterization securely, preventing malicious code from executing. -
Is it possible to use Dapper and Entity Framework Core in the same project?
Absolutely. This hybrid approach is a common and highly effective strategy. We might use Entity Framework Core for general CRUD operations on less performance-sensitive entities, benefiting from its productivity features. Concurrently, we deploy Dapper for specific, performance-critical endpoints, complex reporting, or raw SQL interactions where maximum speed is paramount. This allows us to leverage the strengths of both tools strategically.
-
How does Dapper handle transactions?
Dapper integrates seamlessly with ADO.NET transactions. You explicitly open an
IDbConnection, then callBeginTransaction()to obtain anIDbTransactionobject. All Dapper operations within that transaction scope must pass theIDbTransactionobject as a parameter. You then explicitly callCommit()orRollback()on the transaction to ensure atomicity. This provides granular control over transaction boundaries. -
What is the N+1 problem and how does Dapper help avoid it?
The N+1 problem occurs when an application retrieves a list of parent entities (N queries) and then executes a separate query for each parent to fetch its related child entities (N additional queries), leading to N+1 database round trips. Dapper helps avoid this by encouraging explicit SQL joins (e.g.,
JOINclauses) and multi-mapping. We engineer a single, optimized SQL query that fetches all necessary related data in one round trip, dramatically reducing database calls and improving performance.