TCIS.Persistence.Dapper 1.0.0-rc.19

This is a prerelease version of TCIS.Persistence.Dapper.
dotnet add package TCIS.Persistence.Dapper --version 1.0.0-rc.19
                    
NuGet\Install-Package TCIS.Persistence.Dapper -Version 1.0.0-rc.19
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TCIS.Persistence.Dapper" Version="1.0.0-rc.19" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.Persistence.Dapper" Version="1.0.0-rc.19" />
                    
Directory.Packages.props
<PackageReference Include="TCIS.Persistence.Dapper" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TCIS.Persistence.Dapper --version 1.0.0-rc.19
                    
#r "nuget: TCIS.Persistence.Dapper, 1.0.0-rc.19"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TCIS.Persistence.Dapper@1.0.0-rc.19
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TCIS.Persistence.Dapper&version=1.0.0-rc.19&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.Persistence.Dapper&version=1.0.0-rc.19&prerelease
                    
Install as a Cake Tool

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.EntityFrameworkCore for 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

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 TotalCount and Items are 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, use ExecuteCustomAsync with 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 ReadConnectionString at 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 CommandTimeout expires 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 INTEGER columns as long. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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