EF Core earns its keep on the write side: change tracking, migrations, and LINQ that follows your model. But some endpoints are pure reads — a dashboard tile, a report, a search — where you know exactly the SQL you want and every millisecond counts. That's where Dapper shines: a micro-ORM that runs your SQL and maps rows to objects, nothing more. This part adds a Dapper read path beside the EF Core repository from part 2, in the same clean-architecture solution.

EF Core and Dapper are teammates, not rivals
The pattern used by many production teams (including this blog's own platform) is:
- Writes and aggregate loads → EF Core repository. You want tracking, transactions, and invariants.
- Hot reads and reports → Dapper. You want the SQL you wrote, with zero materialization overhead you didn't ask for.
Because part 1 put ports in the application layer, adding a second data technology touches nothing above infrastructure:
public interface IProductReadQueries
{
Task<IReadOnlyList<ProductSummary>> GetLowStockAsync(int threshold, CancellationToken ct = default);
}
// long: SQLite reports integers as Int64, and Dapper matches constructor types exactly.
public record ProductSummary(long Id, string Name, long Stock);
A read model, not a domain entity — three columns, exactly what the endpoint returns. No Reserve() method, no price. Read models are allowed to be boring.
The Dapper implementation
public class DapperProductReadQueries(string connectionString) : IProductReadQueries
{
public async Task<IReadOnlyList<ProductSummary>> GetLowStockAsync(int threshold, CancellationToken ct = default)
{
await using var connection = new SqliteConnection(connectionString);
var rows = await connection.QueryAsync<ProductSummary>(
"select Id, Name, Stock from Products where Stock < @threshold order by Stock asc",
new { threshold });
return rows.ToList();
}
}
That's the whole adapter. Registration:
builder.Services.AddSingleton<IProductReadQueries>(new DapperProductReadQueries(connectionString));
Singleton is fine here because the class holds only a string; connections open and close per call, and ADO.NET connection pooling makes that cheap.
Verified output from the running API:
GET /products/low-stock
{"items":[{"id":3,"name":"USB-C dock","stock":0},{"id":2,"name":"4K monitor","stock":1}]}
Parameters: the non-negotiable part
Dapper hands you raw SQL, which hands you SQL injection if you concatenate. The rule has no exceptions: values go in as parameters (@threshold, bound via the anonymous object). Dapper handles lists too — where Id in @ids with new { ids = new[] {1, 2, 3} } expands safely. The only thing you may ever build dynamically is SQL structure (an optional order by chosen from a whitelist), never values.
Mapping gotchas worth knowing
- Constructor matching is exact. Positional records must match column types precisely — SQLite returns 64-bit integers, so
ProductSummary(int Id, ...)throws a materialization error while(long Id, ...)works. (We hit this for real while building the sample; the error message names the expected signature, so read it literally.) - Column names map case-insensitively to property/parameter names; use SQL aliases (
select p.Name as ProductName) when they differ. - Multi-mapping (
QueryAsync<Order, Customer, Order>) joins parent/child in one query with asplitOncolumn — powerful for read models that span tables.
Multi-mapping: joins into object graphs
Real read models span tables. Dapper's multi-mapping folds a join into parent/child objects in one round trip. Given orders and their customers:
const string sql = """
select o.Id, o.PlacedAt, o.Total,
c.Id, c.Name, c.Email
from Orders o
join Customers c on c.Id = o.CustomerId
where o.PlacedAt >= @since
""";
var orders = await connection.QueryAsync<OrderRow, CustomerRow, OrderRow>(
sql,
(order, customer) => order with { Customer = customer },
new { since },
splitOn: "Id");
splitOn names the column where the next object begins (the second Id here). For one-to-many shapes (an order with its lines), query the flat join and group in memory with GroupBy, or issue two queries with QueryMultipleAsync — both beat N+1 loops, and the SQL stays visible and explainable to your DBA.
Performance notes that matter in practice
- Buffered by default. Dapper materializes the full result before returning. For large exports pass
buffered: falseto stream rows — but then the connection stays busy until you finish iterating. - Async all the way.
QueryAsyncfrees the thread during I/O exactly like EF Core; there's no async penalty for choosing Dapper. - Connection pooling does the heavy lifting. Opening a
SqliteConnection/SqlConnectionper call looks wasteful but is a pool checkout, not a TCP handshake. Don't cache open connections in singletons — that fights the pool and breaks under load. - Measure before migrating. The honest win of Dapper over EF Core on a well-written LINQ query is often 10–30%, not 10×. The 10× wins come from rewriting the SQL — fewer round trips, better indexes, narrower selects — which Dapper encourages by putting SQL in your face.
Rule of thumb: reach for Dapper when you would have reached for FromSqlRaw anyway; keep EF Core when LINQ expresses the query naturally.
Keeping raw SQL maintainable
Raw SQL's real cost isn't writing it — it's finding it eight months later. Habits that keep the read layer healthy:
- One class per read context (
ProductReadQueries,OrderReadQueries), SQL inconst stringfields at the top — greppable, reviewable, close to its mapping type. - Read models are records named for their screen or report (
ProductSummary,MonthlyRevenueRow), never reused across unrelated endpoints. Two screens sharing a model couple two screens. - Schema changes: your SQL doesn't participate in EF migrations, so add a smoke integration test per query — run it against the migrated test database in CI and a renamed column fails the build instead of production.
- Version-control the SQL like code, because it is code. No SQL assembled in string builders scattered across services.
Transactions and mixing with EF Core
When a Dapper statement must join an EF Core transaction, share the connection:
var connection = db.Database.GetDbConnection();
await db.Database.BeginTransactionAsync(ct);
await connection.ExecuteAsync("update Products set Stock = Stock - 1 where Id = @id",
new { id }, db.Database.CurrentTransaction!.GetDbTransaction());
Use sparingly — if writes routinely need raw SQL, that's a signal the EF model needs work, not more Dapper.
When to reach for which
| Situation | Tool |
|---|---|
| Aggregate load + invariant + save | EF Core repository |
| Screen/report needing 3 columns from 4 tables | Dapper read query |
| Bulk import of 100k rows | Neither — bulk copy APIs |
| Query you keep hand-tuning with the DBA | Dapper (the SQL is the artifact) |
| Multi-tenant filters, soft deletes on every query | EF Core global query filters |
The unglamorous truth: EF Core's SQL is fine for 90% of queries, and the remaining 10% dominate your latency budget. Dapper gives that 10% a clean home — behind a port, invisible to your domain.
Next
Reads are fast and writes are safe, but the API is wide open — anyone can POST products. Part 4 locks it down with JWT authentication: issuing tokens, validating them, and role-based authorization, all verified with real 401/403/200 responses.
Comments (0)
No comments yet — be the first to share your thoughts.