Performance Notes - lobodava/artisan-orm GitHub Wiki

Artisan.Orm is a thin layer over SqlConnection / SqlCommand / SqlDataReader. Most performance characteristics come straight from ADO.NET; this page lists the few decisions that meaningfully affect throughput and latency in real applications.

Connection pooling — the single biggest one

Production connection strings must keep Pooling=True (the default).

Without pooling, every using var repo = new MyRepo(connStr) performs a full TDS handshake — TLS negotiation, login, SET-options round-trips. That is hundreds of milliseconds, easily, and stacks linearly with concurrency. With pooling, "opening" a connection just borrows a pre-warmed physical connection from a per-process pool — microseconds.

Pooling=False is sometimes useful in tests to surface connection-leak bugs that the pool would mask. Never copy that to production configs.

# Production
Data Source=...;Initial Catalog=...;Integrated Security=True;TrustServerCertificate=True;
                                                                            ^ Pooling defaults to True

# Tests only — see Testing Your Repository
Data Source=...;Pooling=False;...

Repository lifetime

A repository instance owns one SqlConnection and a mutable Transaction field — sharing it across concurrent operations breaks. Treat it as a unit of work:

  • In ASP.NET Core / DI containers — register Scoped, the framework disposes it at the end of each HTTP request. See Using Artisan.Orm in ASP.NET Core.
  • In console / scripts — using var repo = ....
  • In background workers — using var scope = _scopeFactory.CreateScope(); ....

new SqlConnection(connStr) is cheap (it's a managed object, no I/O). Don't try to "save" by sharing repositories — you trade microseconds of allocation for hours of debugging concurrency bugs.

Registered mapper vs auto-mapping

ReadTo<T> (registered [MapperFor]) and ReadAs<T> (auto-mapping via expression trees) converge to the same per-row cost after warmup. The difference is:

  • ReadTo<T> runs your hand-written CreateObject directly — no warmup, friendly to AOT and trim.
  • ReadAs<T> builds the mapper via reflection on the first call for a given (type, column-shape) pair, compiles an expression tree, and caches it. First call: ~1-10 ms one-off. Subsequent calls: same as ReadTo<T>.

For long-lived ASP.NET Core processes, the auto-mapping warmup cost amortises to nothing. For short-lived CLI tools, prefer ReadTo<T>.

ReadToList<T> vs ReadToEnumerable<T> vs ReadToAsyncEnumerable<T>

Pattern Memory Connection lifetime Use when
ReadToList<T> Holds the whole result in RAM Connection closes when the call returns Small, bounded result sets
ReadToEnumerable<T> (sync) Streams row by row Connection stays open for the iteration Large results, sync code
ReadToAsyncEnumerable<T> Streams row by row Connection stays open for the iteration Large results, async code

For a 50-user list-page query, ReadToList<T> is the right answer. For a 5-million-row export, the streaming variants avoid OOM and let downstream pipelines start working immediately.

See Streaming with IAsyncEnumerable for the gotchas around connection lifetime during iteration.

BulkCopy<T> vs row-by-row insert

For more than ~50 rows, BulkCopy beats row-by-row insert by 10x-100x. The break-even point depends on row width and indexes, but it's almost always low.

  • Loop calling Save(row) — one round trip per row.
  • BulkCopy(rows, "dbo.Table") — one round trip total, streamed over a single TDS frame.

For very large inserts (millions of rows), tune batchSize to avoid filling the transaction log in one go and to allow lock release between batches:

conn.BulkCopy<Record>(rows, "dbo.Records",
    options:   SqlBulkCopyOptions.TableLock,
    batchSize: 5_000);

See BulkCopy for the full options.

Index discipline for object-graph saves

The MERGE pattern in Negative identities and object graph saving requires:

The source and the target tables MUST have clustered indexes on their join columns.

This isn't a style preference. Without clustered indexes on the join columns, MERGE may:

  • deadlock under concurrent writes,
  • execute in non-deterministic order (rows may insert before their parents in a multi-table graph save),
  • run dramatically slower at scale (hash-match instead of merge-join).

Define the join columns as primary key clustered in the user-defined table types and the target tables. Negative-identity Ids are designed to be ever-increasing exactly so that this constraint holds for newly-inserted rows.

TDS-level — large blobs

When reading very wide rows (large varbinary(max), large nvarchar(max)), the default CommandBehavior allocates the whole row in memory at Read() time. For multi-megabyte blobs, consider:

  • cmd.ExecuteReader(CommandBehavior.SequentialAccess) — streams column values as you read them, but you must read columns in order and once each.
  • dr.GetStream(ord) / dr.GetTextReader(ord) — true streams over the column.

Artisan.Orm doesn't currently expose SequentialAccess as a flag on its high-level methods; for blob-streaming use RunCommand and configure cmd.ExecuteReader directly inside the lambda.

Async vs sync — when it matters

async doesn't make a single call faster. It makes the calling thread available for other work while SQL Server processes the query.

  • In ASP.NET Core, prefer *Async on every read/write call. Otherwise the request thread blocks waiting for the database, and under load the thread pool exhausts itself.
  • In console apps and short scripts, sync is usually fine and slightly easier to debug.

The async path adds a tiny per-call overhead (allocating a state machine, a few continuations). For SQL Server queries that take >1 ms — i.e., basically all of them — it's noise.

Profile before optimising

Most of the above is "cheap to do right from the start." Beyond those, don't guessdotnet-trace, SQL Server Profiler, or the Activity tracing on Microsoft.Data.SqlClient will tell you where the time actually goes. Common surprises:

  • The slow query is one your code calls 50 times in a loop — fix by reading once.
  • Connection pool exhaustion under concurrency — usually a leaked repository instance not being disposed.
  • Transactions held open across await points — promotes contention; shorten the critical section.
  • Autocommit on each Save in a batch — wrap the loop in RunInTransaction.

See also:

⚠️ **GitHub.com Fallback** ⚠️