Dapper
Dapper is a thin, fast micro-ORM for .NET: you write the SQL, Dapper handles parameterisation and maps the result rows to your types. It gives you near-hand-written performance and total control over the query, with none of the change-tracking machinery of a full ORM. We use it where read performance and SQL control matter — typically the query side — alongside EF Core on the write side.
Where EF Core generates SQL for you, Dapper expects you to provide it and simply removes the boilerplate of `DbCommand`, parameters, and `DataReader`-to-object mapping. There is no change tracker, no lazy loading, no hidden query generation — what you write is what runs. That makes it fast and predictable, and ideal for read-heavy, performance-sensitive, or SQL-shaped work.
The trade-off is that you own the SQL: its correctness, its parameterisation, and its maintenance. Dapper will faithfully execute an injectable query if you build one by string concatenation, so the discipline around input is on you. A common and healthy pattern is CQRS-shaped: EF Core for commands (rich domain writes), Dapper for queries (fast, flat read models). See CQRS, SQL Performance Tuning, and Trust Boundaries.
Always parameterise
- AlwaysPass user input as Dapper parameters, never by string concatenation or interpolation into the SQL text. Parameters are your injection defence and they are effortless in Dapper — there is no excuse to skip them (see Trust Boundaries).
- DoUse anonymous objects or typed parameter objects for parameters, and let Dapper bind them. This is also faster, because the database can reuse the query plan.
- NeverBuilding SQL like `$"... WHERE Name = '{name}'"`. That is a textbook SQL-injection vulnerability, and in an AML/KYC system it is a path to mass data exfiltration.
var sql = $"SELECT * FROM Customers WHERE Email = '{email}'";
var rows = conn.Query(sql);
// email = "x' OR '1'='1" -> returns every customer
User input is concatenated into the query text. An attacker rewrites the WHERE clause and reads the whole table. Dapper executes it faithfully.
const string sql = "SELECT Id, Email, RiskLevel FROM Customers WHERE Email = @Email";
var rows = await conn.QueryAsync(sql, new { Email = email });
The input is bound as a parameter — never part of the SQL text — so injection is impossible, the plan is cached, and only the needed columns are read.
Use it where it fits
- DoReach for Dapper on read-heavy, performance-critical, or complex-SQL paths — reports, dashboards, list queries — where you want a flat read model and full control (see CQRS).
- DoMap results to purpose-built read DTOs, not domain entities. Dapper read models are display shapes; keep your invariant-protecting domain types on the EF write side.
- ConsiderKeeping non-trivial SQL in well-named, reviewed places (constants, embedded resources, or stored procedures) rather than scattered inline strings, so it can be read and tuned (see SQL Performance Tuning).
- AvoidUsing Dapper for complex transactional write graphs where EF Core's change tracking and unit-of-work would save real effort. Pick the tool per use case, not per dogma.
Connections, async, and resilience
- AlwaysOpen connections in a `using`/`await using` so they return to the pool; never leak a `DbConnection`. Connection-pool exhaustion is a classic outage cause (see Designing for Failure).
- DoUse the async APIs (`QueryAsync`, `ExecuteAsync`) and flow a `CancellationToken`; don't block on synchronous calls on request threads.
- DoWrap multi-statement writes in an explicit transaction and pass it to each command, so partial writes can't leave inconsistent state (see Data Integrity & Transactions).
Self-review checklist
- AskIs every piece of user input a parameter, with zero string-built SQL?
- AskAm I mapping to a flat read DTO rather than a domain entity?
- AskIs the connection disposed, and are multi-statement writes inside a transaction?
- AskIs this genuinely a read/perf/SQL-control case — or would EF Core's unit-of-work serve a transactional write better?