Migrating from Dapper to RepoDB
Published:
Even RepoDB is not just another ORM anymore, what started as a Dapper-shaped micro-ORM has grown into a layered platform — see RepoDB’s New Architecture — with its own provider-specific connectors, high-throughput Bulk/Batch operations, and an Insights stack for telemetry sitting above the same core CRUD surface. None of that changes the migration path below — the CRUD layer this post covers is still the part that talks to IDbConnection the same way Dapper does — but it’s worth knowing upfront that migrating gets you a foundation to grow into, not just a like-for-like swap.
Both libraries both sit directly on top of IDbConnection — no context, no change-tracker, no proxy objects. That similarity is what makes a migration from one to the other unusually mechanical: most Dapper calls have a same-shaped RepoDB equivalent, and in a few cases the method name doesn’t even change. This post walks through that migration call by call, with the Dapper code on top and the RepoDB replacement underneath, so you can use it as a working reference while you migrate.
None of this is a knock on Dapper. It’s been the go-to micro-ORM for .NET since 2011, built and battle-tested at StackOverflow.com’s scale, and the design ideas it popularized — thin extension methods over IDbConnection, no magic, no ceremony — are the same ideas RepoDB itself was built on. If this post is useful to you, credit is due to Dapper and the Stack Overflow team for setting that bar in the first place.
If you want the “why” behind this move first, see our older comparison, What will make you choose RepoDB over Dapper. This post assumes you’ve already decided and is focused purely on the how.
🧑💻 Developer experience: what actually changes day-to-day
Before the code, it’s worth being upfront about what migrating actually feels like, because “same-shaped API” doesn’t mean “identical experience.”
- Less hand-written SQL for everyday CRUD. Dapper always needs a SQL string, even for
SELECT * FROM Customer WHERE Id = @Id. RepoDB’s fluent layer (QueryAll,Query(e => e.Id == id),Insert,Update,Delete,Merge) removes that string for the common single-table cases, so there’s less SQL to review, less SQL to typo, and fewer places a column rename in the database silently breaks a string literal at runtime instead of compile time. - No hand-rolled batching or bulk code. In a Dapper codebase,
InsertAll-shaped logic andSqlBulkCopy-shaped logic are things you wrote and now maintain. In RepoDB they’re one call —InsertAll,BulkInsert— so that code simply isn’t in your codebase to review, test, or debug. - The same escape hatch when you need it.
ExecuteQuery,ExecuteNonQuery, andExecuteScalarmean you’re never locked out of raw SQL — complex reporting queries, vendor-specific syntax, and hand-tuned statements work exactly like they did in Dapper. - One thing gets harder, not easier: multi-mapped joins. Dapper’s
splitOn-based multi-mapping has no RepoDB equivalent — you compose the result client-side via multi-query instead (covered below). If your codebase leans heavily onQuery<TFirst, TSecond, TReturn>, budget real review time for that part, not a mechanical pass. - More surface area once you’re in. RepoDB ships things Dapper simply doesn’t have — a 2nd-level cache, tracing/logging hooks, inline hints, telemetry — so the day-to-day experience shifts from “reach for a NuGet package or write it yourself” to “it’s already there, turn it on.” More on that at the end of this post.
Net effect: routine CRUD gets shorter and safer, bulk/batch work stops being something you own, and the one place that gets genuinely harder — joins — is small and well-contained.
Tables and models used throughout
CREATE TABLE [dbo].[Customer]
(
[Id] BIGINT IDENTITY(1,1)
, [Name] NVARCHAR(128) NOT NULL
, [Address] NVARCHAR(MAX)
, CONSTRAINT [PK_Customer] PRIMARY KEY CLUSTERED ([Id] ASC)
);
CREATE TABLE [dbo].[Order]
(
[Id] BIGINT IDENTITY(1,1)
, [ProductId] BIGINT NOT NULL
, [CustomerId] BIGINT NOT NULL
, [OrderDateUtc] DATETIME(5)
, [Quantity] INT
, CONSTRAINT [PK_Order] PRIMARY KEY CLUSTERED ([Id] ASC)
);
public class Customer
{
public long Id { get; set; }
public string Name { get; set; }
public string Address { get; set; }
public List<Order> Orders { get; set; }
}
public class Order
{
public long Id { get; set; }
public long ProductId { get; set; }
public long CustomerId { get; set; }
public int Quantity { get; set; }
public DateTime OrderDateUtc { get; set; }
}
📦 Install the packages
> Uninstall-Package Dapper
> Install-Package RepoDb
> Install-Package RepoDb.SqlServer
> Install-Package RepoDb.SqlServer.BulkOperations
Swap RepoDb.SqlServer for whichever provider extension matches your database — RepoDb.PostgreSql, RepoDb.MySql, RepoDb.SqLite, RepoDb.Oracle, RepoDb.MariaDb, and so on all follow the same package-per-provider pattern. IDbConnection, SqlConnection, and everything else about how you obtain a connection stays exactly the same — RepoDB is a set of extension methods over IDbConnection, not a replacement for it.
🔧 Phase 1 — the mechanical swap (raw-SQL layer)
This is the fastest part of any migration: RepoDB ships a raw-SQL layer that mirrors Dapper’s Query/Execute/ExecuteScalar almost one-for-one. You can do a find-and-replace pass over a file without touching any SQL string, verify it compiles and the tests pass, and move on — no redesign required.
Query → ExecuteQuery
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.Query<Customer>("SELECT * FROM [dbo].[Customer];");
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.ExecuteQuery<Customer>("SELECT * FROM [dbo].[Customer];");
}
QueryFirstOrDefault / QuerySingleOrDefault → ExecuteQuery().FirstOrDefault()
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customer = connection.QueryFirstOrDefault<Customer>(
"SELECT * FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customer = connection.ExecuteQuery<Customer>(
"SELECT * FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 }).FirstOrDefault();
}
Execute → ExecuteNonQuery
Dapper
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.Execute(
"UPDATE [dbo].[Customer] SET Name = @Name, Address = @Address WHERE Id = @Id;",
new { Id = 10045, Name = "John Doe", Address = "New York" });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.ExecuteNonQuery(
"UPDATE [dbo].[Customer] SET Name = @Name, Address = @Address WHERE Id = @Id;",
new { Id = 10045, Name = "John Doe", Address = "New York" });
}
ExecuteScalar → ExecuteScalar
This one doesn’t even change names.
Dapper
using (var connection = new SqlConnection(connectionString))
{
var count = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM [dbo].[Customer];");
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var count = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM [dbo].[Customer];");
}
Async variants
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customers = await connection.QueryAsync<Customer>("SELECT * FROM [dbo].[Customer];");
var affectedRows = await connection.ExecuteAsync(
"DELETE FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customers = await connection.ExecuteQueryAsync<Customer>("SELECT * FROM [dbo].[Customer];");
var affectedRows = await connection.ExecuteNonQueryAsync(
"DELETE FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 });
}
Every RepoDB async method also accepts a cancellationToken argument, the same as the sync-to-async convention you’re used to from Dapper.
Stored procedures
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.Query<Customer>(
"sp_GetCustomersByAddress",
new { Address = "New York" },
commandType: CommandType.StoredProcedure);
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.ExecuteQuery<Customer>(
"sp_GetCustomersByAddress",
new { Address = "New York" },
commandType: CommandType.StoredProcedure);
}
Transactions
Both libraries hand transaction handling entirely to ADO.NET, so this code barely changes at all — only the method names from Phase 1 apply.
Dapper
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
connection.Execute("UPDATE [dbo].[Customer] SET Address = @Address WHERE Id = @Id;",
new { Id = 10045, Address = "California" }, transaction);
connection.Execute("INSERT INTO [dbo].[Order] (ProductId, CustomerId, Quantity, OrderDateUtc) VALUES (@ProductId, @CustomerId, @Quantity, @OrderDateUtc);",
new { ProductId = 1, CustomerId = 10045, Quantity = 2, OrderDateUtc = DateTime.UtcNow }, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
connection.ExecuteNonQuery("UPDATE [dbo].[Customer] SET Address = @Address WHERE Id = @Id;",
new { Id = 10045, Address = "California" }, transaction: transaction);
connection.ExecuteNonQuery("INSERT INTO [dbo].[Order] (ProductId, CustomerId, Quantity, OrderDateUtc) VALUES (@ProductId, @CustomerId, @Quantity, @OrderDateUtc);",
new { ProductId = 1, CustomerId = 10045, Quantity = 2, OrderDateUtc = DateTime.UtcNow }, transaction: transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
Every operation covered below — fluent, batch, and bulk alike — accepts the same transaction argument.
🧩 Phase 2 — adopt the fluent CRUD layer
Once Phase 1 compiles and your tests are green, you’re fully migrated and could stop there. But the raw-SQL layer is only half of what RepoDB gives you. The fluent layer removes the SQL string entirely for the common cases.
Querying all rows
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.Query<Customer>("SELECT * FROM [dbo].[Customer];");
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.QueryAll<Customer>();
}
Querying with a filter
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customer = connection.Query<Customer>(
"SELECT * FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 }).FirstOrDefault();
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customer = connection.Query<Customer>(e => e.Id == 10045).FirstOrDefault();
}
Inserting a record (with identity)
Dapper
using (var connection = new SqlConnection(connectionString))
{
var customer = new Customer { Name = "John Doe", Address = "New York" };
var id = connection.Query<long>(
"INSERT INTO [dbo].[Customer] (Name, Address) VALUES (@Name, @Address); SELECT CONVERT(BIGINT, SCOPE_IDENTITY());",
customer).Single();
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customer = new Customer { Name = "John Doe", Address = "New York" };
var id = connection.Insert<Customer, long>(customer);
}
Updating a record
Dapper
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.Execute(
"UPDATE [dbo].[Customer] SET Name = @Name, Address = @Address WHERE Id = @Id;",
new { Id = 10045, Name = "John Doe", Address = "New York" });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customer = new Customer { Id = 10045, Name = "John Doe", Address = "New York" };
var affectedRows = connection.Update(customer);
}
Deleting a record
Dapper
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.Execute("DELETE FROM [dbo].[Customer] WHERE Id = @Id;", new { Id = 10045 });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.Delete<Customer>(10045);
}
Upsert (insert-or-update)
Dapper has no built-in upsert — you write the MERGE statement by hand.
Dapper
using (var connection = new SqlConnection(connectionString))
{
var sql = @"MERGE [dbo].[Customer] AS T
USING (SELECT @Id AS Id, @Name AS Name, @Address AS Address) AS S
ON S.Id = T.Id
WHEN MATCHED THEN UPDATE SET Name = S.Name, Address = S.Address
WHEN NOT MATCHED THEN INSERT (Name, Address) VALUES (S.Name, S.Address)
OUTPUT INSERTED.Id;";
var id = connection.QuerySingle<long>(sql, new { Id = 10045, Name = "John Doe", Address = "New York" });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customer = new Customer { Id = 10045, Name = "John Doe", Address = "New York" };
var id = connection.Merge(customer, qualifiers: e => e.Id);
}
🗂️ Phase 3 — set-based operations
This is where Dapper hands you back to raw ADO.NET, and it’s usually the part of a Dapper codebase that’s hardest to maintain — every one of these had to be built by hand.
Inserting many rows
Dapper
using (var connection = new SqlConnection(connectionString))
{
foreach (var customer in customers)
{
connection.Execute(
"INSERT INTO [dbo].[Customer] (Name, Address) VALUES (@Name, @Address);", customer);
}
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.InsertAll(customers);
}
InsertAll batches the statements under the hood (tune it with batchSize) and writes the generated identity value back onto each item in customers.
Updating many rows
Dapper
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.Execute(
"UPDATE [dbo].[Customer] SET Name = @Name, Address = @Address WHERE Id = @Id;", customers);
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.UpdateAll(customers);
}
Merging many rows
⚠️ Dapper has no batched equivalent short of looping the single-row MERGE from Phase 2.
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.MergeAll(customers, qualifiers: e => e.Id);
}
🚚 Phase 4 — bulk operations for large datasets
For genuinely large row counts, RepoDB drops down to the provider’s native bulk-loading mechanism (SqlBulkCopy on SQL Server, LOAD DATA INFILE/COPY-style loaders on other providers) — the same tool you’d otherwise reach for by hand alongside Dapper.
Bulk inserting
Dapper ⚠️ (dropping to raw ADO.NET, since Dapper has no bulk API of its own)
using (var connection = new SqlConnection(connectionString))
{
var table = ConvertToDataTable(customers);
using (var bulkCopy = new SqlBulkCopy(connection))
{
bulkCopy.DestinationTableName = "Customer";
bulkCopy.WriteToServer(table);
}
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var affectedRows = connection.BulkInsert(customers);
}
👌 No DataTable conversion step — BulkInsert accepts the entity list directly (or a table name plus dynamics, or a DbDataReader — see below).
BulkUpdate / BulkMerge / BulkDelete
using (var connection = new SqlConnection(connectionString))
{
var updatedRows = connection.BulkUpdate(customersToUpdate);
var mergedRows = connection.BulkMerge(customersToMerge, qualifiers: e => e.Id);
var deletedRows = connection.BulkDelete(customersToDelete);
var deletedByKeyRows = connection.BulkDeleteByKey<Customer>(new long[] { 10045, 10046, 10047 });
}
⚠️ None of these have a Dapper equivalent at all — this is set of operations you gain, not migrate. 🚀
Streaming between two connections
This pattern — read from one database, bulk-load straight into another, without materializing the full result set in memory — has no clean Dapper equivalent, since Query always buffers into an IEnumerable<T> first.
using (var source = new SqlConnection(sourceConnectionString))
using (var reader = source.ExecuteReader("SELECT * FROM [dbo].[Customer];"))
using (var destination = new SqlConnection(destinationConnectionString))
{
var affectedRows = destination.BulkInsert<Customer>(reader);
}
🔗 Multi-mapping and joins
This is the one area with no direct 1:1 swap — RepoDB does not have a JOIN-aware multi-mapping API like Dapper’s Query<TFirst, TSecond, TReturn>. The RepoDB equivalent is multi-query: pack independent SELECT statements into one round-trip, then compose the results client-side.
Dapper
using (var connection = new SqlConnection(connectionString))
{
var sql = @"SELECT C.Id, C.Name, C.Address, O.Id, O.ProductId, O.Quantity, O.OrderDateUtc
FROM [dbo].[Customer] C
INNER JOIN [dbo].[Order] O ON O.CustomerId = C.Id
WHERE C.Id = @Id;";
var customerDict = new Dictionary<long, Customer>();
connection.Query<Customer, Order, Customer>(sql,
(customer, order) =>
{
if (!customerDict.TryGetValue(customer.Id, out var existing))
customerDict[customer.Id] = existing = customer;
existing.Orders ??= new List<Order>();
existing.Orders.Add(order);
return existing;
},
new { Id = 10045 },
splitOn: "Id");
var result = customerDict.Values.FirstOrDefault();
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var customerId = 10045L;
var (customers, orders) = connection.QueryMultiple<Customer, Order>(
c => c.Id == customerId,
o => o.CustomerId == customerId);
var customer = customers.FirstOrDefault();
customer.Orders = orders.ToList();
}
🎯 Dynamic parameters and IN clauses
Dapper
using (var connection = new SqlConnection(connectionString))
{
var addresses = new[] { "New York", "Washington" };
var customers = connection.Query<Customer>(
"SELECT * FROM [dbo].[Customer] WHERE Address IN @Addresses;", new { Addresses = addresses });
}
RepoDB
using (var connection = new SqlConnection(connectionString))
{
var addresses = new[] { "New York", "Washington" };
var customers = connection.Query<Customer>(e => addresses.Contains(e.Address));
}
Or, staying at the raw-SQL layer during Phase 1:
using (var connection = new SqlConnection(connectionString))
{
var addresses = new[] { "New York", "Washington" };
var customers = connection.ExecuteQuery<Customer>(
"SELECT * FROM [dbo].[Customer] WHERE Address IN (@Addresses);", new { Addresses = addresses });
}
📋 Quick-reference table
| Dapper | RepoDB (Phase 1, drop-in) | RepoDB (Phase 2+, fluent) |
|---|---|---|
Query<T>(sql, param) | ExecuteQuery<T>(sql, param) | QueryAll<T>() / Query<T>(expr) |
QueryFirstOrDefault<T> / QuerySingle<T> | ExecuteQuery<T>(...).FirstOrDefault() | Query<T>(expr).FirstOrDefault() |
Execute(sql, param) | ExecuteNonQuery(sql, param) | Insert / Update / Delete |
ExecuteScalar<T>(sql, param) | ExecuteScalar<T>(sql, param) | — |
QueryAsync<T> / ExecuteAsync | ExecuteQueryAsync<T> / ExecuteNonQueryAsync | QueryAllAsync<T> / InsertAsync / etc. |
Execute(sql, param, transaction) | ExecuteNonQuery(sql, param, transaction: t) | same transaction: argument on every operation |
Handwritten MERGE statement | — | Merge<T> / MergeAll<T> |
Loop of Execute calls | — | InsertAll / UpdateAll |
SqlBulkCopy + DataTable conversion | — | BulkInsert / BulkUpdate / BulkMerge / BulkDelete |
Query<TFirst, TSecond, TReturn> + splitOn | — | QueryMultiple<T1, T2> / ExecuteQueryMultiple |
| No equivalent | — | 2nd-level Cache, Trace, Hints, Telemetry |
🗺️ Migration order that actually works
- Run Phase 1 first, everywhere. It’s a mechanical rename with no behavioral risk —
Query→ExecuteQuery,Execute→ExecuteNonQuery,ExecuteScalarstays as-is. Your existing SQL strings, parameter objects, and transaction handling don’t change. Ship this on its own; it’s a safe, reviewable diff. - Opportunistically upgrade to fluent CRUD wherever a call site is doing simple single-table work — that’s most
Query<T>(id),Insert,Update, andDeletecall sites in a typical codebase. - Replace loops of
ExecutewithInsertAll/UpdateAll/MergeAll. These are usually easy to spot — aforeachwrapped around a single-row Dapper call — and the payoff (fewer round-trips, automatic batching) is immediate. - Reach for
BulkInsert/BulkUpdate/BulkMerge/BulkDeleteanywhere you already hand-rolled aSqlBulkCopy, or anywhereInsertAll/UpdateAllis still too slow for the row count involved. - Leave multi-mapped joins for last — they’re the one place the shape of the code actually changes (
QueryMultiple<T1, T2>composing client-side instead of aJOINsplitting server-side), so give them a proper look rather than a mechanical pass.
Nothing about this order is mandatory — RepoDB and Dapper can coexist in the same project indefinitely, since neither one owns the connection — so you can migrate one repository class, one project, or one call site at a time and stop wherever makes sense for you.
🎁 Key features you gain along the way
Migrating isn’t just a rename — it also puts a handful of built-in features within reach that Dapper simply doesn’t have. None of these require a design change; they layer on top of whatever operations you’ve already migrated.
Cache 🗄️
A 2nd-level cache built into the Query and QueryAll operations — no external caching library required.
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.QueryAll<Customer>(cacheKey: "AllCustomers");
// A subsequent call with the same cacheKey is served from cache, not the database
}
See Cache for the full set of cache providers and expiration options.
Trace 🔍
A hook into every operation’s before/after execution — the SQL text, the parameters, and the elapsed time — without wrapping every call site by hand or reaching for a separate interceptor library.
public class ConsoleTrace : ITrace
{
public void AfterExecution<TResult>(ResultTraceLog<TResult> log) =>
Console.WriteLine($"{log.Statement} ({log.ExecutionTime.TotalMilliseconds} ms)");
}
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.QueryAll<Customer>(trace: new ConsoleTrace());
}
See Trace for the full interface, including the ability to cancel an operation before it executes.
Hints 💡
Inline query hints (WITH (NOLOCK), index hints, and the like) passed straight through as an argument, instead of hand-editing the generated SQL or falling back to a raw string.
using (var connection = new SqlConnection(connectionString))
{
var customers = connection.QueryAll<Customer>(hints: SqlServerTableHints.NoLock);
}
See Hints for the supported hint sets per provider.
Telemetry 📡
Operation-level telemetry — what ran, how long it took, whether it failed — shippable to the RepoDB Insights stack with no code at the call site at all; it’s wired in once at startup. There’s no equivalent built into Dapper — you’d otherwise be instrumenting every call site yourself.
~ Conceptualized by me. Reviewed and checked by AI. Refined and gatekept by me. ~
Please support us
Give a ⭐ to our Github repository | Follow me at X / Twitter | Connect with us at our official Teams Channel
Apache License 2.0 — Copyright © 2026 by Michael Camara Pendon / Create a Request
