DbConnectionPlus - Design Decisions
Version: 4.0.0 Last updated: August 2026 Author: David Liebeherr
This document describes the design as it is now, and why it is that way. It is not a change log - see CHANGELOG.md for what changed between versions, and the guides for how to use the library.
Table of contents
- Design principles
- Core architecture
- Technology choices
- Performance decisions
- Native AOT and trimming
- Database adapters
- SQL statements and the interpolated string handler
- Entity mapping
- Temporary tables
- Testing strategy
- Configuration and extensibility
- Trade-offs and alternatives
- Future considerations
Design principles
Everything below follows from six principles:
- Minimal abstraction overhead - performance close to hand-written ADO.NET code
- Type safety - compile-time validation of SQL parameters and mappings
- Multi-database support - MySQL, Oracle, PostgreSQL, SQLite and SQL Server, extensible to any other
- Developer productivity - interpolated string syntax for natural SQL authoring
- Explicit behaviour over implicit magic - and standard patterns over custom frameworks
- Native AOT without an opt-in - the same API, the same behaviour and no diagnostics to suppress in an
application published with
PublishAotorPublishTrimmed
Core architecture
Database adapters - Strategy pattern
Database-specific behaviour is isolated behind IDatabaseAdapter, which exposes an IEntityManipulator and an
ITemporaryTableBuilder alongside seven methods (parameter binding and naming, type mapping, identifier and
temporary-table quoting, temporary-table support, cancellation detection). See
Database adapters for what each of them does.
Databases differ in parameter prefixes (@, :), temporary-table syntax, CLR-to-SQL type mapping and how a
cancelled statement surfaces as an exception. The alternatives - one adapter with runtime switches, an
inheritance hierarchy, or a configuration-driven mapping table - all either couple the databases together or
give up compile-time safety. Strategy keeps each adapter a self-contained, independently testable unit, and
adding a database needs no change to core.
Materializer factories
EntityMaterializerFactory and ValueTupleMaterializerFactory build the delegates that map a DbDataReader
to objects. A factory is warranted because building one is expensive, the strategies differ per target type,
and the results have to be cached.
Each factory builds one of two implementations for the same result-set shape, chosen at run time by
RuntimeFeature.IsDynamicCodeSupported:
// Just-in-time: a compiled expression tree, conceptually
Func<DbDataReader, Product> materializer = reader => new Product
{
Id = reader.GetInt64(0),
Name = reader.GetString(1),
Price = reader.GetDecimal(2)
};
// Native AOT: the same bindings resolved once into a closure and replayed per row with reflection
Both use the typed DbDataReader.GetXxx methods where possible, minimizing boxing, and both produce identical
entities - down to exception types and messages, which the unit tests assert verbatim. See
Native AOT and trimming for why the second implementation exists.
Cached per result-set shape, not per type. The cache key (MaterializerCacheKey) is the entity type - or,
for tuples, the tuple's field types - plus the field names and field types the reader reports. SELECT Id, Name and SELECT * need different delegates, and the same column arrives as Int64 from SQLite and as
Int32 from SQL Server; a cache keyed by type alone would hand back a materializer for the wrong shape.
The cache is a ConcurrentDictionary<MaterializerCacheKey, Delegate>, read through TryGetValue and populated
with an already-built value - never through a GetOrAdd factory lambda, because
[DynamicallyAccessedMembers] does not flow into a lambda. See
Correctness under trimming.
The DbDataReader decorator
ExecuteReader is the one entry point that returns before the work is finished: the caller reads rows
afterwards, so the DbCommand and any temporary tables the statement created cannot be disposed when the
method returns. Every other method owns its command start to finish and disposes it itself.
CommandDisposingDataReaderDecorator wraps the provider's reader and does two things:
- Disposal: disposing the reader disposes the
DbCommandDisposer, which disposes the command, the cancellation-token registration and everyTemporaryTableDisposerthe statement created - synchronously throughDispose, asynchronously throughDisposeAsync. - Cancellation: each provider signals a cancelled statement differently (SQL Server a
SqlExceptionwith class 11, number 0, state 0; OracleORA-01013).Read/ReadAsyncroute the exception throughIDatabaseAdapter.WasSqlStatementCancelledByCancellationTokenand rethrow it asOperationCanceledException, so the caller writes onecatchregardless of database.
Inheritance is not an option here: DbCommand.ExecuteReader() returns a provider-specific concrete type. The
decorator wraps whatever it gets without knowing that type.
Technology choices
Target frameworks: net8.0 and net10.0
.NET 8.0 is the floor: LTS until November 2026, TieredPGO on by default, C# 12, and modern BCL APIs.
The second target exists because the trim and AOT analyzers are meaningfully better on the newer runtime: the
feature guard that lets RuntimeFeature.IsDynamicCodeSupported silence an IL3050 was only recognised from
.NET 9 on, so the same source produces a different diagnostic set on the two targets. That is why both are
gated in CI rather than only the newer one - see Native AOT and trimming.
The trade-off is no .NET Framework support, and a doubled build matrix in which a diagnostic has to be resolved on both targets rather than on the one the developer happens to build.
Dependencies
The core package takes exactly two runtime dependencies; everything else comes from System.Data.Common:
| Library | Purpose | Why |
|---|---|---|
| LinkDotNet.StringBuilder | stack-allocated string building | builds SQL without the intermediate strings a StringBuilder allocates |
| Humanizer.Core | ordinals in error messages ("the 3rd column …") | well-maintained, and only its Ordinalize is used |
Each adapter package adds exactly one: its ADO.NET provider (Microsoft.Data.SqlClient, MySqlConnector,
Npgsql, Microsoft.Data.Sqlite or Oracle.ManagedDataAccess.Core). That split is why the adapters are
separate packages at all - a consumer that uses SQLite does not reference an Oracle client. Analyzer packages
(ErrorProne.NET, Roslynator, the public-API analyzers) are PrivateAssets="all" and do not flow to consumers.
Deliberately absent: anything that emits IL at run time. Property access goes through
System.Reflection.MethodInvoker / ConstructorInvoker, and the temporary-table read path through the
library's own EnumerableReader. Libraries of the Fasterflect / FastMember family are faster to reach for
and work on the JIT, but they build accessors with Reflection.Emit, which does not exist under Native AOT,
and they are unannotated third-party assemblies that produce an assembly-wide IL2104 in a consumer's trimmed
publish. Do not add one back.
LangVersion is latest; the floor for what the library actually uses is C# 12, which net8.0 supplies. The
one language feature the design genuinely rests on is the interpolated string handler (C# 10).
Performance decisions
Expression-tree materializers, where the runtime can compile them
| Approach | Performance | Flexibility | Used? |
|---|---|---|---|
| Compiled expressions | fast | high | yes - the JIT path |
| Reflection | slowest | high | yes - the Native AOT path |
| Source generators | fastest | medium | no - see below |
| Manual mapping | fastest | low | no |
Compiled expressions work with any type including third-party ones, need no build-time step, come close to hand-written code, and can be inspected when debugging.
The one place this does not hold: an application published with Native AOT cannot compile an expression
tree. Expression.Compile() does not fail there - it silently falls back to an interpreter, tens of times
slower per row and far heavier on allocations, which is the worst outcome because it looks like it works.
Materializers therefore dispatch on RuntimeFeature.IsDynamicCodeSupported, and the reflection implementation
behind that branch is a full counterpart rather than a degraded one.
Stack allocation for string building
SQL is composed into a ValueStringBuilder (LinkDotNet.StringBuilder) over a stack-allocated buffer:
using var builder = new ValueStringBuilder(stackalloc Char[500]);
- 500 characters where a whole statement is built (512 in
DbCommandBuilder), 100 where only an identifier is.NameHelpersizes its buffer to the name it is about to produce and falls back to the heap above 512 characters. ValueStringBuildergrows onto the heap by itself when a statement does not fit, so the size is an optimization, never a limit.- The saving is the intermediate strings a
String-concatenating builder would have produced: most statements are built with zero heap allocations, and therefore without GC pressure.
The cost is larger stack frames, bounded by keeping the buffers small enough that they cannot overflow the stack.
Caching: forever, and as late as possible
Three caches, all populated on first use and never evicted (see Caching strategy for why there is no eviction policy):
| Cache | Key | Built when |
|---|---|---|
| Materializers | entity type (or tuple field types) + reader field names and types | a result-set shape is first seen |
| Entity metadata | entity type | an entity type is first used |
| Property accessors | - | a property is first read or written |
The last one is the least obvious. EntityPropertyMetadata.PropertyGetter / PropertySetter are
Func<Object, Object?>? / Action<Object, Object?>? closures that create their MethodInvoker on the first
call and keep it afterwards. Building metadata for a type with 30 properties therefore costs nothing per
property until a property is actually touched, and a query that reads five columns of a wide entity never pays
for the other 25.
In all three cases the first operation pays the one-off construction cost and every subsequent one is a dictionary hit.
Native AOT and trimming
A consumer references the package and publishes. There is no companion package, no source generator, no opt-in attribute, no registration call and no diagnostic to suppress.
1. Reflection, not source generation
Native AOT forbids run-time code generation, not reflection. So no mechanism in the library emits IL, except the one that needs it purely for speed - and that one sits behind a run-time guard:
| Mechanism | How it works | Paths |
|---|---|---|
| Entity property accessors | MethodInvoker / ConstructorInvoker |
single path |
| Temporary tables from complex objects | the library's EnumerableReader over those accessors |
single path |
| Materializers | expression tree on the JIT, reflection under AOT, chosen by RuntimeFeature.IsDynamicCodeSupported |
two paths, one behaviour |
The AOT compiler folds that condition to a constant, so a published application does not carry the expression-tree implementation at all, and a JIT-compiled one never reaches the reflection path.
A source-generator design was prototyped, benchmarked and rejected. It would have discovered the concrete
types at opted-in call sites and emitted reflection-free mappers, registered through a generated
[ModuleInitializer]. It buys run-time performance on the mapping step and nothing else.
What the repository still measures is the price of the reflection path itself - the same cost a generator would have removed. From the benchmark results, on in-memory SQLite, where statement execution is nearly free and mapping is therefore the largest possible share of the total:
| Category, JIT → Native AOT | End to end | Of which the runtime itself (raw DbCommand baseline) |
|---|---|---|
Query_Entities |
1.31x | 1.08x |
Query_ValueTuples |
1.35x | 1.12x |
TemporaryTable_ComplexObjects |
1.22x | 1.17x |
Against a real database server the same absolute difference is a much smaller share of the total.
What rejecting it bought: nothing for consumers to do; no "type the generator never discovered" failure
mode, and no analyzer needed to detect one; two fewer permanently divergent code paths, because accessors and
the temporary-table reader stayed single-path; no third-party emit-based dependency and therefore no
assembly-wide IL2104 in a consumer's publish; no Roslyn version-skew risk; and fluent-API configuration keeps
working with no special handling, because everything resolves at run time from the live entity metadata.
What it costs: the table above. This is not a dead end - generated mappings remain layerable on top later as a pure optimization, because reflection is then a working fallback rather than a blocker.
2. Correctness under trimming
This is the reason the design has a run-time guard rather than only annotations.
Under trimming, if no [DynamicallyAccessedMembers] annotation told the trimmer to keep an entity's members,
Type.GetProperties() returns fewer members - or none - with no error. The library then binds no columns
and returns a default-valued entity. Measured: 6 columns of real data in, 0 bound, no exception.
Three layers defend this, and all three are mandatory:
| Layer | What it is | What it depends on |
|---|---|---|
| 1 | [DynamicallyAccessedMembers] on every entry point that reaches reflection, flowed down to EntityHelper.GetEntityTypeMetadata |
the annotations being complete |
| 2 | No IL2xxx suppression on the entity path, and none at all at a public API |
developer discipline |
| 3 | The zero-binding guard: if a result set binds no writable property, throw | nothing |
Layer 2 is the project rule that keeps layer 1 honest: the IL2xxx warnings are the only build-time proof
that the annotation chain is complete, so silencing one where entity types flow silently voids the guarantee.
The default answer is to restructure. The annotation does not flow through a ConcurrentDictionary.GetOrAdd
lambda, for instance, so both the materializer cache and EntityHelper.GetEntityTypeMetadata build their value
first and pass it to GetOrAdd rather than suppressing the resulting warning.
The library contains exactly two IL2xxx suppressions, both on the value-tuple path and neither anywhere
near an entity type; decision 4 sets out what makes each true and what
guards it. A third one is a regression, not a precedent.
Layer 3 is the only layer that does not depend on humans, and it is what a source generator would have given for free: with a registry, an undiscovered type fails loudly. Measured under Native AOT:
| Case | Columns bound | Outcome |
|---|---|---|
| Broken annotation chain, no guard | 0 | silent corruption - default-valued entity, no exception |
| Broken annotation chain, with guard | - | throws |
| Correct annotation chain, with guard | 6 | OK - no false positive |
The guard also covers a failure that is not AOT-specific: on the JIT, a result set matching no property
would otherwise produce a sequence of default-valued objects, so a typo in a SELECT alias would return empty
objects with no error.
⚠️ All three cases pass on the JIT. Nothing is trimmed there, so the entire unit and integration suite passes with a broken annotation chain. That is why verification lives in a natively published smoke test - see Testing strategy - and why that job must never be disabled or made non-blocking.
3. Where annotations run out: nested value tuples
[DynamicallyAccessedMembers] is not recursive. A value tuple with more than seven fields is represented by
the runtime as a nested value tuple, so the annotation on Query<T>'s type parameter preserves the outermost
type and says nothing about the inner one - which the trimmer then removes, fields and constructor alike.
Two things were needed, and only together:
- Traverse through
Type.GetGenericArguments(), notFieldInfo.FieldTypeandGetFields(). The generic arguments of a value tuple are its field types, in field order, and they are type metadata that cannot be trimmed away. - Ship an embedded
ILLink.Descriptors.xmlpreserving the members ofSystem.ValueTuple1throughSystem.ValueTuple8. The traversal makes the nested types discoverable; only the descriptor makes their constructors survive.
A descriptor preserves members rather than silencing a diagnostic, so it does not conflict with layer 2. It is also what made decision 4 possible: once the members are guaranteed by a mechanism that ships inside the assembly, the one remaining diagnostic on this path can be answered where it occurs instead of being reported to every consumer.
4. No consumer-facing diagnostics
The generic query methods carry no [RequiresUnreferencedCode] and no [RequiresDynamicCode], so a
consumer publishing with PublishAot or PublishTrimmed sees no diagnostic at a call site.
Putting the attributes on them was considered and rejected. Carrying both would make every consumer publishing
with PublishAot or PublishTrimmed see an IL2026 and an IL3050 at every call site, including callers
that only ever query an entity. The argument for them rested on the nested value-tuple traversal being
un-analyzable, which decision 3 removes. Without them, the analyzers report exactly three distinct sites in the
library, all inside the two materializer factories, and each is answered where it occurs:
| Diagnostic | Where | How it is answered |
|---|---|---|
IL2060 |
MakeGenericMethod specializing ValueConverter.ConvertValueToType<TTarget> (expression path) |
suppressed at a one-line helper, MaterializerFactoryHelper.MakeValueConverterConvertValueToTypeMethod, that exists only to scope it. That method declares no [DynamicallyAccessedMembers] on TTarget, so the specialization has no requirements trimming could fail to preserve |
IL3050 |
Expression.Lambda and MakeGenericMethod (expression path) |
[RequiresDynamicCode] stays on the expression-tree materializer, where it is true. Its only caller reaches it from inside the RuntimeFeature.IsDynamicCodeSupported dispatcher, which net9.0+ recognises as a feature guard. net8.0 needs a suppression on the dispatcher because its reference assembly lacks that annotation |
IL2065 |
GetConstructor on a nested value tuple type (reflection path) |
suppressed in GetValueTupleConstructors, justified by the embedded descriptor from decision 3, the caller's value-tuple validation, a unit test asserting the descriptor still lists all eight arities, and native smoke cases that materialize nested tuples for real |
This is what layer 2 forbids and what it does not. A suppression at the public API would hide a broken
[DynamicallyAccessedMembers] chain for a consumer's entity types - a real, measured failure mode. That is out
of bounds and untouched. The two sanctioned suppressions cover value-tuple BCL metadata: a closed set of
eight framework types, preserved by a shipped descriptor, guarded by a unit test and by native smoke cases.
The net8.0 IL3050 suppression is a transcription, not an assertion. The net10.0 inner build compiles
the same source without it. That is why net8.0 and net10.0 are both gated in CI: the newer one verifies the
reasoning the older one has to state by hand.
Why bother. A warning a consumer cannot act on, and that does not correspond to any way their application
can break, trains them to ignore the warnings that do. The smoke test gates on zero diagnostics from anywhere,
so it fails immediately if a public API gains one of these attributes. If any of the three answers above stops
holding, the correct response is to put [RequiresUnreferencedCode] on the query methods, not to leave an
untrue suppression in place.
5. DataRow instead of dynamic for untyped result sets
The non-generic query methods return DataRow, whose string indexer - row["Id"] - is the documented way to
read a column. Member access through a dynamic reference still works on the JIT.
dynamic cannot work under Native AOT: the Dynamic Language Runtime binds call sites by generating code. That
is a fact about the consumer's own call site, and the C# compiler reports it there - the right place for it.
What the library must not do is charge every consumer for it. Two choices follow:
DataRowimplementsIDynamicMetaObjectProvider; it does not derive fromDynamicObject. As of .NET 10 theDynamicObjectconstructor is[RequiresDynamicCode], so a base class would push that attribute ontoDataRow's constructor and from there onto all ten non-generic query methods - reported even to a caller who only ever writesrow["Id"]. The interface carries no such annotation.DataRowMetaObjectbinds to a plain delegate that reads or writes the column, never throughExpression.Lambda/LambdaExpression.Compile, which are[RequiresDynamicCode]and would reintroduce exactly the problem the interface avoids.
The consequence for behaviour is deliberate: through a dynamic reference a property always addresses a
column, so row.Count reads the column named Count and throws KeyNotFoundException if there is none, while
method calls still resolve against DataRow itself. row.X and row["X"] are then the same operation in
every case, rather than the row's own members shadowing columns that share their name.
Database adapters
Responsibilities
Each IDatabaseAdapter implementation handles:
| Member | What it does |
|---|---|
BindParameterValue |
type conversions (DateOnly → DateTime for Oracle, Guid → string for Oracle), enum serialization |
FormatParameterName |
@Name for SQL Server, :Name for Oracle |
GetDataType |
CLR → SQL type mapping, e.g. DateTime → DATETIME2 (SQL Server) vs DATE (Oracle) |
QuoteIdentifier |
[Name] (SQL Server), `Name` (MySQL), "Name" (PostgreSQL / Oracle / SQLite) |
QuoteTemporaryTableName |
separate from QuoteIdentifier because a temporary table is not always quoted like an ordinary one - SQL Server's #Name carries its scope in the name |
SupportsTemporaryTables |
lets an adapter refuse the feature. Oracle returns false unless OracleDatabaseAdapter.AllowTemporaryTables is set, because creating or dropping a private temporary table implicitly commits the caller's transaction |
EntityManipulator |
CRUD SQL generation, generated-key readback, concurrency checks |
TemporaryTableBuilder |
temporary-table creation and population |
WasSqlStatementCancelledByCancellationToken |
maps a provider exception to a cancellation - SQL Server class 11 / number 0 / state 0, Oracle ORA-01013 |
Registration
Each adapter ships as its own NuGet package and is registered explicitly at startup, through a UseXxx()
extension method in the RentADeveloper.DbConnectionPlus.Configuration namespace, so it appears on the
configuration object without an extra using:
public static DbConnectionPlusConfiguration UseSqlite(this DbConnectionPlusConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
configuration.RegisterDatabaseAdapter<SqliteConnection>(new SqliteDatabaseAdapter());
return configuration;
}
DbConnectionExtensions.Configure(config => config.UseSqlServer().UseSqlite());
RegisterDatabaseAdapter<TConnection> stores the adapter against typeof(TConnection). Every extension method
resolves the adapter from the runtime type of the DbConnection it was called on; an unregistered connection
type throws InvalidOperationException naming the type rather than failing later with something obscure.
Registration happens inside Configure, which freezes the configuration when it returns, so the map is
read-only for the rest of the process and the lookup needs no synchronization - see
Global configuration.
Why explicit registration:
- Only the packages you use are referenced. A SQLite consumer does not drag in
Oracle.ManagedDataAccess.Core- which matters for deployment size generally, and under Native AOT specifically, because every referenced assembly enters the compilation closure. - No assembly scanning. Discovery by reflecting over loaded assemblies is exactly the pattern Native AOT and trimming cannot follow: an adapter reached only that way can be trimmed away, and the failure is a missing registration at run time.
- The same mechanism serves custom adapters. There is no built-in/third-party asymmetry: implement
IDatabaseAdapter(plus anIEntityManipulatorand anITemporaryTableBuilder), callRegisterDatabaseAdapter<MyConnection>, and optionally wrap that in aUseMyDatabase()extension method - which is all the built-in adapters are. The custom-adapter guide carries a worked example.
Trade-off: one line of startup configuration that a static auto-registering registry would not need, and a run-time rather than compile-time error if it is forgotten.
SQL statements and the interpolated string handler
Why a handler
// Unsafe
var sql = "SELECT * FROM Product WHERE Id = " + productId;
// Safe but error prone
command.CommandText = "SELECT * FROM Product WHERE Id = @Id";
command.Parameters.AddWithValue("@Id", productId);
// Safe and concise
connection.Query<Product>($"SELECT * FROM Product WHERE Id = {Parameter(productId)}");
InterpolatedSqlStatement is an [InterpolatedStringHandler], so the compiler turns an interpolated string
passed to a query method into AppendLiteral / AppendFormatted calls at the call site. That gives
compile-time type checking, no run-time parsing and no reflection to recover parameters, and makes injection
safety structural: a value reaches the database as a DbParameter because it arrived as a fragment, not
because a string was escaped correctly.
The alternative - parsing the composed string at run time - costs a parser on every statement, gives up compile-time validation, and turns injection safety into a property of how complete the parser is.
Fragments
A statement is stored as an ordered list of immutable fragments rather than as a concatenated string. All four
fragment types are records implementing the internal marker interface IInterpolatedSqlStatementFragment:
| Fragment | Visibility | What it is |
|---|---|---|
Literal |
internal | raw SQL text - everything between the holes |
InterpolatedParameter |
public | a value captured by Parameter(value), carrying the name inferred from the call site |
InterpolatedTemporaryTable |
public | a sequence captured by TemporaryTable(values), replaced by the name of the table created for the statement |
Parameter |
internal | an explicitly named parameter, for the non-interpolated constructor |
connection.Query<Product>($"SELECT * FROM Product WHERE Id = {Parameter(productId)}");
connection.Query<Product>(
$"SELECT * FROM Product WHERE Id IN (SELECT Value FROM {TemporaryTable(productIds)})"
);
connection.Query<Product>(
new InterpolatedSqlStatement("SELECT * FROM Product WHERE Id = @Id", ("Id", productId))
);
A plain String also converts implicitly (or explicitly through FromString), which is what makes
connection.Query<Product>("SELECT * FROM Product") compile into a single Literal.
Fragments buy deferred concatenation (the string is built only when needed - execution, logging, the debug
view), inspection before execution (which is what InterceptDbCommand and the temporary-table builder work
from), and a readable InterpolatedSqlStatementDebugView that shows SQL, parameters and temporary tables
apart. The cost is a small allocation against a monolithic string.
InterpolatedSqlStatement is a struct because the compiler creates one per interpolated string at the call
site; a class would allocate on every statement, which is precisely the overhead this design exists to avoid.
Parameter name inference
Names come from [CallerArgumentExpression]:
public static InterpolatedParameter Parameter(
Object? value,
[CallerArgumentExpression(nameof(value))] String? parameterValueExpression = null
)
NameHelper.CreateNameFromCallerArgumentExpression strips the leading noise words this., new and Get,
drops every character that is not a letter, a digit or an underscore, uppercases the first character and
truncates to the maximum length - 60 characters, which every major database supports. It writes into a
stack-allocated buffer, so inferring a name allocates only the resulting string.
| Expression | Name |
|---|---|
Parameter(productId) |
ProductId |
Parameter(product.Id) |
ProductId |
Parameter(user.Orders[0].Total) |
UserOrders0Total |
Parameter(42) - nothing usable left |
Parameter_1, Parameter_2, … assigned when the command is built |
The same helper names temporary tables, which is why TemporaryTable(orderItems) produces
OrderItems_<guid>.
Entity mapping
Attributes and fluent configuration
Standard .NET data annotations are supported - all of [Table], [Column], [Key], [DatabaseGenerated],
[Timestamp], [ConcurrencyCheck] and [NotMapped] - rather than a custom attribute library, because they
are already documented, discoverable and shared with EF Core and other tools.
[Table("Products")]
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Int64 Id { get; set; }
[Column("ProductName")]
public String Name { get; set; } = "";
[Timestamp]
public Byte[]? Version { get; set; }
[NotMapped]
public Decimal TotalPrice => UnitPrice * Quantity;
}
Without [Table] the table name is the entity type's name as written - no pluralization, no singularization.
Without [Column] the column name is the property name.
The same mapping is available fluently, for types you cannot or do not want to annotate - types from another assembly, generated types, or a domain model kept free of persistence attributes:
DbConnectionExtensions.Configure(config =>
{
config.Entity<Product>().ToTable("Products");
config.Entity<Product>().Property(a => a.Name).HasColumnName("ProductName");
config.Entity<Product>().Property(a => a.Id).IsKey().IsIdentity();
config.Entity<Product>().Property(a => a.DiscountedPrice).IsComputed();
config.Entity<Product>().Property(a => a.IsOnSale).IsIgnored();
config.Entity<Product>().Property(a => a.Version).IsRowVersion();
});
EntityTypeBuilder<TEntity> offers ToTable and Property; EntityPropertyBuilder offers HasColumnName,
IsKey, IsIdentity, IsComputed, IsRowVersion, IsConcurrencyToken and IsIgnored, each returning
itself so the calls chain. The shape follows EF Core's, so it needs no separate mental model.
Precedence: a fluent mapping wins over data annotations. Configuring an entity type fluently makes the attributes on that type irrelevant; configuring a single property fluently makes the attributes on that property irrelevant.
Entity metadata
EntityHelper.GetEntityTypeMetadata derives one EntityTypeMetadata record per entity type and caches it:
public static EntityTypeMetadata GetEntityTypeMetadata(
[DynamicallyAccessedMembers(EntityMemberTypes)] Type entityType)
{
ArgumentNullException.ThrowIfNull(entityType);
if (entityTypeMetadataPerEntityType.TryGetValue(entityType, out var entityTypeMetadata))
{
return entityTypeMetadata;
}
return entityTypeMetadataPerEntityType.GetOrAdd(entityType, CreateEntityTypeMetadata(entityType));
}
Note the [DynamicallyAccessedMembers] annotation, and that the metadata is not created inside a GetOrAdd
factory lambda: the annotation does not flow into a lambda, so entityType would arrive at
CreateEntityTypeMetadata unannotated and the trimmer would be free to drop the entity's members. Reading
through TryGetValue first also keeps the hot path free of both the allocation and the reflection. The
IL2xxx the lambda version reports is answered by restructuring, not by a suppression - see
layer 2.
The record holds the table name; all instance properties, and the same set indexed by property name; the mapped, key, identity, computed, database-generated, row-version and concurrency-token subsets; and the insert and update projections the CRUD statements are built from, so an adapter does not re-derive them per call. The per-property reflection accessors are not built here - see Caching.
Constructor vs. property initialization
A materializer first looks for a constructor whose parameters match the columns of the result set by name and compatible type; failing that it uses the parameterless constructor and property setters; failing both it throws. Constructor injection is what makes records and other immutable entity types work, and the property-setter path keeps traditional mutable classes working unchanged. Neither imposes a base type or an attribute.
Both materializer implementations - the expression tree and the reflection counterpart - support both strategies and choose between them the same way, so an entity behaves identically on the two runtimes.
Optimistic concurrency
| Kind | Configure with | Who writes the value |
|---|---|---|
| Row version | [Timestamp] / IsRowVersion() |
the database, on every insert and update |
| Concurrency token | [ConcurrencyCheck] / IsConcurrencyToken() |
the application |
When an entity type has either kind, UpdateEntity, UpdateEntities, DeleteEntity and DeleteEntities add
the original values to the WHERE clause alongside the key. A row whose token changed since the entity was
read therefore matches nothing, the statement affects fewer rows than expected, and the library throws
DbUpdateConcurrencyException.
Why an exception rather than a return value: a silently ignored update is the failure this feature exists
to prevent, and a returned row count is easy to ignore. DbUpdateConcurrencyException carries the offending
Entity, so a caller that wants a "reload and merge" flow catches it and has the instance in hand; a caller
that does not gets a loud failure instead of lost data. Row versions and other database-generated values are
read back onto the entity after a successful insert or update, so the in-memory instance stays usable.
Temporary tables
Passing a collection to a query otherwise means an IN clause (limited to small collections), one query per
item (round-trips), XML/JSON parameters (parsing overhead, version dependencies) or table-valued parameters
(SQL Server-specific). DbConnectionPlus creates a temporary table on the fly instead:
var products = connection.Query<Product>(
$"""
SELECT *
FROM Product
WHERE Id IN (SELECT Value FROM {TemporaryTable(productIds)})
"""
);
Lifecycle
TemporaryTable(values)captures the collection reference and a name inferred from the call site.DbCommandBuilderfinds the temporary-table fragments of the statement, and for each one generates a unique name (the inferred name, orValues, plus a GUID suffix), infers the schema from the element type - a singleValuecolumn for scalars, one column per mapped property for complex objects - callsITemporaryTableBuilder.BuildTemporaryTable()and populates the table.- The fragment is replaced by the quoted table name in the SQL.
- Every command is built together with a
DbCommandDisposerowning the command, the cancellation-token registration and the statement'sTemporaryTableDisposerinstances. Disposing it drops the tables.
ExecuteNonQuery, ExecuteScalar, Exists, Query and friends dispose it in a finally, so cleanup happens
even if the statement throws. ExecuteReader cannot - the caller reads rows after the method returns - so its
reader is wrapped in CommandDisposingDataReaderDecorator and disposing the reader disposes the same object.
That is why the reader returned by ExecuteReader must be disposed, and why await using works as well as
using.
GUID suffixes rather than a sequence or a timestamp, because they are unique across connections without a shared counter, which keeps the naming thread-safe and collision-free under connection pooling. The names stay within every database's identifier limits.
Per-database implementation
| Database | Create | Populate |
|---|---|---|
| MySQL | CREATE TEMPORARY TABLE `ProductIds_abc123` (`Value` BIGINT) |
MySqlBulkCopy (LOAD DATA LOCAL INFILE; needs AllowLoadLocalInfile=true in the connection string and local_infile on the server) |
| Oracle | CREATE PRIVATE TEMPORARY TABLE "ORA$PTT_ProductIds_abc123" ("Value" NUMBER(19)) |
one parameterized INSERT, re-executed per row |
| PostgreSQL | CREATE TEMP TABLE "ProductIds_abc123" ("Value" bigint) |
COPY … FROM STDIN (FORMAT BINARY) through NpgsqlBinaryImporter |
| SQLite | CREATE TEMP TABLE "ProductIds_abc123" ("Value" INTEGER) |
one parameterized INSERT into temp."…", re-executed per row - the database is in-process, so there is no round-trip to amortize |
| SQL Server | CREATE TABLE #ProductIds_abc123 (Value BIGINT) |
SqlBulkCopy |
Oracle's private temporary table name must carry the server's private_temp_table_prefix, which
QuoteTemporaryTableName reads from v$parameter rather than hard-coding.
No silent fallback. A failing bulk insert propagates; it is not retried row by row behind the caller's
back. The one exception it does translate is cancellation - SQL Server's OperationAbortedException becomes
OperationCanceledException, matching every other cancellation path in the library.
One reader feeds every adapter. The bulk-copy APIs and the INSERT loops both consume an EnumerableReader: a
DbDataReader implementation over an IEnumerable, exposing a single Value column for scalars or one column
per mapped readable property for complex objects. Nothing materializes the sequence into an intermediate table
or array first, and the same code runs on the JIT and under Native AOT.
Testing strategy
Four tiers
| Tier | What it is | Scale |
|---|---|---|
Unit tests (DbConnectionPlus.UnitTests) |
core logic in isolation, DbConnection / DbDataReader substituted with NSubstitute |
~3,270 executed plus ~200 skipped per target framework, in a few seconds - which is what makes them the default verification loop |
Integration tests (DbConnectionPlus.IntegrationTests) |
real databases: Testcontainers-managed containers for MySQL, Oracle, PostgreSQL and SQL Server, SQLite in-process | ~600 s for the full matrix, ~90 s for the default SQLite + SQL Server pair |
Package-consumption tests (tests/package-consumption/) |
console apps consuming the packed packages, not the projects. AotConsumer is published with Native AOT; AllAdaptersConsumer installs every package and builds on the .NET 8 SDK alone, which is what makes the documented net8.0 floor a checked fact |
two CI gates |
Benchmarks (DbConnectionPlus.Benchmarks) |
regression detection against a raw DbCommand baseline and against Dapper |
BenchmarkDotNet |
Unit tests
Table-driven conversion tests. ValueConverter's matrix of source and target types is covered by
[Theory] / [MemberData] cases that assert CanConvert and the conversion itself from the same row, so a
new supported conversion is one table entry rather than two tests that can drift apart.
Null-guard verification. ArgumentNullGuardVerifier (from RentADeveloper.ArgumentNullGuards) re-invokes
a call once per reference-typed parameter with that parameter set to null and asserts an
ArgumentNullException naming it - so adding an unguarded parameter fails an existing test instead of needing
a new one.
The public surface is not tested - it is declared. Every shipping project carries PublicAPI.Shipped.txt
and PublicAPI.Unshipped.txt, and Microsoft.CodeAnalysis.PublicApiAnalyzers turns an undeclared public
member into RS0016 and a declared-but-vanished one into RS0017. With TreatWarningsAsErrors=true that is a
build error in every shipping project on net8.0 and net10.0, so an accidental break cannot compile, let alone
reach a test run. scripts/update-public-api.ps1 records a deliberate change.
Integration tests
Each database has an ITestDatabaseProvider implementation that creates the test database, runs the setup
scripts, hands out connections and cleans up. Every test method gets a fresh connection, except for SQLite,
which runs in-process.
The suite starts its own databases. Testcontainers runs the four
servers; the container definitions are the fixtures in
tests/DbConnectionPlus.IntegrationTests/TestDatabase/Containers/. There is no compose file to bring up, no
testconfig.json, and no ConnectionString_* environment variable: each fixture builds its connection string in
code from the free host port Docker published its container on, which is what removes both the port collision
with a locally installed server and any second set of connection strings for CI. CI declares no service
containers either - it runs the same code path a developer does, so a container configured wrong fails in both
places or in neither.
Started per database system, on demand. A container would be wasted on a filtered run, so
IntegrationTestsBase<T> declares an IClassFixture<TestDatabaseFixture<T>> it never reads: xUnit creates a
class fixture immediately before the first test of a class and awaits its InitializeAsync, which is both late
enough to skip database systems the run does not touch and early enough for the constructor of the test class to
open a connection. The container behind it is shared by every test class of that database system and removed by
an assembly fixture when the run ends.
What that costs. Every run starts from a freshly created server rather than from whatever a long-lived
compose stack would have accumulated, and pays the startup: about a minute of the full matrix's 597 s goes on
container startup (PostgreSQL 4.7 s, SQL Server 11.1 s, MySQL 19.8 s, Oracle 23.9 s).
That is also why the Oracle fixture pins the faststart image variant, whose database is already created, over
the plain one that spends minutes creating FREEPDB1 on first start.
Scoping rules and measured per-provider timings:
.agents/skills/integration-db/SKILL.md.
The Native AOT smoke test
Why a console application rather than more unit tests: the defect it guards against is invisible to every
other test in the repository. Nothing is trimmed on the JIT, so a broken [DynamicallyAccessedMembers] chain
passes the entire unit and integration suite. Only a natively published binary can see it.
tests/package-consumption/AotConsumer installs the SQLite adapter package, is published with
-p:PublishAot=true and executed in a CI job with no database containers, on both target frameworks -
they produce different diagnostics, so gating only the newer one would miss regressions on the documented
floor. It runs locally too:
pwsh -File scripts/verify-package-aot.ps1 -Pack
Why it consumes the package rather than referencing the project: the DAM annotations, the embedded
ILLink.Descriptors.xml and the [assembly: AssemblyMetadata("IsTrimmable", "True")] marker all have to
survive dotnet pack, and a project reference hands the trimmer the freshly compiled assembly instead of the
one a consumer installs. A packaging mistake that dropped any of them would leave a project-referenced check
green while every consumer's trimmed build silently bound nothing.
What it asserts: every property of every materialized entity, never row counts - silent trimming damage
does not remove rows, it empties them. It covers InsertEntity, Query<T> for entities under both
materialization strategies, a second SELECT shape (materializers are cached per result-set shape, so one
correct shape does not imply another), value tuples including the nested case, DataRow and its indexer, both
kinds of temporary table, and the zero-binding guard.
It also covers enums as value-tuple fields - flat and nested, converted from an integer and parsed by name -
each with its own enum type whose members are never referenced in source. Nothing in the library preserves
those types: DAM reaches the tuple, the descriptor reaches System.ValueTuple1-8`, and neither reaches a
type used as a tuple field. Their members survive because the trimmer preserves the members of an enum it
keeps (dotnet/runtime#100814,
dotnet/runtime#105351) - runtime behaviour the library depends
on but does not own, and therefore worth a regression guard.
The warning gate is zero diagnostics, from anywhere - the library, an adapter, a package in the closure, or
the consumer's own call sites. Because the query methods carry neither [RequiresUnreferencedCode] nor
[RequiresDynamicCode] (see decision 4), this program is a faithful
sample of what a consumer sees; any diagnostic at all is a regression, and one at its own call sites means a
public API has gained one of those attributes.
Two details keep the test honest. Its model types are plain classes, never records: a positional record's
compiler-generated ToString/Equals reference every property, which would root them and make the test pass
for the wrong reason. Asserting property values is nonetheless safe, because a statically rooted member is
still invisible to reflection unless an annotation preserved it - measured, not assumed.
Benchmarks
Each category has a _Command variant (hand-written ADO.NET, the baseline), a _DbConnectionPlus variant and,
where a comparable API exists, a _Dapper one. Everything runs on in-memory SQLite, the worst case for a
library like this: the statement itself costs almost nothing, so the library's overhead is the largest possible
share of the measurement.
Two jobs, and only three categories in the second. Everything runs on the JIT; Query_Entities,
Query_ValueTuples and TemporaryTable_ComplexObjects additionally run as a Native AOT binary, because they
are the only categories that reach the RuntimeFeature.IsDynamicCodeSupported branch. Every other category
executes identical IL on both runtimes, so a second row would compare RyuJIT with ILC rather than say anything
about this library. Dapper cannot appear in the AOT job at all - it builds its materializers with
Reflection.Emit - so the AOT comparison uses the Dapper.AOT source generator where it applies and the raw
baseline alone where it does not.
Tracked: execution time (mean, error, standard deviation), allocations and allocation ratio, and the ratio
against the _Command baseline within one job. The JIT-versus-AOT comparison is read from the Mean
column of the two rows for the same method.
⚠️ The benchmarks are not a trimming check. BenchmarkDotNet reports a benchmark that returned default-valued entities as a fast benchmark, not a broken one. Only the smoke test asserts values. Details: the benchmark suite's README.
Configuration and extensibility
Global configuration
One Configure entry point covers everything global - settings, entity mappings and adapter registrations -
and is callable once, at application startup:
DbConnectionExtensions.Configure(config =>
{
config.UseSqlServer();
config.EnumSerializationMode = EnumSerializationMode.Integers;
config.InterceptDbCommand = (command, temporaryTables) => logger.LogDebug("{Sql}", command.CommandText);
});
The configuration is frozen (IFreezable) when Configure returns, so any later mutation throws
InvalidOperationException - whether it comes from a second Configure call or from a builder captured during
the first. Every read afterwards is lock-free, which is what keeps the per-statement path free of
synchronization. Configure also clears the entity metadata cache, because a fluent mapping has to win over
metadata already derived from attributes or conventions.
Configure before the first database operation: the settings are read while a command is built, so a change part-way through a process would otherwise apply to some statements and not others - exactly the race the freeze prevents.
Trade-offs and alternatives
1. Extension methods vs. a fluent query builder
Chosen: extension methods on DbConnection. They work with existing connection objects without a wrapper,
are a familiar .NET pattern, allocate nothing extra, and let the SQL read like SQL through interpolated
strings.
A QueryBuilder(connection).Select("*").From("Product").Where(…) API would enable more sophisticated
programmatic query building, at the cost of verbosity and a learning curve.
2. Sync + async vs. async-only
Chosen: both, named Query() / QueryAsync(). Enterprise codebases still have synchronous code paths, and
async machinery is overhead in CPU-bound scenarios. This doubles the API surface.
3. Static global state vs. dependency injection
Chosen: one process-wide DbConnectionPlusConfiguration.Instance, populated through
DbConnectionExtensions.Configure(…) - enum serialization mode, command interceptor, entity mappings and
registered adapters all live there.
It needs no DI container and no ceremony to get started, and the library's entry points are extension methods
on DbConnection, which has nowhere to carry a scope. Because it is frozen after configuration, reading it
from any thread is safe without locking - and that is also why configuration belongs in application startup
rather than anywhere a request can reach.
Optional DI support (services.AddDbConnectionPlus(…)) remains possible later, for the rare case of per-scope
settings.
4. Caching strategy - forever vs. LRU
Chosen: cache materializers, entity metadata and property accessors forever, with no eviction. Applications work with a finite set of entity types and result-set shapes, the per-entry footprint is tiny, there are no misses after warm-up, and there is no eviction policy to tune.
The materializer cache is keyed by shape, not only by type, so an application querying the same entity with
many different SELECT lists has more entries than it has entity types. Both are still bounded by the
statements in the code. An application that generates entity types dynamically at run time (rare) would grow
the cache unboundedly.
Future considerations
Breaking changes to avoid
- Never change the signature of a public method
- Never remove a public member (mark it
[Obsolete]instead) - Never change default behaviour (add opt-in flags for new behaviour)
- Never change the exception types an existing method throws
- Never weaken a thread-safety guarantee
The declared public API files make the first two mechanical: an undeclared public member is RS0016, a
vanished one RS0017, and both are build errors.
Possible enhancements
Source-generated materializers. The one place an ahead-of-time compiled application is measurably slower than a JIT-compiled one is the mapping step, measured in Reflection, not source generation. Generation was rejected as the primary mechanism; it stays viable as an optional layer on top, because the reflection path is then a working fallback rather than a blocker. Anything built here must not reintroduce the failure mode that rejection avoided: a type the generator never discovered must not silently map to nothing.
Dependency injection support. Optional IServiceCollection extension methods and per-scope configuration
via IOptions<…>, for applications that need something other than the process-wide configuration.
Multi-result sets. QueryMultiple<Product, OrderItem>(sql) for queries returning more than one result set,
avoiding a round-trip per related collection.
A custom exception hierarchy. A DbConnectionPlusException base with specific derivations (e.g.
AdapterNotRegisteredException) would make errors easier to handle selectively than the BCL exception types
currently thrown - at the cost of a breaking change to every catch that names one of them.