Dapper + EF Core Hybrid in ASP.NET Core: The Right Tool for Each Job

Most .NET teams pick one data access tool and stick with it. EF Core for everything, or Dapper for everything. But the most performant production systems often use both โ EF Core where its strengths matter (writes, migrations, change tracking) and Dapper where raw query speed matters (complex reads, reports, bulk lookups). This guide walks through exactly when and how to combine them in a single ASP.NET Core application without making a mess.
The complete working implementation โ a full ASP.NET Core Web API using EF Core and Dapper side by side, with repository interfaces, shared DbConnection, and a realistic product catalogue scenario โ is available on Patreon. It's production-ready code you can clone, run, and adapt directly.
Why Not Just Use One?
Before getting into implementation, it's worth being clear about why you'd want both in the first place.
EF Core excels at writes. Its change tracker knows exactly which properties changed, it generates the right UPDATE statement, it handles concurrency tokens, and it coordinates transactions across multiple entities. Migrations give you a version-controlled schema history. For any operation that modifies state, EF Core's abstractions save significant time and reduce bugs.
Dapper excels at reads. It maps raw SQL results to objects with almost zero overhead. No change tracker, no lazy-loading traps, no AsNoTracking() to remember. You write SQL, you get results, you move on. For complex joins, reporting queries, or any read path that needs to be as fast as possible, Dapper consistently outperforms EF Core by a significant margin.
The decision is not either/or. Use EF Core for writes and Dapper for complex reads. This pattern gives you the best of both in a clean, maintainable way.
The Core Idea: Shared Connection, Separate Responsibilities
The key to making this work cleanly is that both EF Core and Dapper use the same database connection. EF Core's DbContext exposes its underlying DbConnection via Database.GetDbConnection(). Dapper works directly with any IDbConnection. So you can share the connection โ and the transaction โ between both tools without any extra infrastructure.
// Get the raw connection from EF Core's DbContext
var connection = _dbContext.Database.GetDbConnection();
// Dapper works with this directly
var results = await connection.QueryAsync<ProductSummary>(sql, parameters);
What this means in practice: You do not need two connection strings, two connection pools, or two separate database configurations. One
DbContextregistration, one connection pool, both tools.
Setting Up the Hybrid Repository
The cleanest approach is to have two repository interfaces โ one for writes (EF Core) and one for reads (Dapper) โ or a single repository that uses each tool for the appropriate operation.
Here is the pattern using a single repository:
// IProductRepository.cs
public interface IProductRepository
{
// Writes โ EF Core
Task AddAsync(Product product, CancellationToken ct = default);
Task UpdateAsync(Product product, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
// Simple reads โ EF Core (identity map, change tracking useful)
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
// Complex reads โ Dapper (raw SQL, optimised projections)
Task<IEnumerable<ProductSummary>> GetSummariesAsync(ProductFilter filter, CancellationToken ct = default);
Task<PagedResult<ProductSummary>> GetPagedAsync(ProductQueryParams query, CancellationToken ct = default);
}
The rule is simple: if the result is going to be tracked and modified, use EF Core. If you're projecting into a DTO for a read endpoint, use Dapper.
Implementing the Repository
// ProductRepository.cs
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
// โโ Writes via EF Core โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public async Task AddAsync(Product product, CancellationToken ct = default)
{
_context.Products.Add(product);
await _context.SaveChangesAsync(ct);
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
{
// EF Core โ result is tracked, suitable for subsequent writes
return await _context.Products.FindAsync([id], ct);
}
// โโ Reads via Dapper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public async Task<IEnumerable<ProductSummary>> GetSummariesAsync(
ProductFilter filter, CancellationToken ct = default)
{
var conn = _context.Database.GetDbConnection();
const string sql = """
SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName
FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
WHERE (@CategoryId IS NULL OR p.CategoryId = @CategoryId)
AND (@IsActive IS NULL OR p.IsActive = @IsActive)
ORDER BY p.Name
""";
return await conn.QueryAsync<ProductSummary>(
sql,
new { filter.CategoryId, filter.IsActive },
commandTimeout: 30);
}
}
Common mistake: Forgetting to open the connection before Dapper uses it. EF Core manages the connection lifetime automatically, but when you extract it for Dapper, it may be closed. Add this before Dapper queries:
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
Sharing Transactions
One of the most powerful aspects of the shared connection approach is that both EF Core and Dapper can participate in the same transaction. This matters when you need to write via EF Core and query via Dapper in the same unit of work.
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
try
{
// Write via EF Core
_context.Products.Add(newProduct);
await _context.SaveChangesAsync(ct);
// Read via Dapper โ same transaction
var conn = _context.Database.GetDbConnection();
var summary = await conn.QueryFirstOrDefaultAsync<ProductSummary>(
"SELECT Id, Name FROM Products WHERE Id = @Id",
new { newProduct.Id },
transaction: transaction.GetDbTransaction());
await transaction.CommitAsync(ct);
return summary;
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
When to Use Which โ Decision Guide
| Scenario | Use |
|---|---|
| Creating, updating, or deleting entities | EF Core |
| Loading an entity to modify and save | EF Core |
Simple GetById that may lead to a write |
EF Core |
| Projecting into a DTO for an API response | Dapper |
| Complex multi-table joins | Dapper |
Pagination with COUNT + SELECT |
Dapper |
| Reporting queries with aggregations | Dapper |
Queries with dynamic WHERE clauses |
Dapper |
| Bulk read operations (thousands of rows) | Dapper |
Avoiding the Pitfalls
Double-tracking: Never load an entity with Dapper and then attach it to EF Core's change tracker. If you need to update an entity, always load it with EF Core's FindAsync or FirstOrDefaultAsync.
N+1 via Dapper: Dapper gives you raw SQL, so you're responsible for writing efficient queries. A foreach loop with a Dapper query inside is just as bad as EF Core's lazy loading trap. Use QueryMultiple or JOINs to load related data in a single round trip.
Connection state: As noted above โ always check and open the connection before Dapper uses it. EF Core may have closed it between operations.
Different models: EF Core works with your domain entities. Dapper works best with lightweight read models (DTOs). Keep them separate โ do not use Dapper to populate the same Product class that EF Core tracks.
Registering in DI
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
That's it. No separate Dapper registration needed โ ProductRepository gets AppDbContext injected and extracts the connection from it when needed.
Frequently Asked Questions
Does using Dapper with EF Core's connection break EF Core's behaviour?
No. Dapper reads the connection but does not interfere with EF Core's change tracker, identity map, or transaction state. They operate independently on the same connection.
Should I use Dapper for all read queries?
Not necessarily. Simple GetById or GetBySlug queries are fine in EF Core with AsNoTracking(). Use Dapper when the query involves complex joins, projections into DTOs, or performance is a measurable concern.
Can I use Dapper with a different database than EF Core?
The shared connection approach requires the same database. If you need queries against a different database, Dapper can use a separate connection entirely โ just inject it separately via IConfiguration.
Is this approach compatible with unit testing?
Yes. Because both tools go through the repository interface, your unit tests mock IProductRepository and never touch either tool directly. Integration tests can use an in-memory SQLite database or Testcontainers for either tool.
What about Dapper's multi-mapping for complex joins?
Dapper's QueryAsync<TFirst, TSecond, TReturn> multi-mapping works perfectly in this pattern. The connection is the same SqlConnection or NpgsqlConnection โ all Dapper features are available.
Does the hybrid approach add significant complexity?
The added complexity is minimal โ one extra method call to get the connection, and the discipline to choose the right tool for each operation. Most teams find the separation of reads and writes into appropriate tools simplifies reasoning about performance.
โ If this guide helped, consider buying us a coffee โ it keeps the content coming!





