1 Introduction: The ORM Performance vs. Productivity Dilemma
Modern software projects increasingly operate under two seemingly opposing forces: the need for rapid development and the demand for uncompromising performance. Nowhere is this tension more visible than in the data access layer of enterprise-grade .NET applications. On one hand, developers want frameworks that abstract complexity, reduce boilerplate, and align naturally with domain-driven design. On the other hand, users and stakeholders expect applications to respond instantly—even under heavy load and with terabytes of data.
Entity Framework Core (EF Core) and Dapper stand as the two dominant players in this space. EF Core offers productivity and deep integration into the .NET ecosystem, while Dapper focuses almost entirely on performance and control. Many teams frame their decision as a binary choice: either EF Core or Dapper. This is a false dilemma. A pragmatic architecture can, and often should, employ both.
EF Core vs. Dapper at a Glance
- Use EF Core when: you need strong domain modeling, change tracking, schema migrations, or transactional consistency.
- Use Dapper when: performance of large read queries matters, or when you need fine-grained SQL control.
- Hybrid wins when: your system mixes complex writes (EF Core) with high-volume, read-heavy queries (Dapper).
- Avoid pitfalls: don’t overuse EF Core for massive reports; don’t reinvent change tracking with Dapper.
- Rule of thumb: start with EF Core for business logic, add Dapper where profiling shows bottlenecks.
Quick Decision Flow ➡️ Is the query transactional and entity-rich? → EF Core ➡️ Is the query read-only and performance-critical? → Dapper ➡️ Do you need both? → Hybrid
1.1 The Modern Application’s Demands
Scalability, user experience, and cost drive the importance of data access performance today.
Consider scalability. A high-traffic SaaS application that performs poorly optimized queries through EF Core’s change tracker may add 10 ms to each request. At scale, 10 ms × 5M queries/month = ~14 hours of wasted compute. That’s not only a performance problem but also a cloud billing problem.
User experience is another pressure point. End users do not distinguish between slow database queries and sluggish UI rendering—they just experience latency. An analytics dashboard that takes 3 seconds to load feels broken, even if the back end is “correct” in terms of design patterns.
Finally, cost cannot be ignored. Cloud-native architectures charge by the query, the CPU cycle, or the gigabyte processed. Inefficient data access translates directly into higher operating costs. A CFO may not care whether EF Core or Dapper runs under the hood, but they will certainly notice the cloud bill.
In short: performance is not a luxury; it is a fundamental requirement in today’s .NET 9 era.
1.2 The “One Size Fits All” Fallacy
The temptation to choose a single ORM and commit fully is understandable. EF Core promises a unified developer experience with LINQ, change tracking, and migrations. Dapper promises speed and SQL control. Both camps often evangelize exclusivity: “EF Core is enough for everything” or “Real pros use Dapper only.”
This binary mindset introduces unnecessary compromises. For example:
- Using EF Core for high-volume reporting queries may lead to excessive memory usage and N+1 query issues.
- Using Dapper exclusively may force developers to reimplement features like change tracking, entity relationships, or schema migrations, introducing bugs and maintenance headaches.
Real-world enterprise systems rarely have homogeneous data access requirements. A customer checkout process benefits from EF Core’s transactional integrity and change tracking. Meanwhile, a dashboard listing 50,000 orders with joins across five tables benefits more from Dapper’s raw SQL speed.
No single ORM excels in all scenarios. Recognizing this is the first step toward a hybrid strategy.
1.3 The Hybrid Hero
The core thesis of this article is straightforward: combine EF Core and Dapper to achieve both productivity and performance. EF Core becomes the trusted tool for managing complex business transactions, enforcing consistency, and evolving the schema. Dapper becomes the specialized instrument for performance-critical queries, projections, and bulk operations.
This hybrid approach reflects the maturity of modern software architecture. Instead of forcing one tool to stretch beyond its design intent, we orchestrate both in their optimal roles. Think of EF Core as the framework for reliable orchestration and Dapper as the scalpel for precision tasks. Used together, they give you a balanced and pragmatic solution.
1.4 What You Will Learn
This article is structured to move from first principles to advanced implementations:
- A deep exploration of EF Core and Dapper as of .NET 9, including their strengths, weaknesses, and latest improvements.
- A benchmark-driven look at their performance differences.
- Architectural principles that support a hybrid ORM strategy, particularly CQRS (Command Query Responsibility Segregation).
- Practical guidance on structuring projects, configuring dependency injection, and ensuring EF Core and Dapper can share transactions.
- Detailed examples of read-side queries with Dapper and write-side operations with EF Core.
- Advanced topics including caching, bulk operations, and transaction management across ORMs.
- A real-world case study building a high-performance e-commerce dashboard API.
- Pitfalls to avoid and decision criteria to guide your team’s ORM choices.
Prerequisites
-
Audience: Intermediate+ .NET developers familiar with async/await, LINQ, and dependency injection.
-
Tools:
- .NET 9 SDK
- EF Core 9
- SQL Server 2019+ (or Azure SQL)
- Redis (for caching demos)
2 The Contenders: A Tale of Two ORMs (as of .NET 9 / EF Core 9)
Choosing between EF Core and Dapper requires understanding not just their feature sets but also their underlying philosophies. EF Core is designed as a full-fledged ORM (Object-Relational Mapper) prioritizing developer productivity and abstraction. Dapper is a micro-ORM designed to deliver near-raw SQL performance with minimal overhead. Let’s examine both in detail.
2.1 Entity Framework Core: The Feature-Rich Powerhouse
Entity Framework Core has matured significantly since its early releases. As of EF Core 9, it is not just a productivity tool but also a competitive option in many performance-sensitive scenarios. Still, its design philosophy prioritizes abstraction and modeling over raw speed.
2.1.1 Strengths
-
Rapid Application Development (RAD) with LINQ EF Core integrates deeply with LINQ, allowing developers to express queries in C# rather than SQL. This accelerates prototyping and improves readability.
var activeCustomers = await context.Customers .Where(c => c.IsActive) .OrderBy(c => c.LastName) .ToListAsync();🔎 Note on projections: When projecting directly to DTOs, EF Core does not track them. Tracking applies only to entities. For DTO projections,
AsNoTracking()gives a minor benefit, but overhead is already minimal. -
Robust Change Tracking for Complex Business Transactions EF Core automatically detects changes in entities, enabling complex transactional workflows without manual SQL. Updating an order and its related inventory can be handled within a single
SaveChanges()call. -
Database Migrations for Schema Management EF Core migrations provide a first-class way to evolve the database schema alongside the application. This reduces the need for ad hoc SQL migration scripts.
-
Compiled Queries in EF Core 9 Hot paths benefit from EF Core’s compiled queries, which eliminate query parsing/translation overhead:
private static readonly Func<MyContext, int, Task<Customer?>> _compiled = EF.CompileAsyncQuery((MyContext ctx, int id) => ctx.Customers.FirstOrDefault(c => c.Id == id)); var customer = await _compiled(context, 42); -
Recent Performance Improvements in EF Core 9
- Optimized query generation reduces redundant SQL.
- Better batching support allows multiple statements per round trip.
- Enhanced compiled query performance.
- Smarter query splitting avoids cartesian explosion in relationships.
2.1.2 Weaknesses
-
Performance Overhead of Abstraction and Change Tracking Change tracking adds CPU and memory overhead when working with thousands of entities.
-
The “Black Box” Nature of SQL Generation LINQ queries sometimes produce inefficient SQL. Developers must inspect generated queries or use profiling tools.
-
Inefficiency in Bulk Data Operations While
AddRange/RemoveRangeexist, EF Core lags behind specialized bulk libraries or raw SQL for tens of thousands of rows.
2.2 Dapper: The Lightweight Speed Demon
Dapper takes a radically different approach. Instead of abstracting away SQL, it embraces it. It positions itself as a thin mapper between SQL queries and .NET objects, offering minimal overhead.
2.2.1 Strengths
-
Blazing Fast, Near-Raw ADO.NET Performance Dapper is essentially a micro-ORM built on top of
IDataReader. Benchmarks consistently show it within microseconds of hand-written ADO.NET. -
Full Control over the Generated SQL Developers write SQL directly, giving them complete control over joins, indexes, and execution plans.
var sql = "SELECT * FROM Orders WHERE CustomerId = @CustomerId"; var orders = await connection.QueryAsync<Order>(sql, new { CustomerId = 42 }); -
Minimal Abstraction and Overhead With no change tracker or heavy abstractions, Dapper remains lightweight and predictable.
-
Excellent for Mapping Query Results to POCOs Dapper automatically maps columns to properties, including multi-mapping for joins.
2.2.2 Weaknesses
-
No Change Tracking Developers must manage state manually, which increases complexity for transactional workflows.
-
Writing and Maintaining Raw SQL While empowering, SQL must be kept in sync with schema changes and can add verbosity.
-
No Built-in Migration Tooling Unlike EF Core, Dapper provides no schema evolution features, requiring external frameworks.
2.3 The Verdict in Numbers
Benchmarks offer a clear perspective on the relative performance of EF Core and Dapper. Using BenchmarkDotNet, we compared both for a simple read and a complex join.
⚠️ Transparency note: Results are illustrative, not absolute. Reproducibility depends on DB schema, indexes, and hardware.
2.3.1 Benchmark Setup
- Database: SQL Server 2019, 1M Customers, 5M Orders, 500k Products.
- Schema: Proper indexes on foreign keys, nonclustered index on
Orders.CustomerId. - Hardware: Local dev machine, AMD Ryzen 9 5900X, 64 GB RAM, NVMe SSD.
- Environment: .NET 9.0, EF Core 9.0.1, Dapper 2.1.
- Caching: Benchmarks run with both cold and warm caches.
- Connection pooling: Enabled (default).
Scenarios:
- Simple Read: Fetch a single customer by ID.
- Complex Read: Fetch orders with customer and product details across three tables.
2.3.2 Benchmark Results (Illustrative)
| Operation | EF Core 9 (ms, p95 ± IQR) | Dapper (ms, p95 ± IQR) |
|---|---|---|
| Simple Read | 0.90 ± 0.05 | 0.30 ± 0.02 |
| Complex Read | 2.8 ± 0.2 | 1.1 ± 0.1 |
2.3.3 Interpretation
- EF Core is fast enough for most transactional workloads, especially with compiled queries.
- Dapper consistently outperforms EF Core for read-heavy, high-volume queries.
- The gap justifies hybrid adoption: EF Core for consistency and complex writes; Dapper for hot-path queries and reporting.
3 The Hybrid Manifesto: Architectural Principles and Patterns
The power of combining EF Core and Dapper comes not from simply sprinkling both tools across the codebase, but from applying architectural discipline. Without structure, a hybrid strategy quickly devolves into a tangled mess of SQL strings and LINQ queries. To avoid this fate, we need guiding principles and well-established patterns that ensure consistency, maintainability, and scalability. This is where CQRS and a set of decision rules provide clarity.
3.1 Embracing CQRS (Command Query Responsibility Segregation)
CQRS—Command Query Responsibility Segregation—is not a new idea, but it provides a near-perfect lens for blending EF Core and Dapper. The philosophy is simple: commands mutate state, queries return state. Separating the two concerns allows us to optimize each side independently without compromising the other.
3.1.1 The Command Stack (Writes)
The write side benefits from EF Core’s strengths. Change tracking, navigational properties, and cascading rules shine when modeling complex business logic.
public class OrderService
{
private readonly AppDbContext _context;
public OrderService(AppDbContext context) => _context = context;
public async Task PlaceOrderAsync(int customerId, List<OrderItem> items)
{
var customer = await _context.Customers.FindAsync(customerId);
if (customer is null) throw new InvalidOperationException("Customer not found.");
var order = new Order(customerId);
foreach (var item in items)
{
order.AddItem(item.ProductId, item.Quantity, item.Price);
var product = await _context.Products.FindAsync(item.ProductId);
if (product == null || product.Stock < item.Quantity)
throw new InvalidOperationException("Insufficient stock.");
product.Stock -= item.Quantity;
}
_context.Orders.Add(order);
await _context.SaveChangesAsync();
}
}
Here, EF Core automatically tracks changes to the Order, Product, and Customer entities. A single call to SaveChangesAsync() ensures atomicity. Achieving the same workflow in Dapper would require dozens of SQL statements wrapped in manual transaction handling.
3.1.2 The Query Stack (Reads)
For the read side, EF Core’s change tracker is unnecessary overhead. Queries for dashboards, reports, or lists often involve large joins and projections into DTOs rather than tracked entities. This is where Dapper thrives: minimal overhead and optimized SQL for maximum performance.
public class OrderDashboardRepository
{
private readonly IDbConnection _connection;
public OrderDashboardRepository(IDbConnection connection) => _connection = connection;
public async Task<IReadOnlyList<OrderSummaryDto>> GetRecentOrdersAsync(int count, DbTransaction transaction)
{
var sql = @"
SELECT TOP (@Count) o.Id, c.Name AS CustomerName, o.Total, o.CreatedOn
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
ORDER BY o.CreatedOn DESC";
return (await _connection.QueryAsync<OrderSummaryDto>(
sql,
new { Count = count },
transaction: transaction))
.ToList();
}
}
Here, Dapper delivers only the data we need, mapped directly to a lightweight DTO, without EF Core’s change tracker interfering.
By aligning EF Core with commands and Dapper with queries, we honor the CQRS principle while keeping both sides optimized.
3.2 The Rules of Engagement
A hybrid strategy demands discipline. Without clear rules, developers risk inconsistencies and anti-patterns. Below is a one-page decision matrix mapping operation type to default ORM choice, rationale, and common exceptions.
| Operation | Default Tool | Rationale | Exceptions |
|---|---|---|---|
| Create / Update / Delete (CUD) | EF Core | Change tracking + Unit of Work simplify multi-entity transactions. | Simple single-row updates (e.g., flag toggles) may be faster in Dapper. |
| Complex business workflows | EF Core | Navigational properties + aggregates enforce consistency. | None—EF Core almost always better here. |
| Read-heavy dashboards / reporting | Dapper | Faster DTO projection; avoids change tracker overhead. | Small entity lookups where EF Core LINQ is clearer and perf impact negligible. |
| Bulk inserts / updates / deletes | Dapper (+TVPs) | EF Core is inefficient for 10k+ row operations. | Moderate batch sizes (<500 rows) are acceptable with EF Core. |
| Stored procedures / DB-specific features | Dapper | Direct SQL control; easy parameter binding. | Lightweight sprocs returning single entities can also be materialized in EF. |
| Ad hoc queries for prototyping | EF Core | LINQ provides rapid feedback and productivity. | For load/perf testing, switch to Dapper for realism. |
These rules are heuristics, not laws. The goal is consistency first, exceptions documented.
3.3 The Single Source of Truth
The hybrid model only works if EF Core and Dapper operate on the same schema and connection. Both must share the same underlying DbConnection and DbTransaction to ensure transactional consistency.
using var transaction = await context.Database.BeginTransactionAsync();
var connection = context.Database.GetDbConnection();
// Dapper operation using EF's DbTransaction
await connection.ExecuteAsync(
"UPDATE Products SET Stock = Stock - 1 WHERE Id = @Id",
new { Id = 1 },
transaction: transaction.GetDbTransaction());
// EF Core operation within the same transaction
var order = new Order(customerId: 1);
context.Orders.Add(order);
await context.SaveChangesAsync();
// Commit both
await transaction.CommitAsync();
Here, both EF Core and Dapper share the same transaction object. Without this discipline, one tool may commit changes unseen by the other until after the transaction boundary, leading to subtle and hard-to-diagnose bugs.
4 Setting the Stage: Project Setup and Configuration
With principles in place, the next step is configuring the project to support this hybrid approach. Good structure and dependency injection practices are critical to avoiding spaghetti code. Let’s build a solid foundation.
4.1 Structuring the Solution
A layered architecture provides separation of concerns. A typical .NET solution might look like this:
src/
Core/
Domain/
Entities/
ValueObjects/
Events/
Application/
Services/
Interfaces/
Infrastructure/
Persistence/
EfRepositories/
DapperRepositories/
UnitOfWork/
Config/
Api/
Controllers/
DTOs/
- Core contains domain logic and application services. It knows nothing about EF Core or Dapper.
- Infrastructure implements repositories and data access logic with EF Core and Dapper.
- Api exposes endpoints, orchestrating application services.
This separation ensures that EF Core and Dapper remain implementation details, not polluting the domain.
4.2 Dependency Injection Done Right
Dependency Injection (DI) ensures flexibility and testability. Both EF Core’s DbContext and Dapper’s IDbConnection should be registered properly in Program.cs.
4.2.1 Registering EF Core
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.CommandTimeout(30); // seconds
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
}));
Why:
AddDbContextPoolimproves throughput by pooling contexts.EnableRetryOnFailureadds resilience against transient SQL errors.- Setting
CommandTimeoutavoids hanging queries.
Caveat: Never store a pooled DbContext in static fields or reuse it across threads—it’s not thread-safe.
4.2.2 Registering Dapper’s IDbConnection
There are two safe patterns:
(a) Use a small factory for isolated queries
Prefer this for services that don’t require a shared transaction:
builder.Services.AddScoped<IDbConnectionFactory>(sp =>
{
var connString = builder.Configuration.GetConnectionString("DefaultConnection");
return new SqlConnectionFactory(connString);
});
public interface IDbConnectionFactory
{
IDbConnection Create();
}
public class SqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connString;
public SqlConnectionFactory(string connString) => _connString = connString;
public IDbConnection Create() => new SqlConnection(_connString);
}
Usage: using var conn = factory.Create();
Dapper will open the connection if it’s closed. Do not manually dispose if the connection is owned by EF Core.
(b) Resolve from DbContext inside a transaction scope
For cross-ORM transactions, resolve the shared connection directly from EF Core’s DbContext:
builder.Services.AddScoped<IDbConnection>(sp =>
{
var context = sp.GetRequiredService<AppDbContext>();
return context.Database.GetDbConnection(); // shared with EF
});
Guidance:
- Only resolve this inside a transaction scope.
- Always pass
transaction.GetDbTransaction()to Dapper calls to ensure alignment. - Do not dispose the connection—it’s owned by EF Core.
4.3 Sharing Connections and Transactions
When EF Core and Dapper must participate in the same transaction:
await using var transaction = await context.Database.BeginTransactionAsync();
var connection = context.Database.GetDbConnection();
await connection.ExecuteAsync(
"DELETE FROM Orders WHERE CreatedOn < @Date",
new { Date = DateTime.UtcNow.AddYears(-1) },
transaction: transaction.GetDbTransaction());
context.Customers.Add(new Customer("NewCo"));
await context.SaveChangesAsync();
await transaction.CommitAsync();
Best practice: Always pass transaction.GetDbTransaction() explicitly to Dapper.
This guarantees both EF Core and Dapper operate on the same transaction boundary.
4.4 A Hybrid Repository and Unit of Work
Repositories abstract the ORM choice, exposing only domain-relevant operations. Splitting into command (EF Core) and query (Dapper) repositories reinforces CQRS.
4.4.1 Interfaces
public interface ICommandRepository<T> where T : class
{
Task AddAsync(T entity);
void Update(T entity);
void Remove(T entity);
}
public interface IQueryRepository<T>
{
Task<T?> GetByIdAsync(int id);
Task<IReadOnlyList<T>> GetAllAsync();
}
4.4.2 Implementations
EF Core-based command repository:
public class EfCommandRepository<T> : ICommandRepository<T> where T : class
{
private readonly AppDbContext _context;
public EfCommandRepository(AppDbContext context) => _context = context;
public async Task AddAsync(T entity) => await _context.Set<T>().AddAsync(entity);
public void Update(T entity) => _context.Set<T>().Update(entity);
public void Remove(T entity) => _context.Set<T>().Remove(entity);
}
Dapper-based query repository:
public class DapperQueryRepository<T> : IQueryRepository<T>
{
private readonly IDbConnectionFactory _factory;
private readonly string _tableName;
public DapperQueryRepository(IDbConnectionFactory factory)
{
_factory = factory;
_tableName = typeof(T).Name + "s";
}
public async Task<T?> GetByIdAsync(int id)
{
using var conn = _factory.Create();
return await conn.QuerySingleOrDefaultAsync<T>(
$"SELECT * FROM {_tableName} WHERE Id = @Id", new { Id = id });
}
public async Task<IReadOnlyList<T>> GetAllAsync()
{
using var conn = _factory.Create();
return (await conn.QueryAsync<T>($"SELECT * FROM {_tableName}")).ToList();
}
}
4.4.3 Unit of Work
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context) => _context = context;
public async Task<int> CommitAsync() => await _context.SaveChangesAsync();
public async Task ExecuteInTransactionAsync(Func<Task> operation)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await operation();
await transaction.CommitAsync();
}
}
This pattern ensures that EF Core and Dapper queries participate in the same transaction safely.
5 The Read Side in Practice: Unleashing Dapper’s Raw Speed
The read side of an application is often its performance bottleneck. Reports, dashboards, search endpoints, and list views are queried orders of magnitude more frequently than complex write operations. While EF Core can certainly execute queries, the change tracker and abstraction layers add latency and memory overhead. Dapper, by contrast, translates raw SQL directly into strongly typed objects, eliminating unnecessary steps. In this section, we’ll dive into concrete examples of how Dapper delivers efficient, predictable reads.
5.1 Simple Queries: Fetching a List of DTOs
The bread and butter of Dapper is straightforward queries returning DTOs. Unlike EF Core, where developers often retrieve full entity objects, Dapper encourages mapping directly to lightweight DTOs tailored to the use case.
public record ProductListDto(int Id, string Name, decimal Price);
public class ProductQueryService
{
private readonly IDbConnection _connection;
public ProductQueryService(IDbConnection connection) => _connection = connection;
public async Task<IReadOnlyList<ProductListDto>> GetAllProductsAsync(CancellationToken ct = default)
{
const string sql = "SELECT Id, Name, Price FROM Products ORDER BY Name";
var products = await _connection.QueryAsync<ProductListDto>(new CommandDefinition(sql, cancellationToken: ct));
return products.ToList();
}
}
Why this matters: We explicitly select Id, Name, Price rather than SELECT *. This avoids fetching unnecessary columns (e.g., audit metadata, binary blobs), saving network bandwidth and memory.
5.2 Complex Projections and Joins
Real-world queries rarely involve a single table. A product listing might require category and supplier details. Dapper allows us to write the precise SQL we need.
public record ProductDetailsDto(
int Id,
string Name,
string Category,
int Stock,
string SupplierName);
public class ProductQueryService
{
private readonly IDbConnection _connection;
public ProductQueryService(IDbConnection connection) => _connection = connection;
public async Task<IReadOnlyList<ProductDetailsDto>> GetProductDetailsAsync(CancellationToken ct = default)
{
var sql = @"
SELECT p.Id, p.Name, c.Name AS Category, p.Stock, s.Name AS SupplierName
FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
INNER JOIN Suppliers s ON p.SupplierId = s.Id
ORDER BY p.Name";
var products = await _connection.QueryAsync<ProductDetailsDto>(
new CommandDefinition(sql, cancellationToken: ct));
return products.ToList();
}
}
No need for EF Core’s entity graphs or query splitting—just a single optimized query projecting directly into a DTO.
5.3 Multi-Mapping with splitOn
Sometimes we do want to hydrate related objects (e.g., an Order with its Customer). Dapper’s multi-mapping API handles this neatly:
public class Order { public int Id { get; set; } public DateTime CreatedOn { get; set; } public Customer Customer { get; set; } = null!; }
public class Customer { public int Id { get; set; } public string Name { get; set; } = null!; }
public async Task<IReadOnlyList<Order>> GetRecentOrdersAsync(CancellationToken ct = default)
{
var sql = @"
SELECT o.Id, o.CreatedOn,
c.Id, c.Name
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
ORDER BY o.CreatedOn DESC";
var orders = await _connection.QueryAsync<Order, Customer, Order>(
new CommandDefinition(sql, cancellationToken: ct),
(order, customer) =>
{
order.Customer = customer;
return order;
},
splitOn: "Id"); // tells Dapper where Customer starts
return orders.ToList();
}
⚠️ Note: Always provide splitOn to avoid ambiguous mappings. Dapper depends on column order, so ensure IDs or foreign keys appear where expected.
One-to-Many Aggregation
For collections (e.g., orders with items), a dictionary aggregator avoids duplicates:
public class OrderWithItems { public int Id { get; set; } public List<OrderItem> Items { get; } = new(); }
public class OrderItem { public int Id { get; set; } public string ProductName { get; set; } = null!; }
public async Task<IReadOnlyList<OrderWithItems>> GetOrdersWithItemsAsync(CancellationToken ct = default)
{
var sql = @"
SELECT o.Id, oi.Id, oi.ProductName
FROM Orders o
INNER JOIN OrderItems oi ON o.Id = oi.OrderId";
// Pre-size dictionary if you expect large counts
var orderDict = new Dictionary<int, OrderWithItems>(capacity: 5000);
await _connection.QueryAsync<OrderWithItems, OrderItem, OrderWithItems>(
new CommandDefinition(sql, cancellationToken: ct),
(order, item) =>
{
if (!orderDict.TryGetValue(order.Id, out var existing))
{
existing = order;
orderDict.Add(order.Id, existing);
}
existing.Items.Add(item);
return existing;
},
splitOn: "Id");
return orderDict.Values.ToList();
}
Pre-sizing the dictionary reduces reallocations for high-volume queries.
5.4 Dynamic Queries with Builders
Many search endpoints need optional filters. Instead of string concatenation, use a builder pattern for clean SQL composition.
public async Task<IReadOnlyList<Product>> SearchProductsAsync(
string? category, decimal? maxPrice, CancellationToken ct = default)
{
var builder = new SqlBuilder();
var selector = builder.AddTemplate("SELECT Id, Name, Price FROM Products /**where**/ ORDER BY Name");
if (!string.IsNullOrEmpty(category))
builder.Where("Category = @Category", new { Category = category });
if (maxPrice.HasValue)
builder.Where("Price <= @MaxPrice", new { MaxPrice = maxPrice.Value });
return (await _connection.QueryAsync<Product>(
new CommandDefinition(selector.RawSql, selector.Parameters, cancellationToken: ct))).ToList();
}
This keeps repository code tidy and parameters safe.
5.5 Leveraging Stored Procedures
Enterprises often rely on stored procedures for encapsulating logic or optimizing performance. Dapper integrates seamlessly:
public async Task<decimal> CalculateCustomerBalanceAsync(int customerId, CancellationToken ct = default)
{
return await _connection.ExecuteScalarAsync<decimal>(
new CommandDefinition("usp_CalculateCustomerBalance",
new { CustomerId = customerId },
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}
For result sets:
public async Task<IReadOnlyList<OrderSummaryDto>> GetCustomerOrdersAsync(int customerId, CancellationToken ct = default)
{
return (await _connection.QueryAsync<OrderSummaryDto>(
new CommandDefinition("usp_GetCustomerOrders",
new { CustomerId = customerId },
commandType: CommandType.StoredProcedure,
cancellationToken: ct)))
.ToList();
}
Same QueryAsync API—just switch commandType to StoredProcedure.
6 The Write Side in Practice: EF Core’s Transactional Integrity
While Dapper dominates reads, EF Core is the natural fit for writes. Writes usually involve multiple related entities, invariants, and rules that must be enforced consistently. EF Core’s change tracker, concurrency controls, and entity modeling tools make this side safer and more maintainable.
6.1 Standard CUD Operations
The simplest operations—create, update, and delete—are elegantly expressed through EF Core’s DbSet.
public class CustomerService
{
private readonly AppDbContext _context;
public CustomerService(AppDbContext context) => _context = context;
public async Task<int> CreateCustomerAsync(string name, CancellationToken ct = default)
{
var customer = new Customer { Name = name, IsActive = true };
_context.Customers.Add(customer);
await _context.SaveChangesAsync(ct);
return customer.Id;
}
public async Task UpdateCustomerAsync(int id, string newName, CancellationToken ct = default)
{
var customer = await _context.Customers.FindAsync([id], ct);
if (customer == null) throw new InvalidOperationException("Customer not found");
customer.Name = newName;
await _context.SaveChangesAsync(ct);
}
public async Task DeleteCustomerAsync(int id, CancellationToken ct = default)
{
var customer = await _context.Customers.FindAsync([id], ct);
if (customer != null)
{
_context.Customers.Remove(customer);
await _context.SaveChangesAsync(ct);
}
}
}
Compared to Dapper, there’s no need to manually construct SQL or handle parameter binding. EF Core abstracts these details while maintaining transactional guarantees.
6.2 The Power of the Change Tracker
The change tracker becomes indispensable when operations involve multiple entities. Imagine processing an order: creating the order, updating inventory, recording an invoice, and adjusting the customer’s status.
public async Task ProcessOrderAsync(int customerId, List<(int ProductId, int Qty)> items, CancellationToken ct = default)
{
var customer = await _context.Customers.FindAsync([customerId], ct);
if (customer == null) throw new InvalidOperationException();
var order = new Order(customerId);
_context.Orders.Add(order);
foreach (var (productId, qty) in items)
{
var product = await _context.Products.FindAsync([productId], ct);
if (product == null || product.Stock < qty)
throw new InvalidOperationException("Insufficient stock.");
product.Stock -= qty;
order.Items.Add(new OrderItem(productId, qty, product.Price));
}
var invoice = new Invoice(order.Id, order.TotalAmount);
_context.Invoices.Add(invoice);
customer.LastOrderDate = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
}
Here, EF Core tracks changes across Customer, Product, Order, and Invoice. A single call to SaveChangesAsync generates the correct SQL and commits everything atomically.
6.3 Transactions and Unit of Work Consistency
From §4, our Unit of Work abstraction can be extended to surface EF Core’s transaction so mixed EF + Dapper operations share the same boundary:
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context) => _context = context;
public async Task ExecuteInTransactionAsync(Func<IDbContextTransaction, Task> operation, CancellationToken ct = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
await operation(transaction);
await transaction.CommitAsync(ct);
}
}
Usage:
await _unitOfWork.ExecuteInTransactionAsync(async tx =>
{
// EF Core write
var customer = new Customer("Hybrid Corp");
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
// Dapper write sharing EF’s transaction
var conn = _context.Database.GetDbConnection();
await conn.ExecuteAsync(
"INSERT INTO AuditLog (Message) VALUES (@msg)",
new { msg = "Customer created" },
transaction: tx.GetDbTransaction());
});
Both ORMs participate in a single, consistent transaction.
6.4 Validation and Invariants
Business rules shouldn’t live only in the database. Command handlers often combine EF Core with:
- FluentValidation for input validation.
- Domain events for cross-aggregate invariants or side effects.
Example:
public class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand>
{
public CreateCustomerCommandValidator()
{
RuleFor(c => c.Name).NotEmpty().MaximumLength(200);
}
}
Validation runs before EF Core persists changes, ensuring only valid aggregates are saved.
6.5 Concurrency Control with Retries
Multi-user applications risk conflicting updates. EF Core’s optimistic concurrency support uses a rowversion column to detect conflicts.
public class Product
{
public int Id { get; set; }
public int Stock { get; set; }
[Timestamp] public byte[] RowVersion { get; set; } = null!;
}
If two updates collide, EF Core throws DbUpdateConcurrencyException. A retry loop with backoff resolves transient conflicts:
public async Task<bool> ReduceStockAsync(int productId, int qty, CancellationToken ct = default)
{
for (var attempt = 0; attempt < 3; attempt++)
{
var product = await _context.Products.FindAsync([productId], ct);
if (product == null || product.Stock < qty) return false;
product.Stock -= qty;
try
{
await _context.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateConcurrencyException)
{
await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1)), ct);
_context.Entry(product).State = EntityState.Detached; // clear stale entity
}
}
return false;
}
This gives the app resilience under concurrent writes without compromising integrity.
6.6 Owned Entity Types
Domain models often include value objects that don’t need their own tables. EF Core’s owned entity types simplify this mapping.
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public Address Address { get; set; } = null!;
}
[Owned]
public class Address
{
public string Street { get; set; } = null!;
public string City { get; set; } = null!;
public string PostalCode { get; set; } = null!;
}
EF Core flattens the Address fields into the Customers table. Updates are seamless:
customer.Address = new Address("123 Main St", "Seattle", "98101");
await _context.SaveChangesAsync();
- Bulk operations: The ultimate performance play
At some point in every enterprise application, you will need to process large amounts of data—whether migrating records, importing files, or executing batch updates. This is where EF Core’s elegance shows its limits. While EF Core excels in transaction safety and domain modeling, its default bulk operation capabilities lag behind optimized database-native methods. This section covers the challenges, cross-provider options, and cautions for production use.
7.1 The EF Core problem
EF Core’s AddRange, UpdateRange, and RemoveRange methods provide a convenient API for batch operations. However, under the hood, EF Core treats each entity individually. When you call SaveChanges(), EF Core generates an individual INSERT, UPDATE, or DELETE statement per entity.
var customers = new List<Customer>();
for (int i = 0; i < 10_000; i++)
{
customers.Add(new Customer { Name = $"Customer {i}", IsActive = true });
}
_context.Customers.AddRange(customers);
await _context.SaveChangesAsync();
This code results in 10,000 separate INSERT statements. Even with EF Core 9’s batching improvements, overhead remains high compared to a single bulk insert. What should take seconds can balloon into minutes, saturating the database with round trips and locks.
7.2 Provider-specific strategies
Different databases expose different primitives for high-throughput inserts and updates. A matrix of common options:
| Provider | Bulk insert options |
|---|---|
| SQL Server | Table-Valued Parameters (TVPs), SqlBulkCopy |
| PostgreSQL | COPY FROM STDIN, NpgsqlBinaryImporter, UNNEST with arrays |
| MySQL/MariaDB | Multi-row INSERT, LOAD DATA INFILE |
| SQLite | Transaction + batched INSERT (no dedicated bulk API) |
Each approach trades simplicity for raw speed. SQL Server TVPs and PostgreSQL COPY are especially efficient because they stream rows in a single round trip.
7.3 Dapper with SQL Server TVPs
SQL Server’s TVPs allow passing thousands of rows as a structured parameter.
- Define a user-defined table type:
CREATE TYPE [dbo].[CustomerTableType] AS TABLE
(
[Name] NVARCHAR(100) NOT NULL,
[IsActive] BIT NOT NULL
);
- Create a stored procedure:
CREATE PROCEDURE [dbo].[InsertCustomers]
@Customers CustomerTableType READONLY
AS
BEGIN
INSERT INTO Customers (Name, IsActive)
SELECT Name, IsActive FROM @Customers;
END
- Call from Dapper:
public async Task BulkInsertCustomersAsync(IEnumerable<Customer> customers, CancellationToken ct = default)
{
var dt = new DataTable();
dt.Columns.Add("Name", typeof(string));
dt.Columns.Add("IsActive", typeof(bool));
foreach (var c in customers)
dt.Rows.Add(c.Name, c.IsActive);
var parameters = new DynamicParameters();
parameters.Add("@Customers", dt.AsTableValuedParameter("CustomerTableType"));
await _connection.ExecuteAsync(
new CommandDefinition("InsertCustomers", parameters, commandType: CommandType.StoredProcedure, cancellationToken: ct));
}
7.4 Open-source libraries
DapperPlus
A fluent API for bulk insert, update, delete, and merge.
await _connection.BulkInsert(customers);
await _connection.BulkUpdate(customers);
Commercial but widely adopted in enterprise scenarios.
EFCore.BulkExtensions
Stays inside the EF Core ecosystem.
await _context.BulkInsertAsync(customers);
await _context.BulkUpdateAsync(customers);
Respects EF model configuration, popular in community projects.
7.5 Cautions in production
Bulk APIs bypass parts of EF Core’s pipeline. This can cause surprises:
- Triggers: Bulk inserts fire database triggers; verify that they scale.
- Identity/auto-increment: Libraries must handle reseeding identity columns. Some require
IDENTITY_INSERT. - Defaults/computed columns: Values may not be populated back into entities automatically.
- Navigation properties: Bulk APIs often ignore relationships unless explicitly configured.
Plan for these differences when mixing EF Core and bulk libraries.
7.6 Transactions with bulk operations
When combining EF Core and bulk APIs in the same unit of work, wrap them in a single IDbContextTransaction. Always pass the transaction explicitly to Dapper or the bulk library:
await using var tx = await _context.Database.BeginTransactionAsync();
var conn = _context.Database.GetDbConnection();
await conn.ExecuteAsync(
"DELETE FROM TempTable",
transaction: tx.GetDbTransaction());
await _context.BulkInsertAsync(customers, new BulkConfig { UseTempDB = true });
await tx.CommitAsync();
This ensures consistency: both EF Core and the bulk operation succeed or fail together.
8 Advanced strategies for the hybrid architect
A hybrid architecture is not just about picking EF Core for writes and Dapper for reads. Mature systems require careful handling of transactions, caching, and query patterns to maintain both performance and consistency. This section covers advanced strategies to elevate your architecture from functional to production-ready.
8.1 End-to-end transaction management
One challenge of mixing EF Core and Dapper is ensuring that both participate in the same transaction. EF Core exposes IDbContextTransaction to control transactions. By sharing the underlying DbConnection, we can coordinate both tools.
public async Task ExecuteHybridTransactionAsync(CancellationToken ct = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
var connection = _context.Database.GetDbConnection();
await connection.ExecuteAsync(
"UPDATE Products SET Stock = Stock - 1 WHERE Id = @Id",
new { Id = 42 },
transaction: transaction.GetDbTransaction());
var order = new Order { ProductId = 42, Quantity = 1 };
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
Passing the EF transaction explicitly into Dapper ensures atomicity: if either operation fails, all changes roll back.
8.2 Implementing a resilient caching layer
Performance is not only about database speed. Reducing roundtrips with caching can yield order-of-magnitude improvements. The cache-aside pattern is a natural fit: check cache first, if absent, query the database (via Dapper), then populate cache.
8.2.1 Unified caching approach
To avoid confusion, this article standardizes on IDistributedCache for most cases. IMemoryCache remains useful for single-instance apps or tests, but in distributed or cloud-native environments, use IDistributedCache backed by Redis.
8.2.2 Example decorator with Redis
public class CachedProductRepository : IQueryRepository<Product>
{
private readonly IQueryRepository<Product> _inner;
private readonly IDistributedCache _cache;
public CachedProductRepository(IQueryRepository<Product> inner, IDistributedCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
{
var cacheKey = $"v2:product:{id}";
var cached = await _cache.GetStringAsync(cacheKey, ct);
if (cached != null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _inner.GetByIdAsync(id, ct);
if (product != null)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), options, ct);
}
return product;
}
public Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken ct = default)
=> _inner.GetAllAsync(ct);
}
Notes:
- Cache keys are versioned (
v2:product:{id}) so deployments can invalidate old entries. - Time-to-live defaults: hot lists 30–60 seconds; product details 5–15 minutes; tune per endpoint.
8.3 Maintaining consistency
Caching introduces the challenge of stale data. A common solution is to raise domain events when entities change and invalidate corresponding cache keys.
public class ProductUpdatedHandler : INotificationHandler<ProductUpdatedEvent>
{
private readonly IDistributedCache _cache;
public ProductUpdatedHandler(IDistributedCache cache) => _cache = cache;
public async Task Handle(ProductUpdatedEvent notification, CancellationToken ct)
{
await _cache.RemoveAsync($"v2:product:{notification.ProductId}", ct);
}
}
This ensures EF Core writes immediately invalidate cached entries.
8.4 Strategic query splitting
EF Core 9 introduced query splitting to reduce the risk of cartesian explosions when loading related data. While helpful, it may still generate multiple roundtrips. Dapper’s QueryMultiple allows precise control.
public async Task<(Customer, List<Order>)> GetCustomerWithOrdersAsync(int customerId, CancellationToken ct = default)
{
var sql = @"
SELECT Id, Name FROM Customers WHERE Id = @Id;
SELECT Id, CustomerId, Total FROM Orders WHERE CustomerId = @Id;
";
using var multi = await _connection.QueryMultipleAsync(new CommandDefinition(sql, new { Id = customerId }, cancellationToken: ct));
var customer = await multi.ReadSingleAsync<Customer>();
var orders = (await multi.ReadAsync<Order>()).ToList();
return (customer, orders);
}
This fetches related data in one roundtrip without the N+1 problem.
8.5 Observability and metrics
Advanced systems must measure performance, not just optimize it. With OpenTelemetry, you can trace database spans and tag the ORM used.
using var activity = _activitySource.StartActivity("Query:RecentOrders");
activity?.SetTag("db.system", "mssql");
activity?.SetTag("orm", "dapper");
var orders = await _connection.QueryAsync<Order>(
new CommandDefinition(sql, cancellationToken: ct));
Expose p95 latency per repository and per ORM in your monitoring system. This makes it clear where EF Core is “fast enough” and where Dapper provides measurable wins.
9 Case study: Building a high-performance e-commerce dashboard API
Theory and patterns are valuable, but the best way to internalize a hybrid EF Core and Dapper strategy is to walk through a concrete case study. Let’s consider a real-world scenario: building a dashboard API for an e-commerce platform. This dashboard is heavily read-focused, must respond in milliseconds, and yet still requires reliable transactional integrity for writes.
We’ll design the API endpoint, implement the write side with EF Core, build the read side with Dapper, and finally tie it all together with caching and dependency injection.
9.1 The scenario
Our e-commerce platform needs a dashboard endpoint at GET /api/dashboard. The requirements are:
- Display total sales revenue for the last 30 days.
- Show the five most recent orders with customer names and totals.
- List products with low inventory (stock < 10).
At the same time, the platform must support standard write operations: creating new orders and updating inventory. This makes the hybrid approach ideal: EF Core will handle the transactional writes, while Dapper will handle the performance-sensitive dashboard reads.
9.2 The write operations (EF Core)
9.2.1 Creating a new order
POST /api/orders places a new order. EF Core shines here because we’re dealing with multiple entities: Order, OrderItem, Product, and Customer.
[ApiController]
[Route("api/orders")]
[Authorize(Roles = "admin,manager")]
public class OrdersController : ControllerBase
{
private readonly AppDbContext _context;
private readonly IMediator _mediator;
public OrdersController(AppDbContext context, IMediator mediator)
{
_context = context;
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderDto dto, CancellationToken ct)
{
var customer = await _context.Customers.FindAsync([dto.CustomerId], ct);
if (customer is null) return NotFound("Customer not found.");
var order = new Order(dto.CustomerId);
foreach (var item in dto.Items)
{
var product = await _context.Products.FindAsync([item.ProductId], ct);
if (product is null || product.Stock < item.Quantity)
return BadRequest($"Insufficient stock for Product {item.ProductId}");
product.Stock -= item.Quantity;
order.Items.Add(new OrderItem(item.ProductId, item.Quantity, product.Price));
}
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
await _mediator.Publish(new OrderCreatedEvent(order.Id), ct);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order.Id);
}
[HttpGet("{id}")]
public async Task<ActionResult<Order>> GetOrder(int id, CancellationToken ct)
{
var order = await _context.Orders.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
if (order == null) return NotFound();
return order;
}
}
Domain events (OrderCreatedEvent) trigger cache invalidation later. Note the [Authorize] attribute, ensuring only authenticated, authorized users can place orders.
9.2.2 Updating inventory
[HttpPut("/api/products/{id}/stock")]
[Authorize(Roles = "admin")]
public async Task<IActionResult> UpdateStock(int id, UpdateStockDto dto, CancellationToken ct)
{
var product = await _context.Products.FindAsync([id], ct);
if (product == null) return NotFound();
product.Stock = dto.NewStock;
await _context.SaveChangesAsync(ct);
await _mediator.Publish(new ProductUpdatedEvent(product.Id), ct);
return NoContent();
}
EF Core enforces invariants at write time. The event system ensures caches stay consistent.
9.3 The read operation (Dapper)
The dashboard endpoint is where Dapper demonstrates its speed.
9.3.1 The view model
public record DashboardViewModel(
decimal TotalSales,
List<RecentOrderDto> RecentOrders,
List<LowInventoryDto> LowInventory);
public record RecentOrderDto(int OrderId, string CustomerName, decimal Total, DateTime CreatedOn);
public record LowInventoryDto(int ProductId, string Name, int Stock);
9.3.2 Optimized SQL query
We use QueryMultiple for a single roundtrip. Note that we avoid SQL Server–specific functions like GETUTCDATE() by passing the timestamp from the application layer.
public class DashboardRepository
{
private readonly IDbConnection _connection;
public DashboardRepository(IDbConnection connection) => _connection = connection;
public async Task<DashboardViewModel> GetDashboardAsync(DateTime utcNow, CancellationToken ct = default)
{
var sql = @"
-- Total Sales (Order totals are assumed invariant; for absolute accuracy consider summing OrderItems)
SELECT SUM(Total) AS TotalSales
FROM Orders
WHERE CreatedOn >= @Since;
-- Recent Orders
SELECT TOP 5 o.Id AS OrderId, c.Name AS CustomerName, o.Total, o.CreatedOn
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
ORDER BY o.CreatedOn DESC;
-- Low Inventory
SELECT Id AS ProductId, Name, Stock
FROM Products
WHERE Stock < 10
ORDER BY Stock ASC;
";
using var multi = await _connection.QueryMultipleAsync(
new CommandDefinition(sql, new { Since = utcNow.AddDays(-30) }, cancellationToken: ct));
var totalSales = await multi.ReadSingleAsync<decimal>();
var recentOrders = (await multi.ReadAsync<RecentOrderDto>()).ToList();
var lowInventory = (await multi.ReadAsync<LowInventoryDto>()).ToList();
return new DashboardViewModel(totalSales, recentOrders, lowInventory);
}
}
The note on SUM(Total): Order totals are correct if enforced on write. For stricter correctness, calculate from OrderItems or enforce invariants during order creation.
9.3.3 Caching for performance
The dashboard is a high-traffic endpoint. Add Redis caching for efficiency.
public class CachedDashboardRepository
{
private readonly DashboardRepository _inner;
private readonly IDatabase _redis;
public CachedDashboardRepository(DashboardRepository inner, IConnectionMultiplexer redis)
{
_inner = inner;
_redis = redis.GetDatabase();
}
public async Task<DashboardViewModel> GetDashboardAsync(DateTime utcNow, CancellationToken ct = default)
{
const string key = "v2:dashboard:summary";
var cached = await _redis.StringGetAsync(key);
if (!cached.IsNullOrEmpty)
return JsonSerializer.Deserialize<DashboardViewModel>(cached)!;
var dashboard = await _inner.GetDashboardAsync(utcNow, ct);
await _redis.StringSetAsync(key, JsonSerializer.Serialize(dashboard), TimeSpan.FromSeconds(60));
return dashboard;
}
}
Keys are versioned (v2:dashboard:summary) for safe invalidation across deployments. TTL here is short (60 seconds) since dashboards change rapidly.
9.3.4 Cache invalidation with domain events
public class DashboardCacheInvalidator :
INotificationHandler<OrderCreatedEvent>,
INotificationHandler<ProductUpdatedEvent>
{
private readonly IDatabase _redis;
public DashboardCacheInvalidator(IConnectionMultiplexer redis) => _redis = redis.GetDatabase();
public Task Handle(OrderCreatedEvent notification, CancellationToken ct)
=> _redis.KeyDeleteAsync("v2:dashboard:summary");
public Task Handle(ProductUpdatedEvent notification, CancellationToken ct)
=> _redis.KeyDeleteAsync("v2:dashboard:summary");
}
Whenever writes occur, cache entries are cleared to prevent serving stale data.
9.4 Tying it all together
Dependency injection setup:
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IDbConnection>(sp =>
{
var context = sp.GetRequiredService<AppDbContext>();
return context.Database.GetDbConnection();
});
builder.Services.AddScoped<DashboardRepository>();
builder.Services.AddScoped<CachedDashboardRepository>();
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")));
builder.Services.AddMediatR(typeof(Program));
builder.Services.AddAuthorization();
The controller:
[ApiController]
[Route("api/dashboard")]
[Authorize(Roles = "admin")]
public class DashboardController : ControllerBase
{
private readonly CachedDashboardRepository _repository;
public DashboardController(CachedDashboardRepository repository) => _repository = repository;
[HttpGet]
public async Task<DashboardViewModel> GetDashboard(CancellationToken ct)
=> await _repository.GetDashboardAsync(DateTime.UtcNow, ct);
}
This setup achieves:
- Reliable writes with EF Core’s transactional integrity
- Blazing-fast reads with Dapper and
QueryMultiple - Caching with Redis for scalability
- Consistency through event-driven cache invalidation
- Security with parameterized queries and authorization on admin endpoints
- Common pitfalls and anti-patterns to avoid
A hybrid approach with EF Core and Dapper unlocks productivity and performance. But like all powerful tools, misuse can cause subtle bugs, degraded performance, and long-term maintainability issues. This section explores the most common pitfalls teams face when implementing a dual-ORM strategy, along with practical advice and examples to avoid them.
10.1 The leaky abstraction
One of the worst mistakes in repository design is returning IQueryable from repositories. While it may feel convenient, it leaks EF Core’s query-building API into upper layers, breaking encapsulation. This creates tight coupling, makes testing harder, and often results in poorly optimized SQL.
Incorrect:
public interface ICustomerRepository
{
IQueryable<Customer> GetCustomers();
}
public class CustomerRepository : ICustomerRepository
{
private readonly AppDbContext _context;
public CustomerRepository(AppDbContext context) => _context = context;
public IQueryable<Customer> GetCustomers() => _context.Customers;
}
Correct:
public interface ICustomerRepository
{
Task<IReadOnlyList<CustomerDto>> GetActiveCustomersAsync();
}
public class CustomerRepository : ICustomerRepository
{
private readonly AppDbContext _context;
public CustomerRepository(AppDbContext context) => _context = context;
public async Task<IReadOnlyList<CustomerDto>> GetActiveCustomersAsync()
{
return await _context.Customers
.Where(c => c.IsActive)
.Select(c => new CustomerDto(c.Id, c.Name))
.ToListAsync();
}
}
Repositories should return specific results (DTOs) rather than leaking EF Core abstractions.
10.2 Transaction mishandling
When EF Core and Dapper share a database, they must also share transactions. A common anti-pattern is forgetting to enlist Dapper operations into EF Core’s transaction.
Incorrect:
var product = await _context.Products.FindAsync(1);
product.Stock -= 1;
await _context.SaveChangesAsync();
// Dapper query runs outside EF Core transaction
await _connection.ExecuteAsync("INSERT INTO AuditLogs ...");
Correct:
await using var transaction = await _context.Database.BeginTransactionAsync();
var product = await _context.Products.FindAsync(1);
product.Stock -= 1;
await _context.SaveChangesAsync();
await _connection.ExecuteAsync(
"INSERT INTO AuditLogs ...",
transaction: transaction.GetDbTransaction());
await transaction.CommitAsync();
Both operations succeed or fail together.
10.3 The schizophrenic repository
Mixing EF Core writes and Dapper reads in the same repository method creates unclear responsibilities and harder debugging.
Incorrect:
public async Task<Order?> PlaceOrderAsync(Order order)
{
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Mixed with Dapper
return await _connection.QuerySingleOrDefaultAsync<Order>(
"SELECT * FROM Orders WHERE Id = @Id", new { order.Id });
}
Correct:
public async Task<int> PlaceOrderAsync(Order order)
{
_context.Orders.Add(order);
await _context.SaveChangesAsync();
return order.Id;
}
// Separate Dapper service
public async Task<Order?> GetOrderByIdAsync(int id)
{
return await _connection.QuerySingleOrDefaultAsync<Order>(
"SELECT Id, CreatedOn, Total FROM Orders WHERE Id = @Id", new { Id = id });
}
Apply CQRS: EF Core for commands, Dapper for queries.
10.4 Ignoring the change tracker
Performing Dapper updates on entities that EF Core is currently tracking can leave the context out of sync with the database.
Incorrect:
var product = await _context.Products.FindAsync(1);
// Dapper update bypasses EF Core
await _connection.ExecuteAsync(
"UPDATE Products SET Stock = Stock - 5 WHERE Id = @Id", new { Id = 1 });
// EF Core still holds the stale Stock value
await _context.SaveChangesAsync();
Correct:
// Stick with EF Core
var product = await _context.Products.FindAsync(1);
product.Stock -= 5;
await _context.SaveChangesAsync();
// Or detach and use Dapper
_context.Entry(product).State = EntityState.Detached;
await _connection.ExecuteAsync(
"UPDATE Products SET Stock = Stock - 5 WHERE Id = @Id", new { Id = 1 });
Always make sure the change tracker is aware of modifications or intentionally bypassed.
10.5 Unsafe table name construction
A generic Dapper repository often builds table names using string concatenation:
_tableName = typeof(T).Name + "s";
This is risky because:
- Pluralization rules are inconsistent.
- Names may collide with reserved SQL keywords.
- Unescaped identifiers risk SQL injection if type names are not controlled.
Safer alternatives:
- Use schema-qualified, bracketed identifiers (
[dbo].[Customers]). - Use attributes like
[Table("Customers")]to map explicitly. - Drop the generic “one repo fits all” pattern and write feature-specific repositories with explicit SQL.
10.6 Async pitfalls
Blocking on asynchronous database calls with .Result or .Wait() can lead to deadlocks in ASP.NET Core.
Incorrect:
var orders = _connection.QueryAsync<Order>("SELECT ...").Result;
Correct:
var orders = await _connection.QueryAsync<Order>("SELECT ...");
Always propagate async all the way up.
10.7 The “Dapper everything” fallacy
Dapper is fast, but not everything needs it. Using it for trivial lookups adds unnecessary complexity.
Over-optimized:
var customer = await _connection.QuerySingleAsync<Customer>(
"SELECT Id, Name FROM Customers WHERE Id = @Id", new { Id = id });
Pragmatic:
var customer = await _context.Customers.FindAsync(id);
Reserve Dapper for performance-critical queries, bulk operations, or complex projections. For simple operations, EF Core is more than sufficient.
11 Conclusion: The Best of Both Worlds
The hybrid strategy isn’t about choosing EF Core or Dapper—it’s about choosing both, in the right contexts. Senior developers, tech leads, and architects who adopt this mindset gain flexibility and resilience. By leveraging EF Core’s productivity for writes and Dapper’s performance for reads, you create systems that scale gracefully without sacrificing maintainability.
11.1 Recap of the Hybrid Philosophy
- EF Core excels at writes: It provides change tracking, transactional integrity, and migrations that simplify complex business logic.
- Dapper excels at reads: It maps raw SQL to POCOs with minimal overhead, making it perfect for dashboards, reports, and projections.
- The hybrid approach avoids extremes: No ORM is perfect for all scenarios. Blending both yields pragmatic performance without overcomplicating simple tasks.
11.2 Final Decision Tree
When deciding which tool to use, apply this checklist:
- Is it a CUD operation with multiple entities? → Use EF Core.
- Does it require transaction management or change tracking? → Use EF Core.
- Is it a read-heavy, performance-critical query? → Use Dapper.
- Do you need bulk inserts, updates, or deletes? → Use Dapper or a bulk library.
- Is it a simple lookup by primary key? → Use EF Core for simplicity.
- Does it call a stored procedure or require advanced SQL features? → Use Dapper.
This decision tree helps keep choices consistent across the team.
11.3 The Future is Pragmatic
As EF Core continues to improve performance and Dapper continues to dominate as a micro-ORM, the most pragmatic path is to combine them. Senior engineers know that architecture is not about dogma—it’s about trade-offs. By embracing the hybrid mindset, you’re not just optimizing code, you’re optimizing for team velocity, scalability, and long-term maintainability.
The hallmark of technical maturity is choosing the right tool for the job, not forcing one tool into every situation.
11.4 Further Resources
For deeper dives and practical reference, explore the following resources:
- Entity Framework Core Documentation: https://learn.microsoft.com/ef/core/
- Dapper GitHub Repository: https://github.com/DapperLib/Dapper
- DapperPlus (ZZZ Projects): https://entityframework-plus.net/
- EFCore.BulkExtensions: https://github.com/borisdj/EFCore.BulkExtensions
- MediatR for CQRS and events: https://github.com/jbogard/MediatR
- BenchmarkDotNet for measuring ORM performance: https://benchmarkdotnet.org/
A companion GitHub repository with all examples from this article is available here: [GitHub Repository Link – Example Hybrid EF Core + Dapper Project] (provide in production setup).
By combining theory, patterns, and real-world code, you now have a playbook for hybrid EF Core and Dapper architecture—ready to apply in your next high-performance .NET system.