TCIS.Persistence.Dapper
1.0.0-rc.19
dotnet add package TCIS.Persistence.Dapper --version 1.0.0-rc.19
NuGet\Install-Package TCIS.Persistence.Dapper -Version 1.0.0-rc.19
<PackageReference Include="TCIS.Persistence.Dapper" Version="1.0.0-rc.19" />
<PackageVersion Include="TCIS.Persistence.Dapper" Version="1.0.0-rc.19" />
<PackageReference Include="TCIS.Persistence.Dapper" />
paket add TCIS.Persistence.Dapper --version 1.0.0-rc.19
#r "nuget: TCIS.Persistence.Dapper, 1.0.0-rc.19"
#:package TCIS.Persistence.Dapper@1.0.0-rc.19
#addin nuget:?package=TCIS.Persistence.Dapper&version=1.0.0-rc.19&prerelease
#tool nuget:?package=TCIS.Persistence.Dapper&version=1.0.0-rc.19&prerelease
TCIS.Persistence.Dapper
The read path of the TCIS ecosystem: a Dapper wrapper with self-managed connection lifetime, built-in pagination, multiple result sets in a single round trip, and an escape hatch for the awkward cases.
Use
TCIS.Persistence.EntityFrameworkCorefor writes. That package already registers this one — no manual wiring needed.
Table of contents
| Section | Contents |
|---|---|
| 1 | When to use Dapper, and when to use EF Core |
| 2 | Installation and registration |
| 3 | Basic queries |
| 4 | Pagination |
| 5 | Multiple result sets in one round trip |
| 6 | Multi-mapping — joining tables |
| 7 | ExecuteCustom — the escape hatch |
| 8 | Connection strings and read replicas |
| 9 | ⚠️ Two things you must know |
| 10 | Pitfalls |
| 11 | Writing tests |
1. When to use Dapper, and when to use EF Core
| Task | Use |
|---|---|
| Insert / update / delete an entity | EF Core (IUnitOfWork + IGenericRepository) |
| Load an entity by key, or load it in order to modify it | EF Core |
| Reports, list screens with several joins, heavy queries | Dapper |
| Projecting a few columns across several tables | Dapper |
| You need precise control over the SQL and the execution plan | Dapper |
EF Core is strong on the write path (change tracking, transactions, auditing) but generates poor SQL for read queries with many joins. Dapper gives you full control — along with full responsibility for tenant isolation, which EF otherwise handles for you (see section 9).
2. Installation and registration
dotnet add package TCIS.Persistence.Dapper
Option 1 — alongside EF Core (recommended)
AddTCorePersistence already registers IDbConnectionFactory and IDapperContext. Nothing else to do.
Option 2 — standalone (read-only service)
using Microsoft.Data.SqlClient;
builder.Services.AddOptions<PersistenceOptions>()
.Bind(builder.Configuration.GetSection("ConfigurationStore"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddScoped<IDbConnectionFactory>(sp =>
new DefaultDbConnectionFactory(
sp.GetRequiredService<IOptions<PersistenceOptions>>(),
cs => new SqlConnection(cs)));
builder.Services.AddScoped<IDapperContext, DapperContext>();
3. Basic queries
public sealed class VesselReportQuery(IDapperContext db)
{
private const string Sql = """
SELECT v.Id, v.VesselName, v.Eta, b.BerthCode
FROM VesselVisits v
JOIN Berths b ON b.Id = v.BerthId
WHERE v.Eta >= @From AND v.Eta < @To
ORDER BY v.Eta
""";
public Task<IEnumerable<VesselVisitDto>> RunAsync(DateOnly from, DateOnly to, CancellationToken ct)
=> db.QueryAsync<VesselVisitDto>(Sql, new { From = from, To = to }, ct: ct);
}
| Method | Returns |
|---|---|
QueryAsync<T> |
IEnumerable<T> — many rows |
QueryFirstOrDefaultAsync<T> |
T? — first row, null when there is none |
QuerySingleOrDefaultAsync<T> |
T? — throws when more than one row comes back |
ExecuteAsync |
int — rows affected |
ExecuteScalarAsync<T> |
T? — a single cell |
Every method accepts param, commandTimeout and ct:
var count = await db.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM VesselVisits WHERE Status = @Status",
new { Status = "ARRIVED" },
commandTimeout: 60,
ct: ct);
Always pass values through an anonymous parameter object; never concatenate them into the SQL — that is a SQL injection hole (
DB-034).
4. Pagination
private const string CountSql = "SELECT COUNT(1) FROM VesselVisits WHERE Status = @Status";
private const string DataSql = """
SELECT Id, VesselName, Eta
FROM VesselVisits
WHERE Status = @Status
ORDER BY Eta -- REQUIRED: without ORDER BY the page is undefined
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
PagedResult<VesselVisitDto> page = await db.QueryPageAsync<VesselVisitDto>(
CountSql, DataSql,
pageIndex: 2, pageSize: 20,
param: new { Status = "ARRIVED", Offset = 20, PageSize = 20 },
ct: ct);
PagedResult<T> exposes TotalPages, HasPreviousPage and HasNextPage out of the box.
Two optimisations are built in: one connection serves both queries, and the data query is skipped entirely when TotalCount is 0.
⚠️ Both queries run on the same connection but not in a shared transaction, so
TotalCountandItemsare two snapshots taken at two moments. A write landing between them makes the total disagree with the rows returned — acceptable for a list screen, not acceptable when the number is used for reconciliation. When you need a consistent figure, useExecuteCustomAsyncwith a transaction (section 7).
5. Multiple result sets in one round trip
Instead of three trips to the database, make one:
const string Sql = """
SELECT Id, VesselName FROM VesselVisits WHERE Id = @Id;
SELECT Id, ContainerNo FROM Containers WHERE VisitId = @Id;
SELECT Id, Amount FROM Charges WHERE VisitId = @Id;
""";
var (visits, containers, charges) =
await db.QueryMultipleAsync<VesselDto, ContainerDto, ChargeDto>(Sql, new { Id = visitId }, ct: ct);
Overloads exist for 2, 3 and 4 result sets. The order of the type parameters must match the order of the SELECT statements.
6. Multi-mapping — joining tables
const string Sql = """
SELECT o.Id, o.OrderNo, c.Id, c.CustomerName
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
""";
var orders = await db.QueryAsync<OrderDto, CustomerDto, OrderDto>(
Sql,
map: (order, customer) => { order.Customer = customer; return order; },
splitOn: "Id", // the column where the second object starts
ct: ct);
splitOn names the first column of the next object — it defaults to "Id". For more objects, separate the names with commas: splitOn: "Id,Id".
7. ExecuteCustom — the escape hatch
For anything the standard surface does not cover: a complex GridReader, a bulk insert, or several statements inside one transaction.
// Count and fetch within the SAME snapshot
var page = await db.ExecuteCustomAsync(async conn =>
{
using var tx = ((DbConnection)conn).BeginTransaction(IsolationLevel.Snapshot);
var total = await conn.ExecuteScalarAsync<int>(CountSql, param, tx);
var items = await conn.QueryAsync<VesselVisitDto>(DataSql, param, tx);
tx.Commit();
return new PagedResult<VesselVisitDto> { TotalCount = total, Items = items };
}, ct);
The connection is handed to the callback already open, and is closed automatically when the callback finishes — including when it throws. Do not dispose it yourself.
8. Connection strings and read replicas
DefaultDbConnectionFactory picks the string in this order:
ReadConnectionString (when set and not blank)
↓ absent
ConnectionString
↓ also absent
TInvalidConfigException — code CONFIG_MISSING → HTTP 500
The check uses IsNullOrWhiteSpace, so "ReadConnectionString": "" in appsettings still falls back correctly to the primary string.
Pointing
ReadConnectionStringat a replica means accepting replication lag: a read issued right after a write may return stale data. A "write then read it back to display it" flow should read from the primary string.
In the multi-tenant Pluggable stack the connection string does not come from appsettings at all — it is resolved per request from the Tenant Store. See TCIS.Pluggable.Persistence.*.
9. ⚠️ Two things you must know
9.1. Dapper has none of EF Core's tenant filtering
IDapperContext emits raw SQL. EF Core's Global Query Filter takes no part in this path — the SQL you write is the SQL that runs.
// ❌ DATA LEAK — reads across every tenant
await db.QueryAsync<OrderDto>("SELECT * FROM Orders");
// ✅ Add the tenant predicate yourself
await db.QueryAsync<OrderDto>(
"SELECT * FROM Orders WHERE TenantId = @TenantId",
new { TenantId = workContext.Tenant.TenantId });
On Tier 1 the remaining line of defence is Row-Level Security in the database — and it only defends if the DBA has enabled it. On Tier 3 nothing stops it: a perfectly innocent-looking query reads across tenants. That is why this is a rule, not a suggestion.
9.2. Dapper does not take part in an IUnitOfWork transaction
Every call opens its own connection and closes it again. Inside an open EF transaction, a Dapper call:
- cannot see data EF has written but not committed;
- is not rolled back when the transaction is abandoned;
- may block until
CommandTimeoutexpires if it reads rows the EF transaction holds locks on — and because this is not a deadlock, the database will not break it up.
When you need raw SQL inside the transaction, borrow EF's connection:
var conn = dbContext.Database.GetDbConnection();
var tx = dbContext.Database.CurrentTransaction?.GetDbTransaction();
await conn.ExecuteAsync(sql, param, transaction: tx);
The upside of this design: because the two sides use separate sessions, the read path runs in parallel with the write path without competing for a connection.
10. Pitfalls
| # | Pitfall | Consequence |
|---|---|---|
| 1 | Concatenating user input into SQL | SQL injection (DB-034) — always parameterise |
| 2 | Paging without ORDER BY |
Pages repeat or skip rows, silently |
| 3 | QueryAsync over a large table with no row limit |
Pulls the whole table into memory (DB-030) |
| 4 | QuerySingleOrDefaultAsync on a query that may return several rows |
Throws at runtime |
| 5 | Disposing the connection yourself inside ExecuteCustomAsync |
The package already owns it — double disposal |
| 6 | Writing through Dapper and expecting it to join the UoW transaction | It does not — see section 9.2 |
| 7 | Reading after writing when pointed at a replica | Stale data because of replication lag |
Inputs are validated at the boundary: a blank or null sql, or a null callback, throws TValidationException (GUARD_EMPTY / GUARD_NULL → HTTP 422) carrying the provider name, instead of a NullReferenceException from deep inside Dapper.
11. Writing tests
Use a shared SQLite in-memory database (cache=shared) so each call still opens its own connection — exactly as DapperContext does — while seeing the same data:
var cs = $"DataSource=file:{Guid.NewGuid():N}?mode=memory&cache=shared";
using var keepAlive = new SqliteConnection(cs); // keeps the database alive
keepAlive.Open();
keepAlive.Execute("CREATE TABLE rows (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL);");
var db = new DapperContext(new SqliteFactory(cs));
var rows = await db.QueryAsync<Row>("SELECT Id, Name FROM rows");
SQLite returns
INTEGERcolumns aslong. Use a class with property setters, not a positional record — Dapper only coerces types on the setter path; the constructor path demands an exact signature match.
See TCIS.Persistence.Dapper.Tests for a fuller example: queries, pagination, multiple result sets, parallel execution, and connection release on the failure path.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Dapper (>= 2.1.66)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- TCIS.Persistence.Abstractions (>= 1.0.0-rc.19)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on TCIS.Persistence.Dapper:
| Package | Downloads |
|---|---|
|
TCIS.Persistence.EntityFrameworkCore
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Core Persistence EntityFrameworkCore implementation. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.19 | 0 | 8/13/2026 |
| 1.0.0-rc.18 | 0 | 8/13/2026 |
| 1.0.0-rc.17 | 0 | 8/13/2026 |
| 1.0.0-rc.16 | 0 | 8/13/2026 |
| 1.0.0-rc.15 | 34 | 8/12/2026 |
| 1.0.0-rc.14 | 38 | 8/12/2026 |
| 1.0.0-rc.13 | 50 | 8/11/2026 |
| 1.0.0-rc.12 | 60 | 8/10/2026 |
| 1.0.0-rc.11 | 77 | 7/28/2026 |
| 1.0.0-rc.10 | 82 | 7/24/2026 |
| 1.0.0-rc.9 | 72 | 7/21/2026 |
| 1.0.0-rc.8 | 68 | 7/21/2026 |
| 1.0.0-rc.7 | 61 | 7/17/2026 |