DapperMany
v1.0 · pre-releaseInstallation

Bulk-operations extension for Dapper in .NET

Insert, update and delete thousands of records without writing a line of SQL.

DapperMany maps your classes to tables with attributes, resolves parent-child graphs automatically, and uses each database's native bulk copy — SQL Server, PostgreSQL and MySQL — behind a minimal API over IDbConnection.

Program.cs
await connection.InsertManyAsync(orders); // inserts flat or graph
await connection.UpdateManyAsync(partialOrders);
await connection.DeleteManyAsync(orderIds);
3supported databases
0manual SQL
1call for whole graphs

Why DapperMany

Dapper already handles line-by-line object-relational mapping very well. What's missing is a performant way to move many records at once — including trees of related objects — without falling back to hand-written SQL or losing control over database-generated Ids.

Minimal public API

Consumers only see attributes on the class and extension methods over IDbConnection. All internal mechanics stay hidden.

Zero manual SQL

SQL generation, bulk copy, generated-Id resolution and parent-child correlation all happen internally, by convention.

Core decoupled from drivers

The DapperMany package doesn't reference any database driver. Each provider lives in its own separate NuGet package.

Performance via cached reflection

No attribute or property is read via reflection repeatedly at runtime — everything is compiled with Expression Trees and cached per type.

Installation

Install the core package and the provider for the database you use. When the assembly loads, the provider registers itself — no extra configuration needed.

dotnet add package DapperMany
dotnet add package DapperMany.SqlServer

Extensible by third parties: any external package can register a new provider (e.g. DapperMany.Sqlite) by implementing ISqlDialect, IBulkCopyStrategy and IIdentityRetrievalStrategy.

Quickstart

Map your classes with attributes and call the extension operations directly on the connection.

[Table("Orders")]
public class Order
{
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public string Customer { get; set; }
    public DateTime OrderDate { get; set; }

    [HasMany(foreignKey: nameof(OrderItem.OrderId))]
    public List<OrderItem> Items { get; set; }
}

[Table("OrderItems")]
public class OrderItem
{
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public int OrderId { get; set; }
    public string Product { get; set; }
}
await connection.InsertManyAsync(orders);    // parent + children, FK resolved automatically
await connection.UpdateManyAsync(partialOrders);  // object with just [Key] + fields to update
await connection.DeleteManyAsync(orderIds);

Entity attributes

DapperMany reuses System.ComponentModel.DataAnnotations.Schema whenever possible — anyone who already knows EF Core will recognize [Table], [Key] and [DatabaseGenerated] right away. Relationships are declared with [HasMany] (1:N) and [HasOne] (1:1).

[Table("Orders")]
public class Order
{
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public string Customer { get; set; }
    public DateTime OrderDate { get; set; }

    [HasMany(foreignKey: nameof(OrderItem.OrderId))]
    public List<OrderItem> Items { get; set; }

    // 1:1 relationship — single reference property
    [HasOne(foreignKey: nameof(OrderDetail.OrderId))]
    public OrderDetail Detail { get; set; }
}
AttributeUsage
[Table("Name")]Maps the class to a database table.
[Key]Marks the primary key, used in updates, deletes and Id correlation.
[DatabaseGenerated(Identity)]Indicates the value is generated by the database on insert (the default scenario assumed by the lib).
[HasMany(foreignKey:)]1:N relationship — the FK is propagated from the parent to each item in the children collection.
[HasOne(foreignKey:)]1:1 relationship — the FK is propagated from the parent to the child's single reference.

Public API

Five extension methods over IDbConnection — nothing else is exposed to the consumer.

public static class DbConnectionExtensions
{
    Task InsertManyAsync<T>(this IDbConnection cn, IEnumerable<T> entities, IDbTransaction? tx = null);
    Task InsertManyAsync<T>(this IDbConnection cn, IEnumerable<T> entities, IDbTransaction? tx = null);
    Task UpdateManyAsync<T>(this IDbConnection cn, IEnumerable<T> entities, IDbTransaction? tx = null);
    Task DeleteManyAsync<T>(this IDbConnection cn, IEnumerable<T> entities, IDbTransaction? tx = null);
    Task DeleteManyAsync<T>(this IDbConnection cn, IEnumerable<object> keys, IDbTransaction? tx = null);
}

InsertMany / InsertManyGraph

InsertManyAsync performs bulk inserts for flat collections or entity graphs with automatic relationship detection. When relationships are detected, parent IDs are populated back into each entity and FK values are propagated to children.

  1. Inserts the parent records — the strategy depends on the provider (see Providers).
  2. Populates the generated Id back into each parent entity via a compiled setter.
  3. For each marked relationship, checks whether there's data: a non-null, non-empty collection ([HasMany]) or a non-null reference ([HasOne]) — otherwise it skips without building a batch.
  4. Propagates the parent's FK to the children and groups them by type.
  5. Executes native bulk copy for the grouped children, when supported by the provider.
  6. Recurses for nested relationships (grandchildren), if any.

The parent insert is not native bulk copy in v1 — it's sequential or multi-VALUES with Id return. This trade-off is accepted because, in the typical use case (Order → Items), the volume of parents is orders of magnitude smaller than children, where the real performance gain lives.

Edge cases

Null or empty children: inserts only the parent.

var order = new Order { DocumentNumber = "ORD-NULL", Items = null };
await connection.InsertManyAsync(new[] { order }); // Inserts only the parent

[HasOne] (1:1) is treated as a single child — same FK propagation, same bulk flow.

var order = new Order { DocumentNumber = "ORD-DET", Detail = new OrderDetail { /* ... */ } };
await connection.InsertManyAsync(new[] { order }); // Inserts parent + detail (1:1)

UpdateMany

Takes a partial object: [Key] is required, used for the JOIN/MERGE, plus only the fields to update. The SET clause is generated dynamically from the properties present on the type — no manual column list.

Mechanism: staging table (bulk copy of the partial object) followed by UPDATE ... FROM (SQL Server/Postgres) or UPDATE ... JOIN (MySQL).

Out of scope for v1: automatic dirty-tracking from a full entity. Documented as a possible v2.

DeleteMany

Accepts a list of full entities or a list of keys (IEnumerable<object> keys).

await connection.DeleteManyAsync(orderIds);

Out of scope for v1: automatic cascade via [HasMany]. Each level must be deleted explicitly, respecting FKs.

Transactions

Every bulk operation is required to run inside a transaction tied to the IDbConnection.

Caller-supplied transaction

The lib uses and propagates that transaction to all internal operations (parents, children, staging tables, bulk copy) and does not commit or roll back — that responsibility stays with the caller.

Internally created transaction

When tx == null, the lib creates the transaction via BeginTransaction(), commits automatically on success, rolls back on exception, and guarantees disposal in a finally block.

using var tx = connection.BeginTransaction();
await connection.InsertManyAsync(orders, tx);
tx.Commit();

The transaction spans every phase of the operation — inserting/updating/deleting parents, populating FKs, bulk copying children and any identity reads. No operation touches the database outside that scope. Isolation in v1: the provider's default, typically ReadCommitted.

Providers

Each database has its own package and a different strategy for bulk copy and generated-Id retrieval.

AspectSQL ServerPostgreSQLMySQL
Native bulk copySqlBulkCopyCOPY via NpgsqlBinaryImporterLOAD DATA via temporary CSV (MySqlBulkLoader)
Generated Id returnOUTPUT INSERTED.IdRETURNING id (multi-row)LAST_INSERT_ID() + sequential increment
Bulk updateStaging + UPDATE...FROM / MERGEStaging + UPDATE...FROMStaging + UPDATE...JOIN
Implementation complexityBaselineLowHigh

Id correlation by VALUES position (SQL Server/Postgres) is consistent and used in production, but it isn't a contract formally documented by Microsoft — that's why it has dedicated integration test coverage.

Provider auto-registration

The core doesn't know any concrete driver. Each provider package registers itself when loaded, via [ModuleInitializer]:

internal static class SqlServerModuleInitializer
{
    [ModuleInitializer]
    public static void Initialize() =>
        ProviderRegistry.Register("Microsoft.Data.SqlClient.SqlConnection",
            new ProviderModule(new SqlServerDialect(), new SqlServerBulkCopyStrategy(), new SqlServerIdentityStrategy()));
}

Cache & thread-safety

A cross-cutting foundation used by every operation — no attribute or property access repeats reflection at runtime.

ItemScopeStructure
EntityMetadataStatic, global, per TypeConcurrentDictionary<Type, Lazy<EntityMetadata>>
Compiled getters/settersStatic, global, per PropertyInfoConcurrentDictionary<PropertyInfo, Delegate>
DataTable, buffers, graph correlationLocal, per calllocal variable in the method

EntityMetadata is immutable once built — concurrent reads need no locking. Connections (SqlConnection, NpgsqlConnection, MySqlConnection) aren't thread-safe for simultaneous use on the same instance, the same premise as plain Dapper.

Logging (Debug only)

In Debug builds, bulk operations emit diagnostics via Debug.WriteLine(), measured with a Stopwatch. In Release, those messages don't exist — everything sits behind #if DEBUG.

[DAPPERMANY] BulkInsert Order (SqlServer): affected=10, elapsed=45ms

For graph inserts, both the parent phase and the children phase are logged, with one aggregated total log at the end. This doesn't replace observability integrations like OpenTelemetry, which may be added in the future.

Implementation roadmap

  1. Foundation: EntityMapper + AccessorFactory + EntityMetadata, with isolated concurrency tests.
  2. Docker environment with the three databases and initial schema.
  3. DapperMany (core) + DapperMany.SqlServer: a simple InsertManyAsync, validated with Testcontainers.
  4. Samples project: an InsertMany scenario against SQL Server via local Docker.
  5. InsertManyAsync on SQL Server — one relationship level first, recursion after.
  6. UpdateManyAsync / DeleteManyAsync on SQL Server, with matching scenarios in samples.
  7. Extract ISqlDialect / IBulkCopyStrategy / IIdentityRetrievalStrategy as formal interfaces.
  8. DapperMany.Postgres + scenarios in samples.
  9. DapperMany.MySql + scenarios in samples.
  10. Benchmarks with BenchmarkDotNet comparing against row-by-row insert, per provider.
  11. Documentation and package publishing on NuGet.

Out of scope (v1)

  • Automatic dirty-tracking for UpdateMany from a full entity.
  • Truly bulk parent inserts (multi-VALUES + OUTPUT) for high volume on the parent side too.
  • Automatic cascading DeleteMany via [HasMany].
  • Partial RETURNING/OUTPUT (column subset) to reduce traffic on wide updates.
  • Additional providers (SQLite, Oracle) as independent packages.

DapperMany is an open source framework for .NET. Documentation generated from the project specification.