diff --git a/Analyzer/AnalyzerReleases.Unshipped.md b/Analyzer/AnalyzerReleases.Unshipped.md
index b732dc4..dbc84bf 100644
--- a/Analyzer/AnalyzerReleases.Unshipped.md
+++ b/Analyzer/AnalyzerReleases.Unshipped.md
@@ -1,9 +1,9 @@
### New Rules
-Rule ID | Category | Severity | Notes
---------|----------|----------|-------
-CAERIUS001 | CaeriusNet.Generator | Error | Type must be sealed.
-CAERIUS002 | CaeriusNet.Generator | Error | Type must be partial.
-CAERIUS003 | CaeriusNet.Generator | Error | Type must declare a primary constructor with parameters.
-CAERIUS004 | CaeriusNet.Generator | Error | [GenerateTvp] requires a non-empty TvpName.
-CAERIUS005 | CaeriusNet.Generator | Warning | Parameter type falls back to sql_variant.
+ Rule ID | Category | Severity | Notes
+------------|----------------------|----------|----------------------------------------------------------
+ CAERIUS001 | CaeriusNet.Generator | Error | Type must be sealed.
+ CAERIUS002 | CaeriusNet.Generator | Error | Type must be partial.
+ CAERIUS003 | CaeriusNet.Generator | Error | Type must declare a primary constructor with parameters.
+ CAERIUS004 | CaeriusNet.Generator | Error | [GenerateTvp] requires a non-empty TvpName.
+ CAERIUS005 | CaeriusNet.Generator | Warning | Parameter type falls back to sql_variant.
diff --git a/Benchmark/CaeriusNet.Benchmark.csproj b/Benchmark/CaeriusNet.Benchmark.csproj
index 370da3f..cad1cb2 100644
--- a/Benchmark/CaeriusNet.Benchmark.csproj
+++ b/Benchmark/CaeriusNet.Benchmark.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/Documentations/docs/.vitepress/config.mts b/Documentations/docs/.vitepress/config.mts
index f77d244..8195ac7 100644
--- a/Documentations/docs/.vitepress/config.mts
+++ b/Documentations/docs/.vitepress/config.mts
@@ -35,9 +35,10 @@ export default defineConfig({
items: [
{ text: 'API Reference', link: '/documentation/api' },
{ text: 'Best Practices', link: '/documentation/best-practices' },
- { text: 'Diagnostics', link: '/documentation/diagnostics' },
+ { text: 'Telemetry & Diagnostics', link: '/documentation/diagnostics' },
]
},
+ { text: 'Examples', link: '/examples/' },
{ text: 'Performance', link: '/benchmarks/' }
],
@@ -79,7 +80,18 @@ export default defineConfig({
items: [
{ text: 'API Reference', link: '/documentation/api' },
{ text: 'Best Practices', link: '/documentation/best-practices' },
- { text: 'Diagnostics', link: '/documentation/diagnostics' }
+ { text: 'Telemetry & Diagnostics', link: '/documentation/diagnostics' }
+ ]
+ },
+ {
+ text: 'Examples',
+ collapsed: false,
+ items: [
+ { text: 'Overview', link: '/examples/' },
+ { text: 'Stored Procedures', link: '/examples/stored-procedures' },
+ { text: 'Table-Valued Parameters', link: '/examples/tvp' },
+ { text: 'Multi-Result Sets', link: '/examples/multi-result-sets' },
+ { text: 'Transactions', link: '/examples/transactions' }
]
},
{
diff --git a/Documentations/docs/documentation/advanced-usage.md b/Documentations/docs/documentation/advanced-usage.md
index db8a5fb..855cce9 100644
--- a/Documentations/docs/documentation/advanced-usage.md
+++ b/Documentations/docs/documentation/advanced-usage.md
@@ -1,23 +1,24 @@
# Advanced Usage
-This page covers patterns for combining CaeriusNet features in real-world scenarios: mixing TVP with scalar parameters, applying caching selectively, and structuring repositories for maximum reuse.
+This page collects patterns that combine multiple CaeriusNet features in real-world scenarios — TVPs alongside scalar parameters, conditional caching, multi-result-set calls with TVP filters, and fine-grained transaction handling.
-## TVP + regular parameters
+## TVPs combined with scalar parameters
Combine `AddTvpParameter` and `AddParameter` to send a structured row set alongside scalar filters:
```sql
CREATE PROCEDURE dbo.sp_GetUsers_By_Tvp_Ids_And_Age
- @Ids dbo.tvp_int READONLY,
- @Age INT
+ @Ids dbo.tvp_int READONLY,
+ @Age INT
AS
BEGIN
SET NOCOUNT ON;
SELECT Id, Username, Age
- FROM dbo.Users
- WHERE Id IN (SELECT Id FROM @Ids)
- AND Age >= @Age;
+ FROM dbo.Users
+ WHERE Id IN (SELECT Id FROM @Ids)
+ AND Age >= @Age;
END
+GO
```
```csharp
@@ -25,12 +26,13 @@ END
public sealed partial record UserIdTvp(int Id);
public async Task> GetUsersByIdsAndAgeAsync(
- IEnumerable candidates, int minAge, CancellationToken ct)
+ IReadOnlyCollection userIds, int minAge, CancellationToken ct)
{
- var tvpItems = candidates.Take(4242).Select(u => new UserIdTvp(u.Id)).ToList();
- if (tvpItems.Count == 0) return [];
+ if (userIds.Count == 0) return [];
- var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 4242)
+ var tvpItems = userIds.Select(id => new UserIdTvp(id));
+
+ var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", capacity: 4096)
.AddTvpParameter("Ids", tvpItems)
.AddParameter("Age", minAge, SqlDbType.Int)
.Build();
@@ -41,7 +43,7 @@ public async Task> GetUsersByIdsAndAgeAsync(
## Conditional caching
-Apply different cache strategies depending on input. For example, cache all-users queries but skip caching for filtered queries:
+Apply different cache strategies depending on input. For example, cache the unfiltered query but skip caching for filtered queries:
```csharp
public async Task> GetUsersAsync(
@@ -52,15 +54,15 @@ public async Task> GetUsersAsync(
if (minAge.HasValue)
builder.AddParameter("Age", minAge.Value, SqlDbType.Int);
else
- builder.AddFrozenCache("users:all"); // only cache the unfiltered result
+ builder.AddFrozenCache("users:all"); // only cache the unfiltered result
return await DbContext.QueryAsIEnumerableAsync(builder.Build(), ct) ?? [];
}
```
-## Multiple result sets with TVP
+## Multiple result sets with a TVP filter
-Multi-result SP calls support parameters including TVPs. Pass the `StoredProcedureParameters` to any `QueryMultiple*` method:
+Multi-result SP calls accept the same parameters as single-result calls — including TVPs and any cache tier:
```csharp
var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 512)
@@ -73,123 +75,120 @@ var (users, orders) = await DbContext
.QueryMultipleIEnumerableAsync(sp, ct);
```
-## Scalar return after write
+## Scalar return after a write
-Use `ExecuteScalarAsync` when the SP returns a single value, such as a newly inserted identity:
+Use `ExecuteScalarAsync` when the SP returns a single value — for example a newly inserted identity:
```csharp
var sp = new StoredProcedureParametersBuilder("dbo", "sp_InsertUser_Return_Id")
.AddParameter("Username", username, SqlDbType.NVarChar)
- .AddParameter("Age", age, SqlDbType.TinyInt)
+ .AddParameter("Age", age, SqlDbType.TinyInt)
.Build();
-var newId = await DbContext.ExecuteScalarAsync(sp, cancellationToken);
+var newId = await DbContext.ExecuteScalarAsync(sp, ct);
```
-## Performance notes
+## Performance levers
| Technique | Benefit |
|---|---|
| Pre-sized `resultSetCapacity` | Avoids `List` resizing for large result sets |
| `QueryAsImmutableArrayAsync` | Struct-backed, pool-allocated — zero extra copies |
| TVP `SqlDataRecord` reuse | Single instance per call regardless of row count |
-| `AddFrozenCache` | Eliminates DB round-trip entirely for static data |
-| `CancellationToken` propagation | Cancels in-progress SQL command on request abort |
-
----
+| `AddFrozenCache` | Eliminates the DB round-trip entirely for static data |
+| `CancellationToken` propagation | Cancels in-progress SQL commands when the request is aborted |
-## Transaction examples
+## Transaction patterns
-### Multi-command transaction
+### Multi-command unit of work
Execute multiple operations atomically — all succeed or all roll back:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var spOrder = new StoredProcedureParametersBuilder("dbo", "sp_InsertOrder")
.AddParameter("UserId", userId, SqlDbType.Int)
- .AddParameter("Total", total, SqlDbType.Decimal)
+ .AddParameter("Total", total, SqlDbType.Decimal)
.Build();
-await tx.ExecuteNonQueryAsync(spOrder, cancellationToken);
+await tx.ExecuteNonQueryAsync(spOrder, ct);
var spInventory = new StoredProcedureParametersBuilder("dbo", "sp_DecrementStock")
.AddParameter("ProductId", productId, SqlDbType.Int)
- .AddParameter("Quantity", qty, SqlDbType.Int)
+ .AddParameter("Quantity", qty, SqlDbType.Int)
.Build();
-await tx.ExecuteNonQueryAsync(spInventory, cancellationToken);
+await tx.ExecuteNonQueryAsync(spInventory, ct);
var spBalance = new StoredProcedureParametersBuilder("dbo", "sp_DebitUserBalance")
.AddParameter("UserId", userId, SqlDbType.Int)
- .AddParameter("Amount", total, SqlDbType.Decimal)
+ .AddParameter("Amount", total, SqlDbType.Decimal)
.Build();
-await tx.ExecuteNonQueryAsync(spBalance, cancellationToken);
+await tx.ExecuteNonQueryAsync(spBalance, ct);
-await tx.CommitAsync(cancellationToken);
+await tx.CommitAsync(ct);
```
### Conditional rollback
-Roll back based on business logic without an exception:
+Roll back based on business logic without raising an exception:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var spCheck = new StoredProcedureParametersBuilder("dbo", "sp_GetAvailableStock", 1)
.AddParameter("ProductId", productId, SqlDbType.Int)
.Build();
-var stock = await tx.ExecuteScalarAsync(spCheck, cancellationToken);
+var stock = await tx.ExecuteScalarAsync(spCheck, ct);
if (stock < requestedQuantity)
{
- await tx.RollbackAsync(cancellationToken);
+ await tx.RollbackAsync(ct);
return OrderResult.InsufficientStock;
}
var spReserve = new StoredProcedureParametersBuilder("dbo", "sp_ReserveStock")
- .AddParameter("ProductId", productId, SqlDbType.Int)
- .AddParameter("Quantity", requestedQuantity, SqlDbType.Int)
+ .AddParameter("ProductId", productId, SqlDbType.Int)
+ .AddParameter("Quantity", requestedQuantity, SqlDbType.Int)
.Build();
-await tx.ExecuteNonQueryAsync(spReserve, cancellationToken);
-await tx.CommitAsync(cancellationToken);
+await tx.ExecuteNonQueryAsync(spReserve, ct);
+await tx.CommitAsync(ct);
return OrderResult.Reserved;
```
-### Poison state handling
+### Poison-state handling
-Handle failures gracefully when a command poisons the transaction:
+When a command fails, the scope poisons. Roll back and let the caller retry the entire unit of work:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
- await tx.ExecuteNonQueryAsync(sp1, cancellationToken);
- await tx.ExecuteNonQueryAsync(sp2, cancellationToken); // may fail
- await tx.CommitAsync(cancellationToken);
+ await tx.ExecuteNonQueryAsync(sp1, ct);
+ await tx.ExecuteNonQueryAsync(sp2, ct); // may throw — poisons the scope
+ await tx.CommitAsync(ct);
}
catch (CaeriusNetSqlException ex)
{
logger.LogWarning(ex, "Transaction poisoned by {Procedure}", ex.ProcedureName);
- await tx.RollbackAsync(cancellationToken);
- // Retry the entire operation at the caller level
- throw;
+ await tx.RollbackAsync(ct);
+ throw; // retry at the caller — never partially recover
}
```
-## Logging configuration
+## Logging configuration in detail
### Setting up the logger with DI
-Configure CaeriusNet logging during application startup. The logger must be set before any database operations:
+Configure CaeriusNet logging during application startup. The logger must be set before any database operations are performed:
```csharp
var builder = WebApplication.CreateBuilder(args);
@@ -197,7 +196,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
- logging.AddFilter("CaeriusNet", LogLevel.Information);
+ logging.AddFilter("CaeriusNet", LogLevel.Information);
logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning);
});
@@ -208,21 +207,20 @@ CaeriusNetBuilder
var app = builder.Build();
-// Set the logger after building the service provider
+// LoggerProvider is wired automatically when ILoggerFactory is in DI;
+// the explicit call below is only needed if you build without DI.
LoggerProvider.SetLogger(app.Services.GetRequiredService());
```
-### Filtering by category
-
-Use event ID ranges to control verbosity per subsystem:
+### Filtering by category in `appsettings.json`
```json
{
"Logging": {
"LogLevel": {
- "Default": "Information",
- "CaeriusNet.Cache": "Warning",
- "CaeriusNet.Commands": "Debug"
+ "Default": "Information",
+ "CaeriusNet.Cache": "Warning",
+ "CaeriusNet.Commands": "Debug"
}
}
}
diff --git a/Documentations/docs/documentation/api.md b/Documentations/docs/documentation/api.md
index 5b28f15..23fb998 100644
--- a/Documentations/docs/documentation/api.md
+++ b/Documentations/docs/documentation/api.md
@@ -1,24 +1,32 @@
# API Reference
-This section provides a practical reference for the main public APIs exposed by the CaeriusNet package. All examples use C# 14/.NET 10 and Microsoft.Data.SqlClient.
+This page is a practical reference for the public API exposed by CaeriusNet. All examples target **C# 14 / .NET 10** with `Microsoft.Data.SqlClient`. Namespaces are abbreviated below — add the matching `using` directives in your project.
-- Namespaces shown below are abbreviated; use your project’s using directives accordingly.
-- Code examples assume Dependency Injection is configured via CaeriusNetBuilder.
+> Examples assume DI is configured via `CaeriusNetBuilder` (see [Installation & Setup](/quickstart/getting-started)).
## Builders
-### CaeriusNet.Builders.CaeriusNetBuilder
+### `CaeriusNet.Builders.CaeriusNetBuilder`
+
Configures CaeriusNet services for DI.
+
```csharp
static CaeriusNetBuilder Create(IServiceCollection services);
static CaeriusNetBuilder Create(IHostApplicationBuilder builder);
+
CaeriusNetBuilder WithSqlServer(string connectionString);
CaeriusNetBuilder WithRedis(string? connectionString);
+
CaeriusNetBuilder WithAspireSqlServer(string connectionName = "sqlserver");
CaeriusNetBuilder WithAspireRedis(string connectionName = "redis");
+
+CaeriusNetBuilder WithTelemetryOptions(CaeriusTelemetryOptions options);
+
IServiceCollection Build();
```
+
Example:
+
```csharp
CaeriusNetBuilder
.Create(services)
@@ -27,78 +35,144 @@ CaeriusNetBuilder
.Build();
```
-### CaeriusNet.Builders.StoredProcedureParametersBuilder
-Fluent builder for stored-procedure execution settings, parameters, and caching.
+### `CaeriusNet.Builders.StoredProcedureParametersBuilder`
+
+Fluent builder for Stored Procedure execution settings, parameters, and caching.
+
+**Constructor:**
-Constructor:
```csharp
-StoredProcedureParametersBuilder(string schemaName, string procedureName, int resultSetCapacity = 1);
+StoredProcedureParametersBuilder(
+ string schemaName,
+ string procedureName,
+ int resultSetCapacity = 1);
```
-Parameter methods:
+**Parameter methods:**
+
```csharp
-StoredProcedureParametersBuilder AddParameter(string parameter, object value, SqlDbType dbType);
-StoredProcedureParametersBuilder AddTvpParameter(string parameter, IEnumerable items) where T : class, ITvpMapper;
+StoredProcedureParametersBuilder AddParameter(
+ string parameter,
+ object value,
+ SqlDbType dbType);
+
+StoredProcedureParametersBuilder AddTvpParameter(
+ string parameter,
+ IEnumerable items)
+ where T : class, ITvpMapper;
```
-Caching methods:
+**Caching methods:**
+
```csharp
StoredProcedureParametersBuilder AddInMemoryCache(string cacheKey, TimeSpan expiration);
-StoredProcedureParametersBuilder AddFrozenCache(string cacheKey);
-StoredProcedureParametersBuilder AddRedisCache(string cacheKey, TimeSpan? expiration = null);
+StoredProcedureParametersBuilder AddFrozenCache (string cacheKey);
+StoredProcedureParametersBuilder AddRedisCache (string cacheKey, TimeSpan? expiration = null);
```
-Build:
+**Build:**
+
```csharp
-StoredProcedureParameters Build()
+StoredProcedureParameters Build();
```
-Example (read with capacity):
+Example with capacity, parameters, TVP, and Redis cache:
+
```csharp
-var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", resultSetCapacity: 250)
+var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 1024)
+ .AddTvpParameter("Ids", tvpItems) // T : class, ITvpMapper
+ .AddParameter ("Age", age, SqlDbType.Int)
+ .AddRedisCache ($"users:age:{age}", TimeSpan.FromMinutes(2))
.Build();
```
-Example (parameters + TVP + cache):
+## Telemetry
+
+### `CaeriusNet.Telemetry.CaeriusTelemetryOptions`
+
+Configuration record consumed globally by every command pipeline.
+
```csharp
-var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids_And_Age", 1024)
- .AddTvpParameter("Ids", tvpItems) // T implements ITvpMapper
- .AddParameter("Age", age, SqlDbType.Int)
- .AddRedisCache("users:age:" + age, TimeSpan.FromMinutes(2))
- .Build();
+public sealed record CaeriusTelemetryOptions
+{
+ public bool CaptureParameterValues { get; init; } = false;
+}
+```
+
+| Property | Default | Description |
+|---|---|---|
+| `CaptureParameterValues` | `false` | When `true`, the `caerius.sp.parameters` tag shows `@name=value`. TVP values always render as `[TVP]`. Off by default for PII safety. |
+
+### `CaeriusNet.Telemetry.CaeriusDiagnostics`
+
+Static registry of OpenTelemetry signals.
+
+```csharp
+public static class CaeriusDiagnostics
+{
+ public const string SourceName = "CaeriusNet";
+ public static ActivitySource ActivitySource { get; }
+ public static Meter Meter { get; }
+}
```
+Register in your telemetry pipeline (typically in `ServiceDefaults`):
+
+```csharp
+builder.Services.AddOpenTelemetry()
+ .WithTracing(t => t.AddSource(CaeriusDiagnostics.SourceName))
+ .WithMetrics(m => m.AddMeter (CaeriusDiagnostics.SourceName));
+```
+
+See [Aspire Integration — Tracing & Telemetry](/documentation/aspire#tracing-telemetry) for the complete tag and metric reference.
+
## Abstractions
-### CaeriusNet.Abstractions.ICaeriusNetDbContext
-Represents a factory for opening SQL connections and exposes an optional Redis cache manager.
+### `CaeriusNet.Abstractions.ICaeriusNetDbContext`
+
+Factory for opening SQL connections; exposes the optional Redis cache manager.
-Properties:
```csharp
IRedisCacheManager? RedisCacheManager { get; }
+ValueTask DbConnectionAsync(CancellationToken ct = default);
```
-Methods:
+### `CaeriusNet.Abstractions.ICaeriusNetTransaction`
+
+Transaction scope obtained via `BeginTransactionAsync`. See [Transactions](/documentation/transactions) for the full contract.
+
```csharp
-SqlConnection DbConnection()
+ValueTask CommitAsync (CancellationToken ct = default);
+ValueTask RollbackAsync (CancellationToken ct = default);
+ValueTask DisposeAsync ();
+
+// Same Execute*/Query* surface as ICaeriusNetDbContext, enlisted in the transaction.
```
-### CaeriusNet.Abstractions.IRedisCacheManager
+### `CaeriusNet.Abstractions.IRedisCacheManager`
+
Distributed cache adapter used when Redis is configured.
+
```csharp
bool TryGet(string cacheKey, out T? value);
-void Store(string cacheKey, T value, TimeSpan? expiration) where T : notnull
+void Store (string cacheKey, T value, TimeSpan? expiration) where T : notnull;
```
## Mappers
-### CaeriusNet.Mappers.ISpMapper
-Compile-time friendly contract for mapping a row from SqlDataReader.
+### `CaeriusNet.Mappers.ISpMapper`
+
+Compile-time-friendly contract for mapping a row from `SqlDataReader`.
+
```csharp
-static abstract T MapFromDataReader(SqlDataReader reader)
+public interface ISpMapper where TSelf : ISpMapper
+{
+ static abstract TSelf MapFromDataReader(SqlDataReader reader);
+}
```
-Usage (manual):
+Manual implementation:
+
```csharp
public sealed record UserDto(int Id, string Name) : ISpMapper
{
@@ -107,22 +181,30 @@ public sealed record UserDto(int Id, string Name) : ISpMapper
}
```
-### CaeriusNet.Mappers.ITvpMapper
-Defines how to convert items to an `IEnumerable` for TVP.
+### `CaeriusNet.Mappers.ITvpMapper`
+
+Defines how to convert items to `IEnumerable` for TVP transport.
+
```csharp
-static abstract string TvpTypeName { get; }
-IEnumerable MapAsSqlDataRecords(IEnumerable items)
+public interface ITvpMapper where TSelf : class, ITvpMapper
+{
+ static abstract string TvpTypeName { get; }
+ IEnumerable MapAsSqlDataRecords(IEnumerable items);
+}
```
-Usage (manual):
+Manual implementation:
+
```csharp
-public sealed record UsersIdsTvp(int Id) : ITvpMapper
+public sealed record UserIdsTvp(int Id) : ITvpMapper
{
public static string TvpTypeName => "dbo.tvp_int";
- public IEnumerable MapAsSqlDataRecords(IEnumerable items)
+
+ public IEnumerable MapAsSqlDataRecords(IEnumerable items)
{
var metaData = new[] { new SqlMetaData("Id", SqlDbType.Int) };
- var record = new SqlDataRecord(metaData);
+ var record = new SqlDataRecord(metaData);
+
foreach (var item in items)
{
record.SetInt32(0, item.Id);
@@ -134,97 +216,173 @@ public sealed record UsersIdsTvp(int Id) : ITvpMapper
## Attributes (Source Generators)
-### CaeriusNet.Attributes.Dto.GenerateDtoAttribute
-Annotate sealed partial records/classes to generate `ISpMapper` at compile time.
+### `CaeriusNet.Attributes.Dto.GenerateDtoAttribute`
+
+Annotate a sealed partial record/class to generate `ISpMapper` at compile time.
+
```csharp
[GenerateDto]
public sealed partial record UserDto(int Id, string Name, byte? Age);
```
-### CaeriusNet.Attributes.Tvp.GenerateTvpAttribute
-Annotate sealed partial records/classes to generate `ITvpMapper`.
+### `CaeriusNet.Attributes.Tvp.GenerateTvpAttribute`
+
+Annotate a sealed partial record/class to generate `ITvpMapper`.
+
+**Required init properties:**
-Required init properties:
```csharp
-string Schema { get; init; } = "dbo"
+string Schema { get; init; } = "dbo";
string TvpName { get; init; }
```
-Exemple:
+
+Example:
+
```csharp
[GenerateTvp(Schema = "Types", TvpName = "tvp_Int")]
public sealed partial record UsersIntTvp(int UserId);
```
-## Read Commands
-Extension methods on ICaeriusNetDbContext for reading result sets.
+See [Source Generators](/documentation/source-generators) for the complete generator behaviour and [Compiler Diagnostics](/documentation/diagnostics) for the analyzer rules.
+
+## Read commands
+
+Extension methods on `ICaeriusNetDbContext` for reading result sets.
+
+Namespace: `CaeriusNet.Commands.Reads.SimpleReadSqlAsyncCommands`
-Namespace: CaeriusNet.Commands.Reads.SimpleReadSqlAsyncCommands
```csharp
-ValueTask FirstQueryAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-ValueTask> QueryAsReadOnlyCollectionAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-ValueTask?> QueryAsIEnumerableAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-ValueTask> QueryAsImmutableArrayAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+ValueTask
+ FirstQueryAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+
+ValueTask>
+ QueryAsReadOnlyCollectionAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+
+ValueTask?>
+ QueryAsIEnumerableAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+
+ValueTask>
+ QueryAsImmutableArrayAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
```
-Each TResultSet must be a class implementing `ISpMapper`.
+`TResult` must be a class implementing `ISpMapper`.
Example:
+
```csharp
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250).Build();
-var users = await dbContext.QueryAsIEnumerableAsync(sp);
+var users = await dbContext.QueryAsIEnumerableAsync(sp, ct);
```
-## Write Commands
-Extension methods on ICaeriusNetDbContext for non-query operations.
+## Write commands
+
+Extension methods on `ICaeriusNetDbContext` for non-query operations.
+
+Namespace: `CaeriusNet.Commands.Writes.WriteSqlAsyncCommands`
-Namespace: CaeriusNet.Commands.Writes.WriteSqlAsyncCommands
```csharp
-ValueTask ExecuteScalarAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-ValueTask ExecuteNonQueryAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-ValueTask ExecuteAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+ValueTask ExecuteScalarAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+ValueTask ExecuteNonQueryAsync (this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+ValueTask ExecuteAsync (this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
```
Examples:
+
```csharp
-// Get affected rows
+// Affected rows
var sp = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserAge_By_Guid")
.AddParameter("Guid", guid, SqlDbType.UniqueIdentifier)
- .AddParameter("Age", age, SqlDbType.TinyInt)
+ .AddParameter("Age", age, SqlDbType.TinyInt)
.Build();
-var affected = await dbContext.ExecuteNonQueryAsync(sp);
-// Fire-and-forget (no count)
-await dbContext.ExecuteAsync(sp);
+var affected = await dbContext.ExecuteNonQueryAsync(sp, ct);
+
+// Fire-and-forget
+await dbContext.ExecuteAsync(sp, ct);
```
-## Multiple Result Sets
-Namespace: CaeriusNet.Commands.Reads.MultiIEnumerableReadSqlAsyncCommands
+## Multiple result sets
+
+Up to **5 result sets** per call. Each return type maps positionally to a `SELECT` statement in the SP.
+
+Namespace: `CaeriusNet.Commands.Reads.MultiIEnumerableReadSqlAsyncCommands` (similar shapes exist for `ReadOnlyCollection` and `ImmutableArray`).
+
```csharp
-Task<(IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
-Task<(IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...);
-Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...);
-Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable, IEnumerable)> QueryMultipleIEnumerableAsync(...);
+Task<(IEnumerable, IEnumerable)>
+ QueryMultipleIEnumerableAsync(this ICaeriusNetDbContext, StoredProcedureParameters, CancellationToken);
+
+Task<(IEnumerable, IEnumerable, IEnumerable)>
+ QueryMultipleIEnumerableAsync(/* ... */);
+
+Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable)>
+ QueryMultipleIEnumerableAsync(/* ... */);
+
+Task<(IEnumerable, IEnumerable, IEnumerable, IEnumerable, IEnumerable)>
+ QueryMultipleIEnumerableAsync(/* ... */);
```
Example:
+
```csharp
var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 128).Build();
+
var (users, orders, products) = await dbContext
- .QueryMultipleIEnumerableAsync(sp);
+ .QueryMultipleIEnumerableAsync(sp, ct);
+```
+
+## Transactions
+
+Open a transaction scope from the DB context. See [Transactions](/documentation/transactions) for the complete state-machine and tracing reference.
+
+```csharp
+ValueTask
+ BeginTransactionAsync(this ICaeriusNetDbContext, IsolationLevel level, CancellationToken ct = default);
+```
+
+Example:
+
+```csharp
+await using var tx = await dbContext.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
+await tx.ExecuteNonQueryAsync(sp1, ct);
+await tx.ExecuteNonQueryAsync(sp2, ct);
+await tx.CommitAsync(ct);
```
## Caching
-Caching is configured per-call via StoredProcedureParametersBuilder and resolved using:
-- Frozen (in-process, immutable)
-- InMemory (in-process, expirable)
-- Redis (distributed, optional)
+
+Caching is configured per call on `StoredProcedureParametersBuilder`:
+
+- **Frozen** — in-process, immutable
+- **InMemory** — in-process, expirable
+- **Redis** — distributed (optional, requires `WithRedis(...)` or `WithAspireRedis(...)`)
Example:
+
```csharp
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
- .AddFrozenCache("all_users_frozen")
+ .AddFrozenCache("users:all:frozen")
.Build();
-var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp);
+
+var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct);
+```
+
+See [Caching](/documentation/cache) for the full guide.
+
+## Exceptions
+
+### `CaeriusNet.Exceptions.CaeriusNetSqlException`
+
+Wraps `SqlException` from any failed SQL command. The original exception is available via `InnerException`. Carries the originating procedure name to simplify diagnostics:
+
+```csharp
+try
+{
+ await dbContext.ExecuteAsync(sp, ct);
+}
+catch (CaeriusNetSqlException ex) when (ex.InnerException is SqlException sqlEx)
+{
+ logger.LogError(ex, "SP {Procedure} failed (error {Number})", ex.ProcedureName, sqlEx.Number);
+}
```
-For Redis, configure CaeriusNetBuilder.WithRedis(...) or WithAspireRedis(...).
+The active OpenTelemetry span is tagged `ActivityStatusCode.Error` before the exception bubbles up.
diff --git a/Documentations/docs/documentation/aspire.md b/Documentations/docs/documentation/aspire.md
index b944645..413e991 100644
--- a/Documentations/docs/documentation/aspire.md
+++ b/Documentations/docs/documentation/aspire.md
@@ -1,16 +1,18 @@
# Aspire Integration
-CaeriusNet provides first-class integration with [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) through `WithAspireSqlServer` and `WithAspireRedis` builder methods. Aspire manages connection strings via its resource abstraction — CaeriusNet resolves them automatically.
+CaeriusNet provides first-class integration with [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) through `WithAspireSqlServer` and `WithAspireRedis`. Aspire manages SQL Server and Redis as named resources in the AppHost; CaeriusNet resolves their connection strings automatically.
+
+This page also documents the **OpenTelemetry signals** that CaeriusNet emits, regardless of whether you use Aspire — you only need to register them with your telemetry pipeline.
## Prerequisites
- .NET 10 and a .NET Aspire AppHost project
-- `CaeriusNet` NuGet package in your service project
-- SQL Server and (optionally) Redis resources declared in AppHost
+- The `CaeriusNet` NuGet package in your service project
+- SQL Server and (optionally) Redis resources declared in the AppHost
## AppHost configuration
-In your Aspire AppHost project, declare SQL Server and Redis resources and pass their references to your service:
+Declare SQL Server and Redis in the AppHost and pass their references to your service:
```csharp
// AppHost/Program.cs
@@ -30,18 +32,18 @@ builder.Build().Run();
## Service project configuration
-In your service's `Program.cs` or `Startup.cs`, use `WithAspireSqlServer` and `WithAspireRedis`. These methods resolve the connection string from Aspire's named connection:
+In your service's `Program.cs`, use `WithAspireSqlServer` and `WithAspireRedis`. These methods resolve the connection string from Aspire's named connection registry:
::: code-group
-```csharp [With Redis]
+```csharp [SQL Server + Redis]
using CaeriusNet.Builders;
var builder = WebApplication.CreateBuilder(args);
-builder.AddServiceDefaults(); // Aspire service defaults
+builder.AddServiceDefaults(); // Aspire ServiceDefaults
CaeriusNetBuilder
.Create(builder)
- .WithAspireSqlServer("sqlserver") // matches AppHost resource name
+ .WithAspireSqlServer("sqlserver") // matches the AppHost resource name
.WithAspireRedis("redis") // optional distributed cache
.Build();
@@ -65,9 +67,9 @@ app.Run();
```
:::
-## Console app / Worker Service pattern
+## Console / Worker Service pattern
-For console apps or background workers using Aspire:
+For console apps or background workers running under Aspire:
```csharp
using CaeriusNet.Builders;
@@ -83,8 +85,6 @@ CaeriusNetBuilder
.Build();
var host = builder.Build();
-
-// Inject ICaeriusNetDbContext via DI in your hosted services
host.Run();
```
@@ -100,7 +100,7 @@ CaeriusNetBuilder
.Build();
```
-## Resource name matching
+## Resource-name matching
The string passed to `WithAspireSqlServer` and `WithAspireRedis` must match the resource name declared in the AppHost:
@@ -110,7 +110,8 @@ The string passed to `WithAspireSqlServer` and `WithAspireRedis` must match the
| `builder.AddRedis("redis")` | `.WithAspireRedis("redis")` |
::: tip Default names
-If you use the conventional names `"sqlserver"` and `"redis"` you can also use the parameter-less overloads:
+If you use the conventional names `"sqlserver"` and `"redis"`, you can also call the parameter-less overloads:
+
```csharp
.WithAspireSqlServer() // defaults to "sqlserver"
.WithAspireRedis() // defaults to "redis"
@@ -121,7 +122,7 @@ If you use the conventional names `"sqlserver"` and `"redis"` you can also use t
```csharp
// AppHost/Program.cs
-var sql = builder.AddSqlServer("sqlserver").AddDatabase("CaeriusDb");
+var sql = builder.AddSqlServer("sqlserver").AddDatabase("CaeriusDb");
var redis = builder.AddRedis("redis");
builder.AddProject("api")
.WithReference(sql)
@@ -162,6 +163,100 @@ public sealed record UserRepository(ICaeriusNetDbContext DbContext)
}
```
+## Telemetry options
+
+Use `WithTelemetryOptions` to configure how CaeriusNet records spans and metrics. Options are applied globally and consulted by every command pipeline.
+
+```csharp
+CaeriusNetBuilder.Create(builder)
+ .WithAspireSqlServer("sqlserver")
+ .WithAspireRedis()
+ .WithTelemetryOptions(new CaeriusTelemetryOptions
+ {
+ // Include parameter names AND values in caerius.sp.parameters.
+ // ⚠ Enable only outside production — values may contain PII
+ // (names, emails, tokens, monetary amounts, etc.).
+ CaptureParameterValues = true
+ })
+ .Build();
+```
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `CaptureParameterValues` | `bool` | `false` | When `true`, the `caerius.sp.parameters` tag shows `@name=value` pairs instead of just `@name`. TVP values are always shown as `[TVP]`. |
+
+::: warning Production guidance
+`CaptureParameterValues = true` is convenient in staging or development to correlate a trace with the exact parameters that produced it. In production, parameter values can contain sensitive data (user IDs, emails, amounts …) and should generally **not** be emitted to a shared telemetry backend.
+:::
+
+## Tracing & telemetry {#tracing-telemetry}
+
+CaeriusNet emits OpenTelemetry-compatible signals through the BCL primitives — no OpenTelemetry SDK package is added to the library itself. Consumers (typically the Aspire `ServiceDefaults` project) opt in by registering the source and meter:
+
+```csharp
+// ServiceDefaults/Extensions.cs
+using CaeriusNet.Telemetry;
+
+builder.Services.AddOpenTelemetry()
+ .WithTracing(t => t.AddSource(CaeriusDiagnostics.SourceName)) // "CaeriusNet"
+ .WithMetrics(m => m.AddMeter (CaeriusDiagnostics.SourceName)); // "CaeriusNet"
+```
+
+When no listener is subscribed, no allocation is performed.
+
+### Spans
+
+Every Stored Procedure call creates an `Activity` of `ActivityKind.Client` named `SP {schema}.{procedure}`. Failures set `ActivityStatusCode.Error` and attach the SQL exception via `Activity.AddException`.
+
+| Tag | Description |
+|---|---|
+| `db.system` | Always `mssql` (OpenTelemetry semantic convention) |
+| `db.operation` | The calling command (`FirstQueryAsync`, `QueryMultipleImmutableArrayAsync`, `ExecuteNonQueryAsync`, …) |
+| `db.statement` | `{schema}.{procedure}` |
+| `caerius.sp.schema` | Schema of the Stored Procedure |
+| `caerius.sp.name` | Name of the Stored Procedure |
+| `caerius.sp.parameters` | Comma-separated parameter names (e.g. `@id,@tvp`); shows `@name=value` when `CaptureParameterValues = true`. TVP values always render as `[TVP]`. |
+| `caerius.sp.command` | Same as `db.operation` (kept for filter convenience) |
+| `caerius.tvp.used` | `true` when at least one TVP is attached |
+| `caerius.tvp.type_name` | TVP type name (e.g. `dbo.tvp_int`); comma-separated when several TVPs are used |
+| `caerius.resultset.multi` | `true` when more than one result set is requested |
+| `caerius.resultset.expected_count` | Number of result sets requested (1 by default; 2/3/4/5 for the multi-RS overloads) |
+| `caerius.cache.tier` / `caerius.cache.hit` | Set on the active span when a cache lookup happens during the call |
+| `caerius.tx` | `true` when the call runs inside an `ICaeriusNetTransaction` |
+| `caerius.rows_returned` / `caerius.rows_affected` | Set on success |
+
+### Metrics
+
+Four instruments are exposed by the `CaeriusNet` meter, all tagged with the same `caerius.sp.*` dimensions as the spans:
+
+| Instrument | Type | Unit | Purpose |
+|---|---|---|---|
+| `caerius.sp.duration` | Histogram | ms | Stored Procedure execution duration |
+| `caerius.sp.executions` | Counter | calls | Number of executions started (success or failure) |
+| `caerius.sp.errors` | Counter | calls | Number of executions that failed with a SQL error |
+| `caerius.cache.lookups` | Counter | lookups | Cache lookups, tagged with `caerius.cache.tier` (`Frozen` / `InMemory` / `Redis`) and `caerius.cache.hit` (`true` / `false`) |
+
+When a cache hit short-circuits the SQL call, **no DB span is created** and only `caerius.cache.lookups{hit=true}` is emitted — the Aspire dashboard accurately shows the database was not contacted.
+
+## Transaction tracing {#transaction-tracing}
+
+Every `ICaeriusNetTransaction` scope emits a parent **`TX` span** (kind = Internal) that wraps all child SP spans. This produces a single cohesive trace in the Aspire dashboard instead of one orphaned span per command:
+
+```text
+TX (kind=Internal, caerius.tx.isolation_level=ReadCommitted, caerius.tx.outcome=committed)
+├── SP Users.usp_Create_User (kind=Client, caerius.tx=true)
+└── SP Users.usp_Create_Order (kind=Client, caerius.tx=true)
+```
+
+| Tag | Description |
+|---|---|
+| `caerius.tx.isolation_level` | The SQL Server isolation level (e.g. `ReadCommitted`) |
+| `caerius.tx.outcome` | `committed`, `rolled-back`, `auto-rollback`, `poisoned-auto-rollback`, `commit-failed`, `rollback-failed` |
+
+::: tip SQL-side rollback in the dashboard
+A Stored Procedure that wraps its own `BEGIN TRY / BEGIN CATCH` rolls back internally and re-throws, which surfaces as a `CaeriusNetSqlException`. The corresponding SP span is tagged `ActivityStatusCode.Error` — this is **expected** and intentional, not a CaeriusNet bug.
+:::
+
---
**Next:** [API Reference](/documentation/api) — full surface of all public types and methods.
diff --git a/Documentations/docs/documentation/best-practices.md b/Documentations/docs/documentation/best-practices.md
index 1f7c871..1d5bdbe 100644
--- a/Documentations/docs/documentation/best-practices.md
+++ b/Documentations/docs/documentation/best-practices.md
@@ -1,189 +1,197 @@
-# Best Practices and Guidelines
+# Best Practices
-This guide distills practical recommendations for building reliable, secure, and high‑performance applications with CaeriusNet. It complements the Get Started, Usage, and Advanced Usage pages.
+This page distills practical recommendations for building reliable, secure, and high-performance applications with CaeriusNet. It complements the [Quickstart](/quickstart/getting-started), [Reading Data](/documentation/reading-data), [Writing Data](/documentation/writing-data), and [Advanced Usage](/documentation/advanced-usage) guides.
-Applies to: C# 14 / .NET 10, SQL Server 2019+, Microsoft.Data.SqlClient.
+> Applies to: **C# 14 / .NET 10**, **SQL Server 2019 +**, `Microsoft.Data.SqlClient`.
-## Architecture and Patterns
+## Architecture & patterns
-- Prefer the Repository pattern. Keep data access isolated in repositories behind interfaces.
-- Use Dependency Injection to obtain ICaeriusNetDbContext where needed.
-- Favor sealed records for DTOs and TVPs. They are immutable, lightweight, and great with source generators.
-- Prefer source generators:
-```csharp
- [GenerateDto] // to auto-generate ISpMapper for DTOs.
- [GenerateTvp(Schema = "", TvpName = "")] // to auto-generate ITvpMapper for TVPs.
-```
-- Keep mapping and database concerns out of controllers/services. Services orchestrate repositories.
-
-## DTO Mapping Guidelines
-
-- Mapping is ordinal-based. The constructor parameter order of your DTO must match the column order in the SQL result set.
- - Column names don’t affect mapping. Aliases can improve readability but are not required for mapping.
-- Use nullable types for columns that may return NULL from SQL Server.
-- Keep DTOs minimal and purpose‑specific. Avoid large catch‑all DTOs.
-- Example (manual mapping):
-```csharp
-public sealed record UserDto(int Id, string Name, byte? Age)
- : ISpMapper
-{
- public static UserDto MapFromDataReader(SqlDataReader reader)
- => new(reader.GetInt32(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetByte(2));
-}
-```
+- **Use the Repository pattern.** Keep data access isolated behind interfaces — `ICaeriusNetDbContext` is the only external dependency repositories should know about.
+- **Inject `ICaeriusNetDbContext` via DI.** Never instantiate it manually; the lifetime and connection management is handled by the framework.
+- **Favour sealed records.** They are immutable, lightweight, value-equatable, and pair well with the source generators.
+- **Always prefer source generators.** The analyzer enforces the contract and the generated code is identical to a hand-written mapper:
+ ```csharp
+ [GenerateDto]
+ public sealed partial record UserDto(int Id, string Name, byte? Age);
+
+ [GenerateTvp(Schema = "dbo", TvpName = "tvp_int")]
+ public sealed partial record UserIdTvp(int Id);
+ ```
+- **Keep mapping out of services and controllers.** Repositories own SQL; services orchestrate them.
+
+## DTO mapping
+
+- Mapping is **ordinal-based** — the constructor parameter order **must** match the SP `SELECT` column order. Aliases are cosmetic.
+- Mark columns that may return `NULL` as nullable in the DTO; the source generator emits `IsDBNull` guards automatically.
+- Keep DTOs **purpose-specific**. Avoid catch-all DTOs that grow with every screen.
+- Manual mapping example for reference:
+ ```csharp
+ public sealed record UserDto(int Id, string Name, byte? Age) : ISpMapper
+ {
+ public static UserDto MapFromDataReader(SqlDataReader reader)
+ => new(
+ reader.GetInt32(0),
+ reader.GetString(1),
+ reader.IsDBNull(2) ? null : reader.GetByte(2));
+ }
+ ```
+
+## Stored Procedure conventions (T-SQL)
+
+- **Use dedicated schemas** (`Users`, `Orders`, `Sales`, …) instead of the default `dbo` for application code where feasible.
+- Always start procedures with `SET NOCOUNT ON;` to avoid spurious result sets.
+- **Never `SELECT *`.** Explicitly list columns in the order your DTO expects.
+- Keep result-set shape stable. Any change in cardinality or order requires a matching DTO change.
+- Prefer parameterized procedures for all inputs — avoid dynamic SQL unless absolutely required.
+- Wrap multi-statement writes in `BEGIN TRY / BEGIN CATCH` with explicit `COMMIT` / `ROLLBACK` and `THROW;` for re-raise.
+- Adopt a consistent name convention, e.g. `Schema.sp_Action_Subject_By_Filter`.
+
+## TVPs
+
+- Define SQL types with the **minimal columns** needed.
+- Use `[GenerateTvp]` for zero-boilerplate mappers.
+- Ensure the .NET TVP record's primary-constructor parameters match the SQL column definition exactly (order, nullability, types).
+- Pass TVPs `READONLY` (required by SQL Server).
+- Validate non-empty input before calling — `AddTvpParameter` throws `ArgumentException` on empty collections.
+- For very large sets, batch into reasonable chunks (e.g. 5 000–10 000 rows per call) to keep memory and CPU usage predictable.
+
+## Caching strategy
+
+Pick the right tier per call via `StoredProcedureParametersBuilder`:
+
+- **Frozen** — in-process, immutable, fastest. **Only** for data that truly never changes during the application lifetime (lookup tables, currency codes, permission definitions).
+- **InMemory** — in-process with TTL. Good for hot paths where some staleness is acceptable. Always set a sensible expiration.
+- **Redis** — distributed. Use in multi-instance deployments. Secure with TLS + auth in production.
-## Stored Procedure Guidelines (T/SQL)
-
-- Use dedicated schemas (e.g., App, Users, Sales). Avoid dbo for application code where feasible.
-- Always SET NOCOUNT ON inside procedures to avoid extra result sets.
-- Keep result shapes stable. Any change in cardinality or order requires a matching DTO change.
-- Avoid SELECT *. Explicitly list columns in the exact order your DTO expects.
-- Prefer parameterized procedures for all inputs. Avoid dynamic SQL unless absolutely required.
-- Use TRY/CATCH with explicit COMMIT/ROLLBACK for transactional procedures when necessary.
-- Keep procedure names task‑based and consistent: schema.sp_Action_Subject_By_Filter.
-
-## TVP (Table‑Valued Parameter) Guidance
-
-- Create SQL types with the minimal necessary columns and indexes where relevant.
-- Use `[GenerateTvp]` for zero-boilerplate TVP mapping. The generator uses `IEnumerable` internally for efficient streaming.
-- Ensure the .NET TVP columns (constructor parameters) match the SQL TVP type definition exactly.
-- Keep TVP payload sizes reasonable. Extremely large TVPs can increase CPU and memory usage on both ends.
-- Pass TVPs read‑only (as required by SQL Server) and consider batching if sets are very large.
-
-## Caching Strategy
-
-Choose the right cache per call via StoredProcedureParametersBuilder:
-
-- Frozen cache
- - In‑process, immutable, fastest.
- - Use only for static reference data that rarely/never changes (e.g., lookup tables).
- - No expiration, cleared when the process restarts.
-- In‑memory cache
- - In‑process with expiration. Good for hot paths where staleness is acceptable.
- - Always set a sensible expiration.
-- Redis cache
- - Distributed, optional. Use in multi‑instance deployments for shared caching.
- - Secure with TLS and auth. Set expirations aligned with your invalidation strategy.
-
-Cache key design:
-- Deterministic, short, and descriptive (e.g., users:age:>=30).
-- Include input parameters and stable identifiers.
-- Prefer lowercase and colon separators.
-
-Invalidation:
-- Prefer time‑based expiry for mutable data.
-- For Frozen cache, only cache immutable data to avoid invalidation complexity.
-
-## Performance Tips
-
-- Set ResultSetCapacity accurately to minimize list/array resizing.
-- Return only required columns. Avoid over‑fetching.
-- Avoid unnecessary allocations in hot paths; use ReadOnlyCollection/ImmutableArray variants where appropriate.
-- Benchmark critical flows. Keep an eye on memory pressure for Frozen and In‑Memory caches.
-- For very large multi‑result queries, use the MultiIEnumerable APIs to stream sets efficiently.
-- `SearchValues` SIMD validation: CaeriusNet uses `SearchValues` for parameter name validation — near-zero cost character scanning on modern CPUs.
-- Lock-free `FrozenCache` reads: The Frozen cache uses `FrozenDictionary` for zero-contention concurrent reads without locks.
-- `GC.AllocateUninitializedArray`: Large result arrays use uninitialized allocation to skip zero-fill overhead when the array will be fully populated.
-
-## Transaction Best Practices
-
-- Keep transactions as short as possible — hold locks only for the duration of the actual database work.
-- Always use `await using` to guarantee `DisposeAsync` runs on all code paths.
-- Pass `CancellationToken` to all transactional methods — this cancels in-flight SQL commands if the request is aborted.
-- Use the lowest isolation level that meets your consistency requirements (`ReadCommitted` is the default).
-- Avoid performing non-database I/O (HTTP calls, file writes) inside a transaction scope.
-- Retry the entire transaction at the caller level if a poison state occurs — do not attempt partial recovery.
+**Cache keys:**
+
+- Deterministic, short, descriptive (e.g. `users:age:>=30`).
+- Include all parameters that affect the result.
+- Lowercase, colon-separated.
+
+**Invalidation:**
+
+- Prefer time-based expiry for mutable data.
+- For Frozen, only cache truly immutable data — there is no invalidation hook.
+
+See [Caching](/documentation/cache) for the full guide.
+
+## Performance
+
+- **Set `resultSetCapacity` accurately.** It pre-allocates the `List` and avoids resize churn for large reads.
+- **Return only required columns.** Less data = fewer allocations and faster TDS framing.
+- **Pick the right collection.** `ImmutableArray` for frozen / shared data; `ReadOnlyCollection` for public APIs; `IEnumerable` for LINQ pipelines.
+- **Stream multi-result-sets** with the `QueryMultipleIEnumerableAsync` family rather than chaining separate calls.
+- **Benchmark critical flows.** CaeriusNet's own [BenchmarkDotNet suites](/benchmarks/) are reproducible (`Random(42)`); use them as a baseline.
+
+Internal performance levers worth knowing about:
+
+| Mechanism | What it buys you |
+|---|---|
+| `SearchValues` SIMD scans | Near-zero-cost parameter-name validation on modern CPUs |
+| `FrozenDictionary` for Frozen cache | Lock-free concurrent reads |
+| `GC.AllocateUninitializedArray` | Skips zero-fill on large result arrays that will be fully populated |
+| `CollectionsMarshal.SetCount` + `AsSpan` | Populates `List` in place without bounds checks per write |
+
+## Transactions
+
+- Use `await using` so `DisposeAsync` runs on every code path — including exceptions.
+- Pass `CancellationToken` everywhere; it cancels in-flight SQL commands.
+- **Keep transactions short.** They hold locks; long transactions block other readers and writers.
+- **Don't perform non-database I/O** (HTTP calls, file writes, log shipping) inside a transaction scope.
+- Use the **lowest isolation level** that meets your consistency requirements (`ReadCommitted` is the default).
+- **Retry the entire transaction** at the caller level if the scope poisons — never attempt partial recovery.
```csharp
-// ✅ Short, focused transaction with cancellation
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+// ✅ Short, focused, cancellable
+await using var tx = await dbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
-await tx.ExecuteNonQueryAsync(spDebit, cancellationToken);
-await tx.ExecuteNonQueryAsync(spCredit, cancellationToken);
-await tx.CommitAsync(cancellationToken);
+await tx.ExecuteNonQueryAsync(spDebit, ct);
+await tx.ExecuteNonQueryAsync(spCredit, ct);
+await tx.CommitAsync(ct);
```
-## Logging Best Practices
+## Logging
-- Configure `LoggerProvider.SetLogger(...)` early in application startup — before any database calls.
-- Filter by event ID range to reduce noise: suppress cache events (1xxx–3xxx) in production; keep command execution (5xxx) at `Information`.
-- Use structured logging sinks (Seq, Elasticsearch, OTLP) to leverage CaeriusNet's named parameters (`{ProcedureName}`, `{Duration}`, `{RowCount}`).
-- Set up alerts on event ID 5003 (slow execution threshold) and 5004 (command failure) for production monitoring.
+- Configure `ILoggerFactory` **before** `CaeriusNetBuilder.Build()` — DI then wires the logger automatically.
+- Filter by event-ID category to control verbosity per subsystem:
+ - `CaeriusNet.Cache` → `Warning` in production
+ - `CaeriusNet.Commands` → `Information` to keep timing visible
+- Use **structured sinks** (Seq, Elasticsearch, OTLP) to leverage CaeriusNet's named placeholders (`{ProcedureName}`, `{Duration}`, `{RowCount}`).
+- Set up alerts on event ID **5003** (slow execution) and **5004** (command failure) for production monitoring.
```csharp
builder.Services.AddLogging(logging =>
{
- logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); // quiet in prod
- logging.AddFilter("CaeriusNet.Commands", LogLevel.Information); // keep timing
+ logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning);
+ logging.AddFilter("CaeriusNet.Commands", LogLevel.Information);
});
```
-## Async, Cancellation, and Reliability
+See [Logging & Observability](/documentation/logging) for the complete event-ID reference.
-- All APIs are asynchronous by design. Don’t block on async calls.
-- Propagate CancellationToken from the request boundary to database calls.
-- Configure reasonable command/connection timeouts in connection strings or command options.
-- Open one connection per operation via ICaeriusNetDbContext.DbConnection(), and dispose it promptly (handled by library helpers).
+## Async, cancellation, reliability
-## Error Handling and Troubleshooting
+- All APIs are asynchronous by design — never block on async calls (`.Result`, `.Wait()`).
+- Propagate `CancellationToken` from the request boundary down to every database call.
+- Configure reasonable command and connection timeouts in your connection string.
+- The library opens, uses, and disposes connections automatically — do not manage `SqlConnection` instances manually.
-Common issues and resolutions:
+## Error handling
-- Connection issues (Aspire)
- - Ensure WithAspireSqlServer("name") matches your AppHost AddSqlServer/AddDatabase name.
- - Confirm the connection string is available at runtime (AppHost injection).
-- TVP type mismatch
- - Verify schema and type name (Schema.TvpName) match between SQL and .NET.
- - Ensure constructor parameters and SQL TVP columns align (order and types).
-- Mapping errors
- - InvalidCastException typically indicates an ordinal/type mismatch. Check DTO constructor order and SQL SELECT order.
-- Cache misses
- - Verify identical cache keys and parameters across calls. For Redis, check connectivity and configuration.
-- Memory growth
- - Frozen cache is immutable and monotonic. Use only for true constants.
- - Tune In‑Memory cache expiration and avoid caching very large payloads unnecessarily.
+| Issue | Likely cause | Fix |
+|---|---|---|
+| `InvalidCastException` at runtime | Reader method or DTO type doesn't match SQL column type | Align the `Get*` call (or DTO field type) with the actual SQL type |
+| `IndexOutOfRangeException` | DTO has more parameters than the SP returns columns | Re-check `SELECT` arity and DTO parameter count |
+| Aspire connection failure | `WithAspireSqlServer("name")` does not match AppHost name | Cross-check the resource name in the AppHost |
+| TVP type mismatch | Schema/TvpName diverge between SQL and .NET, or column order is wrong | Re-align `[GenerateTvp]` arguments and constructor params |
+| Cache miss when hit was expected | Different keys per call | Build keys deterministically from inputs |
+| Memory growth (Frozen) | Frozen used for non-static data | Switch to InMemory with TTL |
-## Security Considerations
+## Security
-- Use stored procedures with parameters to avoid SQL injection.
-- Do not cache sensitive data unless necessary and lawful. Prefer short expirations and encryption at rest/in‑transit.
-- Secure Redis with TLS and authentication; restrict network access to trusted environments.
-- Limit SQL permissions for the application user to the minimum required.
+- Use Stored Procedures with parameters — no string concatenation, no dynamic SQL.
+- **Never cache sensitive data** (passwords, tokens, raw PII) without explicit threat-modelling.
+- Secure Redis with TLS and authentication; restrict network access.
+- Grant the application user the **minimum required SQL permissions** (`EXECUTE` on the SP schema, no `dbo`).
-## Versioning and Migrations
+## Versioning & migrations
-- Treat stored procedures and DTOs as a contract. Version them when breaking changes are needed.
-- Add new procedures and DTOs alongside existing ones during migrations, then deprecate old versions.
+- Treat Stored Procedures and DTOs as a contract — version them when breaking changes are needed (e.g. `sp_GetUsers` → `sp_GetUsers_v2`).
+- Add new procedures alongside existing ones; deprecate old ones once consumers migrate.
-## Examples
+## Quick reference
-- Read with caching (In‑memory):
```csharp
+// Read with caching
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
.AddInMemoryCache("users:all", TimeSpan.FromMinutes(2))
.Build();
-var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken);
+
+var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct);
```
-- Write with affected rows:
```csharp
+// Update with affected rows
var sp = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserAge_By_Guid")
.AddParameter("Guid", guid, SqlDbType.UniqueIdentifier)
- .AddParameter("Age", age, SqlDbType.TinyInt)
+ .AddParameter("Age", age, SqlDbType.TinyInt)
.Build();
-int rows = await dbContext.ExecuteNonQueryAsync(sp, cancellationToken);
+
+var rows = await dbContext.ExecuteNonQueryAsync(sp, ct);
```
-- TVP example:
```csharp
-var tvpItems = new List { new(1), new(2), new(3) };
-var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 256)
- .AddTvpParameter("Ids", tvpItems)
+// TVP read
+var tvp = userIds.Select(id => new UserIdTvp(id));
+var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 256)
+ .AddTvpParameter("Ids", tvp)
.Build();
-var users = await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken);
+
+var users = await dbContext.QueryAsIEnumerableAsync(sp, ct);
```
---
-Use this page as a checklist when designing new queries or optimizing existing ones. For detailed APIs and examples, see [Reading Data](/documentation/reading-data), [Writing Data](/documentation/writing-data), [Caching](/documentation/cache), and [API Reference](/documentation/api).
\ No newline at end of file
+Use this page as a checklist when designing new queries or auditing existing ones. For deeper APIs and patterns, see [API Reference](/documentation/api), [Advanced Usage](/documentation/advanced-usage), and the [Examples](/examples/).
diff --git a/Documentations/docs/documentation/cache.md b/Documentations/docs/documentation/cache.md
index b11c14d..b997d0a 100644
--- a/Documentations/docs/documentation/cache.md
+++ b/Documentations/docs/documentation/cache.md
@@ -1,53 +1,53 @@
# Caching
-CaeriusNet supports three caching strategies to reduce database load and improve latency. Caching is opt-in per call via `StoredProcedureParametersBuilder`. When a cache hit occurs, CaeriusNet returns the cached result without executing the Stored Procedure.
+CaeriusNet supports three caching strategies to reduce database load and improve latency. Caching is **opt-in per call** via `StoredProcedureParametersBuilder` — you choose the tier exactly where you need it. On a cache hit, CaeriusNet returns the cached result without executing the Stored Procedure and **without creating a DB span**.
## Strategy comparison
-| Strategy | Scope | Expiration | Requires setup | Best for |
+| Strategy | Scope | Expiration | DI prerequisite | Best for |
|---|---|---|---|---|
-| **Frozen** | In-process | None — lives until process restart | None | Static reference data (e.g., lookup tables, enums) |
-| **InMemory** | In-process | Required (`TimeSpan`) | None | Frequently read data with acceptable staleness |
-| **Redis** | Distributed | Optional | `WithRedis` / `WithAspireRedis` | Multi-instance deployments, shared cache |
+| **Frozen** | In-process | None — lives until process restart | None | Static reference data (lookup tables, enums, country codes) |
+| **InMemory** | In-process | Required (`TimeSpan`) | None | Frequently-read data with acceptable staleness |
+| **Redis** | Distributed | Optional (`TimeSpan?`) | `WithRedis` / `WithAspireRedis` | Multi-instance deployments, shared cache, surviving restarts |
## Frozen cache
-Immutable, in-process cache. Entries persist until the process restarts. No expiration configuration.
+Immutable, in-process cache backed by `FrozenDictionary` for lock-free reads. Entries persist until the process restarts; there is no expiration.
```csharp
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
- .AddFrozenCache("all_users_frozen")
+ .AddFrozenCache("users:all:frozen")
.Build();
-var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken);
+var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct);
```
::: tip When to use Frozen
-Use Frozen for data that truly never changes during the application lifetime: country lists, currency codes, permission definitions, static lookup tables.
+Frozen is for data that **truly never changes** for the lifetime of the process: country lists, currency codes, permission definitions, static lookup tables. If the data needs an invalidation strategy, prefer InMemory or Redis.
:::
## In-memory cache
-In-process cache with a mandatory expiration `TimeSpan`. Entries are automatically evicted after the specified duration.
+In-process cache with a mandatory `TimeSpan` expiration. Entries are evicted automatically when their TTL elapses.
```csharp
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
- .AddInMemoryCache("all_users_memory", TimeSpan.FromMinutes(1))
+ .AddInMemoryCache("users:all:memory", TimeSpan.FromMinutes(1))
.Build();
-var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken);
+var users = await dbContext.QueryAsReadOnlyCollectionAsync(sp, ct);
```
::: tip When to use InMemory
-Use InMemory for data that is read frequently but can tolerate slight staleness: user profiles, product catalogues, configuration records that change infrequently.
+InMemory is for data read frequently within a single process where some staleness is acceptable: user profiles, product catalogues, configuration records that change infrequently.
:::
## Redis cache (distributed)
-Distributed cache backed by Redis. Expiration is optional — if omitted, the entry persists until Redis evicts it. Requires `WithRedis` or `WithAspireRedis` in the builder.
+Distributed cache backed by Redis through `Microsoft.Extensions.Caching.StackExchangeRedis`. Expiration is **optional** — when omitted, the entry persists until Redis evicts it under memory pressure.
```csharp
-// Required: configure Redis in your DI setup
+// Required: configure Redis once in your DI setup
CaeriusNetBuilder
.Create(services)
.WithSqlServer(connectionString)
@@ -56,59 +56,82 @@ CaeriusNetBuilder
```
```csharp
-// Per-call: with expiration
+// Per-call: with explicit expiration
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
- .AddRedisCache("all_users_redis", TimeSpan.FromMinutes(2))
+ .AddRedisCache("users:all:redis", TimeSpan.FromMinutes(2))
.Build();
-// Per-call: no expiration (entry persists until Redis eviction)
+// Per-call: no expiration (entry persists until Redis evicts it)
var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
- .AddRedisCache("all_users_redis")
+ .AddRedisCache("users:all:redis")
.Build();
```
::: tip When to use Redis
-Use Redis when multiple instances of your service share the same cache, or when you need cache to survive application restarts. Combine with Aspire for automatic connection management.
+Redis is for **multi-instance** deployments where the cache must be shared across replicas, or where it must survive application restarts. Combine with Aspire (`WithAspireRedis`) for automatic connection-string resolution from the AppHost.
:::
## Cache key design
-- **Deterministic**: keys must be stable across calls with the same logical input
-- **Scoped**: include parameters that affect the result
-- **Compact**: avoid very long keys
-- **Readable**: use lowercase with `:` separators
+Good cache keys are **deterministic**, **scoped to the input**, and **readable**:
```csharp
-// Good examples
+// ✅ Good
"users:all"
$"users:age:{age}"
$"orders:user:{userId}:page:{page}"
-// Avoid
-$"result_{DateTime.UtcNow}" // non-deterministic
-"very_long_key_with_lots_of_data_that_is_hard_to_read"
+// ❌ Avoid
+$"result_{DateTime.UtcNow}" // non-deterministic — never hits
+"all_user_data_cached_safely" // not scoped to inputs — collisions
```
+Recommended conventions:
+
+- Lowercase, colon-separated segments
+- Most-stable prefix first (`entity`), then identifier, then variant
+- Include all parameters that affect the result — otherwise the cache returns the wrong rows
+
## Cache invalidation
-CaeriusNet does not provide explicit cache invalidation APIs — caches are TTL-based. For Frozen cache, restart the process or deploy a new version. For InMemory, tune the `TimeSpan`. For Redis, use `IDatabase.KeyDelete` directly if needed between deployments.
+CaeriusNet does not provide explicit invalidation APIs — caches are TTL-based by design. Strategies:
+
+- **Frozen** — restart the process or deploy a new version
+- **InMemory** — pick a TTL that matches your acceptable staleness
+- **Redis** — call `IDatabase.KeyDelete` directly (e.g., on a deployment hook) or rely on TTL
+
+## Cache and transactions
+
+**Caches are bypassed inside `ICaeriusNetTransaction` scopes.** This prevents uncommitted (dirty) reads from being published to the cache and served to other consumers. After `CommitAsync`, the next non-transactional call populates the cache normally.
+
+See [Transactions — Cache bypass](/documentation/transactions#cache-bypass).
+
+## Telemetry
+
+Every cache lookup is recorded — both hits and misses — through the `caerius.cache.lookups` counter:
+
+| Tag | Value |
+|---|---|
+| `caerius.cache.tier` | `Frozen`, `InMemory`, or `Redis` |
+| `caerius.cache.hit` | `true` on hit, `false` on miss |
+
+On a hit, **no DB span is created** — the trace accurately reflects that the database was not contacted.
## Security considerations
-- Do not cache data that mixes users without a user-scoped key (e.g., `$"users:profile:{userId}"`)
-- For Redis in production: enable TLS, use authentication, and restrict network access
-- Avoid caching sensitive data (passwords, tokens, PII) unless the risk is acceptable and the cache is properly secured
+- **Per-user data** must include the user identifier in the key (`$"profile:{userId}"`).
+- **Production Redis** should enable TLS, require authentication, and restrict network access to trusted environments.
+- **Sensitive data** (passwords, tokens, PII) should not be cached unless the risk is acceptable and the cache is properly secured. Prefer short TTLs.
## Troubleshooting
-| Issue | Likely cause | Fix |
+| Symptom | Likely cause | Fix |
|---|---|---|
-| Cache miss when hit expected | Different key on each call | Use a deterministic key construction |
-| Stale data returned | Expiration too long | Lower the `TimeSpan` or use a scoped key |
-| Redis not used | Not configured in builder | Add `.WithRedis(...)` or `.WithAspireRedis(...)` |
-| Memory growth | Frozen cache overused | Reserve Frozen for truly static data |
+| Cache miss when a hit was expected | Different key on each call | Construct keys deterministically from inputs |
+| Stale data returned | TTL too long | Lower the `TimeSpan` or include a version segment |
+| `WithRedis` not used | Redis not configured | Add `.WithRedis(...)` or `.WithAspireRedis(...)` to the builder |
+| Memory growth | Frozen cache misused | Reserve Frozen for truly static data; prefer InMemory with TTL otherwise |
---
-**Next:** [Aspire Integration](/documentation/aspire) — configure SQL Server and Redis via .NET Aspire.
-
+**Next:** [Transactions](/documentation/transactions) — atomic multi-statement units of work with parent `TX` spans.
diff --git a/Documentations/docs/documentation/diagnostics.md b/Documentations/docs/documentation/diagnostics.md
index ef88a27..ab4ccc9 100644
--- a/Documentations/docs/documentation/diagnostics.md
+++ b/Documentations/docs/documentation/diagnostics.md
@@ -1,27 +1,27 @@
# Compiler Diagnostics
-CaeriusNet's Roslyn analyzer emits compile-time diagnostics for `[GenerateDto]` and `[GenerateTvp]` usage so configuration and mapping problems show up before runtime.
+CaeriusNet's Roslyn analyzer emits **compile-time diagnostics** for `[GenerateDto]` and `[GenerateTvp]` so contract problems show up in your IDE — never at runtime. The analyzer ships **inside the `CaeriusNet` NuGet package**; no extra reference is required.
## Diagnostic reference
-| ID | Severity | Title | Description |
+| ID | Severity | Title | Triggered when |
|---|---|---|---|
| [CAERIUS001](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS001.md) | Error | Type must be `sealed` | A `[GenerateDto]` or `[GenerateTvp]` type is missing the `sealed` modifier |
| [CAERIUS002](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS002.md) | Error | Type must be `partial` | A `[GenerateDto]` or `[GenerateTvp]` type is missing the `partial` modifier |
-| [CAERIUS003](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS003.md) | Error | Primary constructor required | The type does not use a primary constructor for its parameters |
-| [CAERIUS004](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS004.md) | Error | `[GenerateTvp]` requires a non-empty `TvpName` | The attribute explicitly sets `TvpName` to an empty or whitespace string |
-| [CAERIUS005](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS005.md) | Warning | Unmapped CLR type falls back to `sql_variant` | A constructor parameter has no native SQL Server mapping |
+| [CAERIUS003](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS003.md) | Error | Primary constructor required | The type does not declare a primary constructor (or the constructor has no parameters) |
+| [CAERIUS004](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS004.md) | Error | `[GenerateTvp]` requires a non-empty `TvpName` | The attribute sets `TvpName` to an empty or whitespace-only string |
+| [CAERIUS005](https://github.com/CaeriusNET/CaeriusNet/blob/main/Documentations/diagnostics/CAERIUS005.md) | Warning | Unmapped CLR type falls back to `sql_variant` | A constructor parameter has a type with no native SQL Server mapping |
## Severity levels
| Severity | Build impact | Action required |
|---|---|---|
-| **Error** | Fails compilation | Must fix before building |
-| **Warning** | Compilation succeeds | Review and fix or suppress intentionally |
+| **Error** | Fails compilation | Must be fixed before the project builds |
+| **Warning** | Compilation succeeds | Review and fix, or suppress intentionally |
-## How to suppress diagnostics
+## Suppressing diagnostics
-### Per-declaration with `#pragma`
+### Per declaration with `#pragma`
```csharp
#pragma warning disable CAERIUS005
@@ -34,11 +34,11 @@ public sealed partial record FlexibleDto(int Id, object DynamicValue);
```ini
[*.cs]
-# Suppress specific diagnostic
+# Suppress entirely
dotnet_diagnostic.CAERIUS005.severity = none
-# Downgrade to suggestion
-dotnet_diagnostic.CAERIUS005.severity = suggestion
+# Or downgrade to a suggestion
+# dotnet_diagnostic.CAERIUS005.severity = suggestion
```
### Via `` in `.csproj`
@@ -49,7 +49,7 @@ dotnet_diagnostic.CAERIUS005.severity = suggestion
```
-## How to escalate severity
+## Escalating severity
Promote warnings to errors to enforce stricter rules in CI:
@@ -70,17 +70,17 @@ dotnet_diagnostic.CAERIUS005.severity = error
```
::: tip CI enforcement
-Escalating CAERIUS005 to an error in your CI `.editorconfig` prevents accidental `sql_variant` usage from reaching production.
+Escalating `CAERIUS005` to an error in your CI `.editorconfig` prevents an accidental `sql_variant` fallback from reaching production.
:::
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
-| No diagnostics emitted | Analyzer not referenced | Ensure the `CaeriusNet.Analyzer` project or packaged analyzer is referenced as an analyzer |
-| Diagnostics not appearing in IDE | IDE cache stale | Restart IDE or run `dotnet build` from CLI |
-| Suppression not working | Wrong scope | Ensure `#pragma` wraps the type declaration, not just the file |
+| No diagnostics emitted | Analyzer not loaded | Ensure the `CaeriusNet` package is referenced (the analyzer ships in the same package) |
+| Diagnostics not visible in IDE | IDE cache stale | Restart the IDE or run `dotnet build` from the CLI |
+| `#pragma` suppression has no effect | Wrong scope | Wrap the **type declaration**, not the file or a member |
---
-**See also:** [Source Generators](/documentation/source-generators) — full guide to `[GenerateDto]` and `[GenerateTvp]` code generation.
+**See also:** [Source Generators](/documentation/source-generators) — the full guide to `[GenerateDto]` and `[GenerateTvp]`.
diff --git a/Documentations/docs/documentation/dto-mapping.md b/Documentations/docs/documentation/dto-mapping.md
index 1368b57..3cc7b64 100644
--- a/Documentations/docs/documentation/dto-mapping.md
+++ b/Documentations/docs/documentation/dto-mapping.md
@@ -1,16 +1,20 @@
# DTO Mapping
-CaeriusNet maps SQL Server result sets to C# DTOs at **compile time** — no reflection, no dynamic expression trees, no surprises at runtime.
+CaeriusNet maps SQL Server result sets to C# DTOs at **compile time** — no reflection, no expression-tree compilation, no runtime metadata lookups. This page explains how the contract works, what it requires from your DTOs, and the few special cases worth knowing.
## How it works
-Every DTO must implement `ISpMapper`, a static-interface contract that provides a single `MapFromDataReader` method. Reads are **ordinal-based**: columns are accessed by index rather than by name, which eliminates per-row string lookups and matches the TDS wire protocol directly.
+Every DTO implements `ISpMapper`, a static-interface contract with a single method:
```csharp
public static abstract T MapFromDataReader(SqlDataReader reader);
```
-## The `ISpMapper` interface
+Reads are **ordinal-based**: each column is accessed by its zero-based index rather than by name. This eliminates per-row string lookups and matches the TDS wire protocol directly — the column order in your `SELECT` statement is the contract between SQL and C#.
+
+You can implement `ISpMapper` manually, or — preferably — let the source generator emit it for you with `[GenerateDto]`. Both produce the same machine code; the generator simply removes the boilerplate.
+
+## A manual `ISpMapper`
```csharp
using CaeriusNet.Mappers;
@@ -27,21 +31,19 @@ public sealed record UserDto(int Id, string Username, byte Age)
}
```
-Column indices **must match** the `SELECT` column order in your Stored Procedure. Declare your DTO properties in the same order as the SP result columns.
+The constructor parameter order **must** match the `SELECT` column order in your Stored Procedure.
-## Column order contract
+## Column order is the contract
-The column at position `0` in the result set is read by ordinal `0`. Your Stored Procedure defines the contract:
+The column at position `0` in the result set is read at ordinal `0`. Your Stored Procedure defines the contract:
```sql
-- Columns: Id (0), Username (1), Age (2)
SELECT Id, Username, Age
-FROM dbo.Users
-WHERE Age >= @Age
+FROM dbo.Users
+WHERE Age >= @Age;
```
-The matching DTO:
-
```csharp
public sealed record UserDto(int Id, string Username, byte Age)
: ISpMapper
@@ -57,7 +59,7 @@ If the SP `SELECT` order changes, update the ordinal indices in `MapFromDataRead
## Nullable columns
-Use `reader.IsDBNull(ordinal)` before reading nullable columns. For nullable reference types (`string?`) and nullable value types (`int?`):
+Use `reader.IsDBNull(ordinal)` before reading nullable columns. The pattern applies equally to nullable reference types (`string?`) and nullable value types (`int?`):
```csharp
public sealed record ItemDto(int Id, string? Description, int? Quantity)
@@ -71,18 +73,21 @@ public sealed record ItemDto(int Id, string? Description, int? Quantity)
}
```
+The source generator emits these guards automatically when the constructor parameter is declared nullable.
+
## Special type conversions
-Some C# types require explicit conversion from their SQL Server equivalents:
+A handful of C# types do not have a direct `Get*` method on `SqlDataReader` and require an explicit conversion:
-| C# type | SQL type | Conversion |
+| C# type | SQL type | Conversion expression |
|---|---|---|
| `DateOnly` | `date` / `datetime2` | `DateOnly.FromDateTime(reader.GetDateTime(n))` |
| `TimeOnly` | `time` | `TimeOnly.FromDateTime(reader.GetDateTime(n))` |
-| `byte[]` | `varbinary` | `(byte[])reader.GetValue(n)` |
+| `Half` | `real` | `(Half)reader.GetFloat(n)` |
+| `byte[]` | `varbinary` / `image` | `reader.GetFieldValue(n)` |
| `ushort` | `int` | `(ushort)reader.GetInt32(n)` |
-Example with `DateOnly` and `byte[]`:
+Example combining `DateOnly` and `byte[]`:
```csharp
public sealed record DocumentDto(int Id, DateOnly CreatedDate, byte[] Content)
@@ -92,16 +97,18 @@ public sealed record DocumentDto(int Id, DateOnly CreatedDate, byte[] Content)
=> new(
reader.GetInt32(0),
DateOnly.FromDateTime(reader.GetDateTime(1)),
- (byte[])reader.GetValue(2));
+ reader.GetFieldValue(2));
}
```
+The source generator handles all of the above automatically.
+
## Enum mapping
-Enums map to their underlying integer type in SQL. Cast the reader result:
+Enums map to their underlying integer type in SQL. Cast the reader result accordingly:
```csharp
-public enum UserStatus : byte { Active = 1, Inactive = 0 }
+public enum UserStatus : byte { Inactive = 0, Active = 1 }
public sealed record UserDto(int Id, UserStatus Status)
: ISpMapper
@@ -113,7 +120,7 @@ public sealed record UserDto(int Id, UserStatus Status)
## Source generation (recommended)
-Writing `MapFromDataReader` manually is straightforward but repetitive. Use `[GenerateDto]` to have the compiler emit it for you. See the [Source Generators](/documentation/source-generators) page for full details.
+Writing `MapFromDataReader` manually is straightforward but repetitive. Annotate your DTO with `[GenerateDto]` and the generator emits the implementation at build time:
```csharp
using CaeriusNet.Attributes.Dto;
@@ -122,14 +129,16 @@ using CaeriusNet.Attributes.Dto;
public sealed partial record UserDto(int Id, string Username, byte Age);
```
+The DTO must be `sealed`, `partial`, and use a primary constructor — the [Roslyn analyzer](/documentation/diagnostics) reports a build error if any of these are missing. See the [Source Generators](/documentation/source-generators) page for the full list of features.
+
## Common pitfalls
-| Issue | Fix |
-|---|---|
-| `InvalidCastException` at runtime | Reader method doesn't match the SQL column type |
-| `IndexOutOfRangeException` | Ordinal index doesn't correspond to actual column count |
-| Null reference exception | Nullable column not guarded with `IsDBNull` |
-| Wrong field values | SP column order doesn't match the DTO constructor order |
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| `InvalidCastException` at runtime | Reader method does not match the SQL column type | Align the `Get*` call (or DTO field type) with the actual SQL type |
+| `IndexOutOfRangeException` | Ordinal index does not correspond to an actual column | Re-check the SP `SELECT` arity |
+| `NullReferenceException` on a column | Nullable column not guarded with `IsDBNull` | Make the field nullable or add the guard |
+| Wrong values for several fields | SP column order does not match constructor parameter order | Re-align the SELECT with the constructor |
---
diff --git a/Documentations/docs/documentation/logging.md b/Documentations/docs/documentation/logging.md
index 19de4c4..20ca2b4 100644
--- a/Documentations/docs/documentation/logging.md
+++ b/Documentations/docs/documentation/logging.md
@@ -1,6 +1,8 @@
-# Logging & Observability
+# Logging & Observability
-CaeriusNet uses `[LoggerMessage]` source-generated logging for zero-allocation structured log output. Every database operation emits timing, procedure name, and result metadata — enabling diagnostics, performance monitoring, and alerting without custom instrumentation.
+CaeriusNet uses **source-generated** `[LoggerMessage]` logging for zero-allocation structured output. Every database operation emits timing, procedure name, and result metadata — enabling diagnostics, performance monitoring, and alerting without custom instrumentation.
+
+For OpenTelemetry tracing and metrics, see [Aspire Integration — Tracing & Telemetry](/documentation/aspire#tracing-telemetry).
## Overview
@@ -8,29 +10,30 @@ CaeriusNet uses `[LoggerMessage]` source-generated logging for zero-allocation s
|---|---|
| **Zero-allocation** | `[LoggerMessage]` source generators — no string interpolation at runtime |
| **Structured parameters** | Named placeholders (`{ProcedureName}`, `{Duration}`, `{RowCount}`) |
-| **Execution timing** | `Stopwatch.GetElapsedTime` for high-resolution elapsed measurement |
-| **Event ID convention** | Categorized by subsystem (see table below) |
+| **Execution timing** | `Stopwatch.GetElapsedTime` for high-resolution measurement |
+| **Event-ID convention** | Categorized by subsystem (see table below) |
| **Provider-agnostic** | Works with any `ILogger` implementation |
## Configuration
### Setting the logger
-CaeriusNet uses a static `LoggerProvider` to obtain the logger instance. Configure it during application startup:
+CaeriusNet uses a static `LoggerProvider` to obtain its logger instance. Configure it once during application startup:
```csharp
using CaeriusNet.Logging;
var builder = WebApplication.CreateBuilder(args);
-// After building the service provider, set the logger
+// ... DI registration ...
+
var app = builder.Build();
LoggerProvider.SetLogger(app.Services.GetRequiredService());
```
-### Integration with dependency injection
+### Integration with DI
-When using `CaeriusNetBuilder`, the logger is configured automatically if `ILoggerFactory` is registered in the DI container:
+When using `CaeriusNetBuilder`, the logger is wired automatically as long as `ILoggerFactory` is registered in the DI container — `LoggerProvider.SetLogger` is called for you during `Build()`.
```csharp
var builder = WebApplication.CreateBuilder(args);
@@ -47,36 +50,32 @@ CaeriusNetBuilder
.Build();
```
-### Filtering by event ID range
+### Filtering by category
-Use `ILoggerFactory` configuration to filter CaeriusNet logs by event ID ranges:
+Use `ILoggerFactory` configuration to filter CaeriusNet logs by subsystem:
```csharp
builder.Services.AddLogging(logging =>
{
- logging.AddFilter("CaeriusNet", LogLevel.Information);
-
- // Show only command execution events (5xxx)
- logging.AddFilter("CaeriusNet.Commands", LogLevel.Debug);
-
- // Suppress cache hit/miss noise in production
- logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning);
+ logging.AddFilter("CaeriusNet", LogLevel.Information);
+ logging.AddFilter("CaeriusNet.Commands", LogLevel.Debug); // verbose for command execution
+ logging.AddFilter("CaeriusNet.Cache", LogLevel.Warning); // suppress per-call cache noise
});
```
-## Event ID categories
+## Event-ID categories
CaeriusNet organizes event IDs by subsystem. Use these ranges to filter, route, or alert on specific categories:
| Range | Category | Description |
|---|---|---|
-| **1000–1999** | In-Memory Cache | Cache hit, miss, set, eviction |
-| **2000–2999** | Frozen Cache | Cache hit, miss, freeze operations |
-| **3000–3999** | Redis Cache | Redis get, set, connection events |
-| **4000–4999** | Database / Connection | Connection open, close, pool events |
-| **5000–5999** | Command Execution | Start, complete, duration, row count |
+| **1000–1999** | In-Memory cache | Hit, miss, set, eviction |
+| **2000–2999** | Frozen cache | Hit, miss, freeze operations |
+| **3000–3999** | Redis cache | Get, set, connection events |
+| **4000–4999** | Database / connection | Connection open, close, pool events |
+| **5000–5999** | Command execution | Start, complete, duration, row count |
-### Detailed event reference
+### Event reference
| Event ID | Level | Message template |
|---|---|---|
@@ -100,20 +99,20 @@ CaeriusNet organizes event IDs by subsystem. Use these ranges to filter, route,
## Structured parameters
-CaeriusNet logs use semantic (structured) parameters. These are preserved as key-value pairs by structured logging sinks:
+CaeriusNet log messages use semantic (structured) placeholders. Structured logging sinks preserve them as queryable key/value pairs:
-| Parameter | Type | Description |
+| Placeholder | Type | Description |
|---|---|---|
-| `{ProcedureName}` | `string` | Fully qualified stored procedure name |
+| `{ProcedureName}` | `string` | Fully qualified Stored Procedure name (`schema.name`) |
| `{Duration}` | `TimeSpan` | Elapsed execution time |
| `{RowCount}` | `int` | Number of rows returned or affected |
-| `{CacheKey}` | `string` | Cache key used for lookup/store |
+| `{CacheKey}` | `string` | Cache key used for lookup or store |
| `{Expiration}` | `TimeSpan` | Cache entry TTL |
| `{ErrorMessage}` | `string` | Exception message on failure |
| `{IsolationLevel}` | `string` | Transaction isolation level |
-::: tip Structured logging benefits
-Structured parameters enable powerful queries: "Show all executions of `sp_GetUsers` that took longer than 500ms" or "Count cache misses per key in the last hour."
+::: tip Why this matters
+Structured parameters unlock powerful queries — *"all executions of `sp_GetUsers` slower than 500 ms"*, *"cache misses per key in the last hour"*, *"failure rate by procedure name"* — without parsing log text.
:::
## Integration examples
@@ -148,7 +147,7 @@ Filter CaeriusNet events in `appsettings.json`:
"MinimumLevel": {
"Default": "Information",
"Override": {
- "CaeriusNet.Cache": "Warning",
+ "CaeriusNet.Cache": "Warning",
"CaeriusNet.Commands": "Debug"
}
}
@@ -162,10 +161,7 @@ Export CaeriusNet logs to an OTLP-compatible backend:
```csharp
builder.Services.AddOpenTelemetry()
- .WithLogging(logging =>
- {
- logging.AddOtlpExporter();
- });
+ .WithLogging(logging => logging.AddOtlpExporter());
builder.Services.AddLogging(logging =>
{
@@ -189,37 +185,28 @@ builder.Services.AddLogging(logging =>
});
```
-::: details Custom telemetry from CaeriusNet events
-Use `ILogger` event subscriptions or middleware to convert CaeriusNet log events into custom Application Insights metrics:
-
-```csharp
-// Example: track execution duration as a custom metric
-services.AddSingleton();
-```
-:::
-
## Performance considerations
| Aspect | Guidance |
|---|---|
-| **Log level filtering** | Set `CaeriusNet.Cache` to `Warning` in production to suppress per-request cache hit/miss noise |
-| **High-throughput paths** | `Debug` level events are compiled out when the level is not enabled (source-generator check) |
-| **Structured sinks** | Prefer Seq, Elasticsearch, or OTLP over flat-file for queryability |
+| **Log level filtering** | Set `CaeriusNet.Cache` to `Warning` in production to suppress per-call cache noise |
+| **Hot paths** | `Debug`-level callsites are compiled out when the level is disabled (the source-generator emits an `IsEnabled` check) |
+| **Structured sinks** | Prefer Seq, Elasticsearch, or OTLP over flat-file sinks for queryability |
| **Sampling** | For very high RPS, configure sampling in your telemetry pipeline |
::: warning Avoid excessive logging in hot paths
-`Debug`-level cache events fire on every database call. In production, ensure your minimum level is `Information` or higher for CaeriusNet categories to avoid log volume overwhelming sinks.
+`Debug`-level cache events fire on every call. In production, ensure your minimum level is `Information` (or higher) for `CaeriusNet.Cache` to avoid log volume drowning your sinks.
:::
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
-| No CaeriusNet logs appear | Logger not configured | Call `LoggerProvider.SetLogger(...)` at startup |
-| Missing structured parameters | Using flat-text sink | Switch to a structured sink (Seq, OTLP, JSON console) |
-| High log volume | Debug level in production | Raise minimum level to Information |
-| Missing timing data | Logs filtered too aggressively | Ensure event ID 5002 (command complete) is not filtered |
+| No CaeriusNet logs appear | Logger not configured | Ensure `ILoggerFactory` is registered before `CaeriusNetBuilder.Build()` |
+| Missing structured parameters | Flat-text sink in use | Switch to a structured sink (Seq, OTLP, JSON console) |
+| High log volume | Debug level enabled in production | Raise the minimum level for `CaeriusNet.Cache` to `Warning` |
+| Missing timing data | Event ID 5002 filtered out | Re-enable `CaeriusNet.Commands` at `Information` |
---
-**Next:** [Best Practices](/documentation/best-practices) — recommendations for production-ready CaeriusNet usage.
+**Next:** [Aspire Integration](/documentation/aspire) — connect CaeriusNet to the Aspire dashboard via OpenTelemetry.
diff --git a/Documentations/docs/documentation/multi-results.md b/Documentations/docs/documentation/multi-results.md
index b08402b..7d7072e 100644
--- a/Documentations/docs/documentation/multi-results.md
+++ b/Documentations/docs/documentation/multi-results.md
@@ -1,16 +1,16 @@
# Multiple Result Sets
-SQL Server Stored Procedures can return more than one `SELECT` statement result. CaeriusNet exposes dedicated helpers that read up to five typed result sets from a single command execution — one round-trip, zero intermediate lists.
+A SQL Server Stored Procedure can return more than one `SELECT` result. CaeriusNet exposes dedicated helpers that read up to **five typed result sets** from a single command execution — one round-trip, one connection, one telemetry span.
## When to use
-Use multiple result sets when you need to fetch logically related data simultaneously:
+Multiple result sets are the right tool whenever you need logically related data fetched together:
-- A dashboard query returning users + orders + summary totals
-- A paginated response returning a data page + a total count
-- A reporting query combining header + line items in one call
+- A **dashboard** query returning users + orders + summary totals
+- A **paginated** response returning a data page + a total count
+- A **report** combining header + line items in one call
-Compared to multiple separate stored procedure calls, a single multi-result SP reduces round-trips, connection pool pressure, and total latency.
+Compared to multiple separate SP calls, a single multi-result SP reduces round-trips, connection-pool pressure, and total latency — and it nests under a single span instead of fragmenting your trace.
## SQL Server setup
@@ -22,98 +22,113 @@ BEGIN
-- Result set 1: recent users
SELECT Id, Username, Age
- FROM dbo.Users
+ FROM dbo.Users
ORDER BY Id DESC;
-- Result set 2: recent orders
SELECT OrderId, UserId, Total
- FROM dbo.Orders
+ FROM dbo.Orders
ORDER BY OrderId DESC;
END
+GO
```
## Available overloads
-CaeriusNet provides three collection-type families, each with overloads for 2 to 5 result sets:
+CaeriusNet offers three collection-type families, each with overloads for **2 to 5** result sets. Pick the family that matches your storage, mutation, and allocation needs:
### `QueryMultipleIEnumerableAsync`
-Returns a tuple of `IEnumerable` values:
+Returns a tuple of `IEnumerable`:
```csharp
Task<(IEnumerable, IEnumerable)>
- QueryMultipleIEnumerableAsync(context, sp, ct)
+ QueryMultipleIEnumerableAsync(/* ... */);
Task<(IEnumerable, IEnumerable, IEnumerable)>
- QueryMultipleIEnumerableAsync(context, sp, ct)
+ QueryMultipleIEnumerableAsync(/* ... */);
// ... up to T5
```
### `QueryMultipleReadOnlyCollectionAsync`
-Returns a tuple of `ReadOnlyCollection` values:
+Returns a tuple of `ReadOnlyCollection`:
```csharp
Task<(ReadOnlyCollection, ReadOnlyCollection)>
- QueryMultipleReadOnlyCollectionAsync(context, sp, ct)
+ QueryMultipleReadOnlyCollectionAsync(/* ... */);
```
### `QueryMultipleImmutableArrayAsync`
-Returns a tuple of `ImmutableArray` values:
+Returns a tuple of `ImmutableArray`:
```csharp
Task<(ImmutableArray, ImmutableArray)>
- QueryMultipleImmutableArrayAsync(context, sp, ct)
+ QueryMultipleImmutableArrayAsync(/* ... */);
```
-## Example: two result sets
+## Example — two result sets
```csharp
-public sealed record UserRepository(ICaeriusNetDbContext DbContext)
- : IUserRepository
+public sealed record DashboardRepository(ICaeriusNetDbContext DbContext)
+ : IDashboardRepository
{
- public async Task<(IEnumerable, IEnumerable)> GetDashboardAsync(
- CancellationToken cancellationToken)
+ public async Task<(IEnumerable Users, IEnumerable Orders)>
+ GetDashboardAsync(CancellationToken ct)
{
var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Dashboard_Data", 128)
.Build();
- return await DbContext.QueryMultipleIEnumerableAsync(
- sp, cancellationToken);
+ return await DbContext.QueryMultipleIEnumerableAsync(sp, ct);
}
}
```
-Destructure the tuple at the call site:
+Destructure at the call site:
```csharp
-var (users, orders) = await repository.GetDashboardAsync(cancellationToken);
+var (users, orders) = await repository.GetDashboardAsync(ct);
```
-## Example: three result sets
+## Example — three result sets
```csharp
var sp = new StoredProcedureParametersBuilder("dbo", "sp_Get_Report_Data", 500)
.AddParameter("Month", month, SqlDbType.TinyInt)
.Build();
-var (users, orders, products) = await dbContext
- .QueryMultipleIEnumerableAsync(sp, cancellationToken);
+var (users, orders, products) = await DbContext
+ .QueryMultipleIEnumerableAsync(sp, ct);
```
-## Result set order
+## Result-set order is the contract
-The type parameters `T1`, `T2`, ... must match the **order** in which the SP returns its `SELECT` statements. The first type maps to the first result set, the second to the second, and so on.
+The type parameters `T1`, `T2`, … must match the **order** in which the SP returns its `SELECT` statements. The first type maps to the first result set, the second to the second, and so on. There is no runtime name matching — sets are consumed sequentially from the `SqlDataReader`.
::: warning Order is strict
-If the SP changes its SELECT order, update the type parameters accordingly. There is no runtime name matching — sets are consumed sequentially from the `SqlDataReader`.
+If the SP changes its `SELECT` order, update the type parameters accordingly. Misaligning them produces an `InvalidCastException` (best case) or silently wrong values (worst case) at runtime.
:::
## DTO requirements
-Each type parameter must be a class implementing `ISpMapper` (or generated with `[GenerateDto]`). The same ordinal-mapping rules apply as for single result sets.
+Every type parameter must implement `ISpMapper` (manual or generated with `[GenerateDto]`). The same ordinal-mapping rules described in [DTO Mapping](/documentation/dto-mapping) apply per result set.
+
+## Telemetry
+
+Multi-result-set calls produce a single span tagged with:
+
+| Tag | Value |
+|---|---|
+| `caerius.resultset.multi` | `true` |
+| `caerius.resultset.expected_count` | The number of result sets requested (2, 3, 4, or 5) |
+
+The single span keeps the trace cohesive — there is no fan-out into one span per `SELECT`.
+
+## Combining with TVPs and caching
+
+Multi-result-set calls accept the same builder features as single-result calls — TVPs, scalar parameters, and any of the cache tiers. See [Advanced Usage](/documentation/advanced-usage#multiple-result-sets-with-tvp) for combined examples.
---
diff --git a/Documentations/docs/documentation/reading-data.md b/Documentations/docs/documentation/reading-data.md
index 5d1ed1c..2fea166 100644
--- a/Documentations/docs/documentation/reading-data.md
+++ b/Documentations/docs/documentation/reading-data.md
@@ -1,21 +1,27 @@
# Reading Data
-CaeriusNet provides four read methods, each returning a different collection type to match your performance and API requirements. All methods are `async`, accept a `CancellationToken`, and use `CommandBehavior.SequentialAccess` internally for efficient TDS streaming.
+CaeriusNet exposes four read methods on `ICaeriusNetDbContext`, each returning a different collection type so you can match your performance, allocation, and API-shape requirements. All methods are `async`, accept a `CancellationToken`, and use `CommandBehavior.SequentialAccess` internally for efficient TDS streaming.
## Prerequisites
-- A registered `ICaeriusNetDbContext` (via `CaeriusNetBuilder`)
-- A `StoredProcedureParameters` built with `StoredProcedureParametersBuilder`
-- A DTO implementing `ISpMapper` (or decorated with `[GenerateDto]`)
+- A registered `ICaeriusNetDbContext` (via `CaeriusNetBuilder` — see [Installation & Setup](/quickstart/getting-started))
+- A `StoredProcedureParameters` value built with `StoredProcedureParametersBuilder`
+- A DTO implementing `ISpMapper` (typically generated with `[GenerateDto]`)
## Choosing the right method
| Method | Return type | When to use |
|---|---|---|
-| `QueryAsIEnumerableAsync` | `IEnumerable?` | Deferred access, LINQ pipelines |
-| `QueryAsReadOnlyCollectionAsync` | `ReadOnlyCollection` | Public API surface, immutable contract |
-| `QueryAsImmutableArrayAsync` | `ImmutableArray` | Struct-backed, allocation-efficient, frozen data |
-| `FirstQueryAsync` | `T?` | Single-row lookups |
+| `QueryAsIEnumerableAsync` | `IEnumerable?` | Deferred enumeration, LINQ pipelines |
+| `QueryAsReadOnlyCollectionAsync` | `ReadOnlyCollection` | Public APIs that expose an immutable contract |
+| `QueryAsImmutableArrayAsync` | `ImmutableArray` | Frozen, struct-backed, allocation-efficient data |
+| `FirstQueryAsync` | `T?` | Single-row lookups (returns `null` when empty) |
+
+::: tip Allocation vs. ergonomics
+- `IEnumerable` keeps the door open for downstream LINQ but exposes an enumerator allocation.
+- `ReadOnlyCollection` materializes a `List` and wraps it — predictable, indexable, immutable.
+- `ImmutableArray` is a struct over an array — best for cached, hot-path data passed by value.
+:::
## Repository setup
@@ -29,89 +35,86 @@ using System.Data;
public sealed record UserRepository(ICaeriusNetDbContext DbContext)
: IUserRepository
{
- // ... implementations below
+ // ... method implementations below
}
```
## `QueryAsIEnumerableAsync`
-Returns `IEnumerable?` (null on empty result set). Best for LINQ-pipeline scenarios or when downstream code materializes the collection later.
+Returns `IEnumerable?` (`null` on empty result set). Best for LINQ-pipeline scenarios or when downstream code materializes the collection later.
```csharp
public async Task> GetUsersOlderThanAsync(
- byte age, CancellationToken cancellationToken)
+ byte age, CancellationToken ct)
{
- var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", 450)
+ var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Age", capacity: 450)
.AddParameter("Age", age, SqlDbType.TinyInt)
.Build();
- return await DbContext.QueryAsIEnumerableAsync(sp, cancellationToken) ?? [];
+ return await DbContext.QueryAsIEnumerableAsync(sp, ct) ?? [];
}
```
## `QueryAsReadOnlyCollectionAsync`
-Returns a `ReadOnlyCollection`. Ideal for public APIs where you want to expose an immutable-contract collection.
+Returns a `ReadOnlyCollection` — empty when the SP returns no rows. Ideal for public APIs where the caller should see an immutable contract.
```csharp
-public async Task> GetAllUsersAsync(
- CancellationToken cancellationToken)
+public async Task> GetAllUsersAsync(CancellationToken ct)
{
- var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250)
+ var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
.AddFrozenCache("users:all:frozen")
.Build();
- return await DbContext.QueryAsReadOnlyCollectionAsync(sp, cancellationToken);
+ return await DbContext.QueryAsReadOnlyCollectionAsync(sp, ct);
}
```
## `QueryAsImmutableArrayAsync`
-Returns `ImmutableArray` — a struct wrapper over an array, ideal for frozen data sets that will be cached or passed around without mutation risk.
+Returns `ImmutableArray` — a struct wrapper over an array. Ideal for frozen data sets that will be cached or passed around without mutation.
```csharp
-public async Task> GetUsersImmutableAsync(
- CancellationToken cancellationToken)
+public async Task> GetUsersImmutableAsync(CancellationToken ct)
{
- var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250)
+ var sp = new StoredProcedureParametersBuilder("Users", "usp_Get_All_Users", 250)
.Build();
- return await DbContext.QueryAsImmutableArrayAsync(sp, cancellationToken);
+ return await DbContext.QueryAsImmutableArrayAsync(sp, ct);
}
```
## `FirstQueryAsync`
-Returns `T?` — reads only the first row and returns `null` if no rows are returned. Use for single-entity lookups.
+Returns `T?` — reads only the first row and returns `null` if the result set is empty. Use it for single-entity lookups.
```csharp
-public async Task GetUserByGuidAsync(
- Guid guid, CancellationToken cancellationToken)
+public async Task GetUserByGuidAsync(Guid guid, CancellationToken ct)
{
var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUser_By_Guid")
.AddParameter("Guid", guid, SqlDbType.UniqueIdentifier)
.Build();
- return await DbContext.FirstQueryAsync(sp, cancellationToken);
+ return await DbContext.FirstQueryAsync(sp, ct);
}
```
-## Result set capacity
+## Result-set capacity
-The third argument to `StoredProcedureParametersBuilder` is `resultSetCapacity`. This pre-allocates the internal `List` to the expected row count, avoiding resizing:
+The third constructor argument of `StoredProcedureParametersBuilder` is `resultSetCapacity`. It pre-allocates the internal `List` to the expected row count, avoiding reallocations as rows are added:
```csharp
-// Expecting ~250 rows — pre-allocate to avoid List resizing
-new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250)
+// Expecting ~250 rows — pre-allocate to skip List resizing
+new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", capacity: 250);
```
::: tip Capacity tuning
-Set capacity to a reasonable upper-bound estimate. Too low causes extra allocations; too high wastes memory. For write operations (no result set), the default `16` is fine.
+Pick a reasonable upper bound. Too low triggers extra allocations; too high wastes memory. For write operations (no result set), the default `1` is fine — capacity is ignored.
:::
-## CancellationToken
+## `CancellationToken`
-All read methods accept a `CancellationToken`. Pass it from your controller or caller to support request cancellation:
+Every read method takes a `CancellationToken`. Propagate it from your controller, hosted service, or caller so the SQL command is cancelled if the request is aborted:
```csharp
public async Task> GetAsync(CancellationToken ct)
@@ -123,7 +126,7 @@ public async Task> GetAsync(CancellationToken ct)
## Caching reads
-Add per-call caching to any read operation via the builder. See [Caching](/documentation/cache) for full details.
+Add per-call caching to any read by chaining a cache method on the builder. See [Caching](/documentation/cache) for the full guide.
```csharp
var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250)
@@ -131,6 +134,8 @@ var sp = new StoredProcedureParametersBuilder("dbo", "usp_Get_All_Users", 250)
.Build();
```
+On a cache hit, **no SQL command is executed and no DB span is created** — only the `caerius.cache.lookups{hit=true}` counter ticks.
+
---
**Next:** [Writing Data](/documentation/writing-data) — execute INSERT, UPDATE, DELETE, and scalar returns.
diff --git a/Documentations/docs/documentation/source-generators.md b/Documentations/docs/documentation/source-generators.md
index 44132a9..1caad45 100644
--- a/Documentations/docs/documentation/source-generators.md
+++ b/Documentations/docs/documentation/source-generators.md
@@ -1,25 +1,25 @@
# Source Generators
-CaeriusNet ships two Roslyn incremental source generators that eliminate mapping boilerplate at compile time. Both generators run as part of your build — they produce zero runtime overhead and are fully AOT-compatible.
+CaeriusNet ships two Roslyn **incremental source generators** that eliminate mapping boilerplate at compile time. They run as part of your build, produce zero runtime overhead, and are fully AOT- and trim-compatible.
## Overview
-| Generator | Attribute | Interface generated |
+| Generator | Attribute | Generated interface |
|---|---|---|
| `DtoSourceGenerator` | `[GenerateDto]` | `ISpMapper` |
| `TvpSourceGenerator` | `[GenerateTvp]` | `ITvpMapper` |
-Both generators target **sealed partial records or classes**. The `partial` keyword lets the generator add the interface implementation as a second partial declaration alongside your type.
+Both generators target **sealed partial records or classes** with a primary constructor. The `partial` keyword lets the generator add the interface implementation as a second declaration alongside your type. Constraints are enforced at compile time by the [CaeriusNet analyzer](/documentation/diagnostics) (`CAERIUS001`–`CAERIUS005`).
## `[GenerateDto]` — DTO mapper
-Annotate a sealed partial record or class with `[GenerateDto]`. The generator emits a `MapFromDataReader` method with ordinal-based column reads, correct nullability guards, and special type conversions.
+Annotate a sealed partial record (or class) with `[GenerateDto]`. The generator emits a `MapFromDataReader` method with ordinal-based column reads, correct nullability guards, and special-type conversions.
### Requirements
- Type must be `sealed`
- Type must be `partial`
-- Type must use a primary constructor (the constructor parameters become the mapped columns, in order)
+- Type must declare a **primary constructor** — its parameters become the mapped columns, in order.
### Basic example
@@ -53,10 +53,10 @@ partial record UserDto : ISpMapper
```
::: details Generated code attributes
-- `[GeneratedCode]` marks the file as tool-generated for IDE and analysis tooling
+- `[GeneratedCode]` marks the file as tool-generated for IDEs and analysis tooling
- `[MethodImpl(AggressiveInlining)]` on `MapFromDataReader` enables JIT inlining of the hot-path mapper
- `#pragma warning disable CS1591` suppresses XML doc warnings on generated code
-- `#nullable enable` ensures nullable analysis applies to generated types
+- `#nullable enable` ensures nullable analysis applies regardless of project-level settings
:::
### Nullable fields
@@ -87,22 +87,72 @@ public static ProductDto MapFromDataReader(SqlDataReader reader)
| `Half` | `(Half)reader.GetFloat(n)` |
| `byte[]` | `reader.GetFieldValue(n)` |
-::: tip Half type support
-`System.Half` maps to SQL `real`. The generator reads via `GetFloat` and casts to `Half`. Use for low-precision floating-point values where memory is constrained.
+::: tip `Half` support
+`System.Half` maps to SQL `real`. The generator reads via `GetFloat` and casts to `Half`. Use it for low-precision floating-point values where memory matters.
+:::
+
+### Ordinal constants for wide DTOs
+
+For DTOs with **more than 8 properties**, the generator emits named ordinal constants for readability:
+
+```csharp
+[GenerateDto]
+public sealed partial record LargeDto(
+ int Id, string Name, string Email, byte Age,
+ DateTime CreatedAt, DateTime? UpdatedAt, bool IsActive,
+ decimal Balance, string Country, string? Phone);
+```
+
+Generated (simplified):
+
+```csharp
+partial record LargeDto : ISpMapper
+{
+ private const int OrdId = 0;
+ private const int OrdName = 1;
+ private const int OrdEmail = 2;
+ private const int OrdAge = 3;
+ private const int OrdCreatedAt = 4;
+ private const int OrdUpdatedAt = 5;
+ private const int OrdIsActive = 6;
+ private const int OrdBalance = 7;
+ private const int OrdCountry = 8;
+ private const int OrdPhone = 9;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static LargeDto MapFromDataReader(SqlDataReader reader)
+ => new(
+ reader.GetInt32(OrdId),
+ reader.GetString(OrdName),
+ reader.GetString(OrdEmail),
+ reader.GetByte(OrdAge),
+ reader.GetDateTime(OrdCreatedAt),
+ reader.IsDBNull(OrdUpdatedAt) ? null : reader.GetDateTime(OrdUpdatedAt),
+ reader.GetBoolean(OrdIsActive),
+ reader.GetDecimal(OrdBalance),
+ reader.GetString(OrdCountry),
+ reader.IsDBNull(OrdPhone) ? null : reader.GetString(OrdPhone));
+}
+```
+
+::: tip Why a threshold?
+Compact DTOs read well with raw indices; wide ones do not. The 8-property threshold keeps simple cases terse and improves readability for wider result sets.
:::
## `[GenerateTvp]` — TVP mapper
-Annotate a sealed partial record or class with `[GenerateTvp]`. The generator emits:
-- `TvpTypeName` static property (e.g., `"dbo.tvp_int"`)
-- `_tvpMetaData` static `SqlMetaData[]` field (one entry per property)
-- `MapAsSqlDataRecords` iterator that **reuses a single `SqlDataRecord`** instance across all rows (zero-copy streaming)
+Annotate a sealed partial record (or class) with `[GenerateTvp]` and provide the SQL schema and type name. The generator emits:
+
+- A static `TvpTypeName` property (e.g., `"dbo.tvp_int"`)
+- A static `_tvpMetaData` `SqlMetaData[]` field — one entry per constructor parameter
+- A `MapAsSqlDataRecords` iterator that **reuses a single `SqlDataRecord`** instance across all rows (zero-copy streaming)
### Requirements
- Type must be `sealed`
- Type must be `partial`
-- Attribute requires `Schema` and `TvpName` named arguments
+- Attribute requires **`Schema`** and **`TvpName`** named arguments
+- `TvpName` cannot be empty (analyzer rule `CAERIUS004`)
### Basic example
@@ -146,9 +196,13 @@ partial record UserIdTvp : ITvpMapper
}
```
+::: tip Why a single reused `SqlDataRecord`?
+`Microsoft.Data.SqlClient` synchronously consumes all column values before advancing to the next row, so overwriting the same record between `yield return` calls is safe — and it skips one allocation per row, which adds up at TVP sizes of 10 000 +.
+:::
+
### Nullable TVP fields
-The generator emits `record.SetDBNull(n)` for nullable fields when the value is null:
+The generator emits `record.SetDBNull(n)` for nullable fields whose value is null:
```csharp
[GenerateTvp(Schema = "dbo", TvpName = "tvp_optional")]
@@ -171,80 +225,32 @@ public sealed partial record UserIdTvp(int Id);
|---|---|---|
| `MapFromDataReader` | You write it | Compiler emits it |
| Ordinal indices | You manage them | Auto-assigned from constructor order |
-| Nullable guards | You add `IsDBNull` | Emitted when field is nullable |
-| `SqlDataRecord` reuse | You implement it | Always reused (single instance) |
-| Compilation | Compiles as-is | Requires `sealed partial` |
-| Build-time errors | Runtime | Compile-time |
+| Nullable guards | You add `IsDBNull` checks | Emitted when the parameter is nullable |
+| `SqlDataRecord` reuse (TVP) | You implement it | Always reused (single instance) |
+| Compilation requirement | Compiles as-is | Requires `sealed partial` + primary ctor |
+| Contract violation feedback | Discovered at runtime | Reported at build time by the analyzer |
## Enabling the generators
-The generators ship **inside the CaeriusNet NuGet package** as an embedded Roslyn analyzer. No additional package or MSBuild configuration is required.
+The generators ship **inside the `CaeriusNet` NuGet package** as embedded Roslyn analyzers. No additional package, MSBuild target, or property is required.
-```shell
+```bash
dotnet add package CaeriusNet
```
Once added, `[GenerateDto]` and `[GenerateTvp]` are available in the `CaeriusNet.Attributes.Dto` and `CaeriusNet.Attributes.Tvp` namespaces.
::: tip Inspecting generated code
-Set `true` in your `.csproj` to write generated files to `obj/Generated/` for inspection.
-:::
-
-## Ordinal constants
-
-For DTOs with many columns (more than 8), the generator emits named ordinal constants for readability:
-
-```csharp
-[GenerateDto]
-public sealed partial record LargeDto(
- int Id, string Name, string Email, byte Age,
- DateTime CreatedAt, DateTime? UpdatedAt, bool IsActive,
- decimal Balance, string Country, string? Phone);
-```
-
-Generated:
-
-```csharp
-partial record LargeDto : ISpMapper
-{
- private const int OrdId = 0;
- private const int OrdName = 1;
- private const int OrdEmail = 2;
- private const int OrdAge = 3;
- private const int OrdCreatedAt = 4;
- private const int OrdUpdatedAt = 5;
- private const int OrdIsActive = 6;
- private const int OrdBalance = 7;
- private const int OrdCountry = 8;
- private const int OrdPhone = 9;
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static LargeDto MapFromDataReader(SqlDataReader reader)
- => new(
- reader.GetInt32(OrdId),
- reader.GetString(OrdName),
- reader.GetString(OrdEmail),
- reader.GetByte(OrdAge),
- reader.GetDateTime(OrdCreatedAt),
- reader.IsDBNull(OrdUpdatedAt) ? null : reader.GetDateTime(OrdUpdatedAt),
- reader.GetBoolean(OrdIsActive),
- reader.GetDecimal(OrdBalance),
- reader.GetString(OrdCountry),
- reader.IsDBNull(OrdPhone) ? null : reader.GetString(OrdPhone));
-}
-```
-
-::: tip Threshold
-The ordinal constant threshold (>8 properties) keeps simple DTOs compact while improving readability for wider result sets.
+Set `true` in your `.csproj` to write generated files to `obj/Generated/` for inspection. Useful when debugging mapper behaviour or reviewing what the compiler produced.
:::
-## Pragma directives
+## Pragma directives in generated files
All generated files include standard pragma directives:
```csharp
//
-#pragma warning disable CS1591 // Missing XML comment
+#pragma warning disable CS1591 // Missing XML comment on a public member
#nullable enable
```
@@ -253,4 +259,4 @@ All generated files include standard pragma directives:
---
-**Next:** [Table-Valued Parameters](/documentation/tvp) — bulk inputs without DataTable overhead.
+**Next:** [Table-Valued Parameters](/documentation/tvp) — bulk inputs without `DataTable` overhead.
diff --git a/Documentations/docs/documentation/transactions.md b/Documentations/docs/documentation/transactions.md
index 54279de..ee54b9f 100644
--- a/Documentations/docs/documentation/transactions.md
+++ b/Documentations/docs/documentation/transactions.md
@@ -1,129 +1,128 @@
-# Transactions
+# Transactions
-CaeriusNet provides a lightweight transaction scope that wraps `SqlTransaction` with a thread-safe state machine, automatic rollback on dispose, and cache bypass. Transactions execute multiple commands atomically — either all succeed or all are rolled back.
+CaeriusNet provides a lightweight transaction scope that wraps `SqlTransaction` with a thread-safe state machine, automatic rollback on dispose, cache bypass, and a parent `TX` activity for cohesive tracing. Multiple commands enlisted on the scope succeed or fail **atomically** — there are no partial commits.
-## Overview
+## At a glance
-| Feature | Behavior |
+| Feature | Behaviour |
|---|---|
| **State machine** | `Active` → `Committed` / `RolledBack` / `Poisoned` / `Disposed` |
-| **Thread safety** | `Interlocked.CompareExchange` for state transitions |
-| **Command enforcement** | Single in-flight command (SqlConnection is not thread-safe) |
-| **Failure handling** | Failure poisons the scope — only rollback/dispose remain valid |
+| **Thread safety** | State transitions guarded by `Interlocked.CompareExchange` |
+| **Single in-flight command** | Enforced — `SqlConnection` is not thread-safe |
+| **Failure handling** | A failure poisons the scope; only `RollbackAsync` / `DisposeAsync` remain valid |
| **Cache** | Bypassed inside transactions (no dirty reads published) |
-| **Auto-rollback** | Uncommitted scope rolls back on `DisposeAsync` |
+| **Auto-rollback** | An uncommitted scope rolls back on `DisposeAsync` |
+| **Telemetry** | Parent `TX` span (kind = Internal) wraps every child SP span |
| **Logging** | Structured events for start, commit, rollback, and poison |
## Basic usage
### Commit example
-Execute multiple commands in a single transaction and commit atomically:
+Execute multiple commands and commit atomically:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var spInsert = new StoredProcedureParametersBuilder("dbo", "sp_InsertOrder")
.AddParameter("UserId", userId, SqlDbType.Int)
.AddParameter("Amount", amount, SqlDbType.Decimal)
.Build();
-await tx.ExecuteNonQueryAsync(spInsert, cancellationToken);
+await tx.ExecuteNonQueryAsync(spInsert, ct);
var spUpdate = new StoredProcedureParametersBuilder("dbo", "sp_UpdateUserBalance")
.AddParameter("UserId", userId, SqlDbType.Int)
- .AddParameter("Debit", amount, SqlDbType.Decimal)
+ .AddParameter("Debit", amount, SqlDbType.Decimal)
.Build();
-await tx.ExecuteNonQueryAsync(spUpdate, cancellationToken);
+await tx.ExecuteNonQueryAsync(spUpdate, ct);
-await tx.CommitAsync(cancellationToken);
+await tx.CommitAsync(ct);
```
-### Rollback example
+### Explicit rollback
-Explicitly roll back when business logic requires it:
+Roll back deliberately when business logic decides the work should not persist:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var sp = new StoredProcedureParametersBuilder("dbo", "sp_ReserveInventory")
.AddParameter("ProductId", productId, SqlDbType.Int)
- .AddParameter("Quantity", quantity, SqlDbType.Int)
+ .AddParameter("Quantity", quantity, SqlDbType.Int)
.Build();
-var reserved = await tx.ExecuteScalarAsync(sp, cancellationToken);
+var reserved = await tx.ExecuteScalarAsync(sp, ct);
if (reserved < quantity)
{
- await tx.RollbackAsync(cancellationToken);
+ await tx.RollbackAsync(ct);
return InventoryResult.InsufficientStock;
}
-await tx.CommitAsync(cancellationToken);
+await tx.CommitAsync(ct);
return InventoryResult.Reserved;
```
### Auto-rollback on dispose
-If `CommitAsync` is never called, `DisposeAsync` automatically rolls back:
+If `CommitAsync` is never called, `DisposeAsync` rolls back automatically:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
-await tx.ExecuteNonQueryAsync(sp, cancellationToken);
+await tx.ExecuteNonQueryAsync(sp, ct);
-// No CommitAsync — transaction is rolled back when tx is disposed.
+// No CommitAsync — the transaction is rolled back when tx is disposed.
```
-::: warning Always call CommitAsync explicitly
-Relying on auto-rollback is a safety net, not an intended control flow. Always call `CommitAsync` on the success path for clarity and intent.
+::: warning Always call `CommitAsync` on the success path
+Auto-rollback is a safety net, not an intended control flow. Calling `CommitAsync` explicitly makes the success path obvious to the reader and to logs.
:::
-## Reading data inside transactions
+## Reading inside a transaction
-Query methods work inside a transaction scope. Results reflect uncommitted changes within the same transaction:
+Query methods work inside a transaction scope. Reads see uncommitted changes made earlier in the same transaction:
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var spInsert = new StoredProcedureParametersBuilder("dbo", "sp_InsertUser")
.AddParameter("Username", "alice", SqlDbType.NVarChar)
.Build();
-await tx.ExecuteNonQueryAsync(spInsert, cancellationToken);
+await tx.ExecuteNonQueryAsync(spInsert, ct);
var spRead = new StoredProcedureParametersBuilder("dbo", "sp_GetUser_By_Name", 1)
.AddParameter("Username", "alice", SqlDbType.NVarChar)
.Build();
-var user = await tx.QueryAsIEnumerableAsync(spRead, cancellationToken);
-// user contains the uncommitted row — visible within this transaction.
+var user = await tx.QueryAsIEnumerableAsync(spRead, ct);
+// user contains the uncommitted row — visible within this transaction only.
-await tx.CommitAsync(cancellationToken);
+await tx.CommitAsync(ct);
```
## State machine
-The transaction scope enforces a strict state machine to prevent illegal operations:
+The scope enforces a strict state machine to prevent illegal operations:
-| State | Value | Allowed operations |
-|---|---|---|
-| **Active** | 0 | `ExecuteNonQueryAsync`, `QueryAs*Async`, `CommitAsync`, `RollbackAsync`, `DisposeAsync` |
-| **Committed** | 1 | `DisposeAsync` only |
-| **RolledBack** | 2 | `DisposeAsync` only |
-| **Poisoned** | 3 | `RollbackAsync`, `DisposeAsync` only |
-| **Disposed** | 4 | None — all calls throw `ObjectDisposedException` |
-
-### State transition diagram
+| State | Allowed operations |
+|---|---|
+| **Active** | `ExecuteNonQueryAsync`, `QueryAs*Async`, `CommitAsync`, `RollbackAsync`, `DisposeAsync` |
+| **Committed** | `DisposeAsync` only |
+| **RolledBack** | `DisposeAsync` only |
+| **Poisoned** | `RollbackAsync`, `DisposeAsync` only |
+| **Disposed** | None — all calls throw `ObjectDisposedException` |
-```
+```text
┌──────────────┐
- │ Active │
+ │ Active │
└──────┬───────┘
│
┌───────────┼───────────┐
@@ -139,27 +138,27 @@ Committed RolledBack Poisoned
### Poison state
-When a command fails (throws an exception) inside an `Active` transaction, the scope transitions to `Poisoned`. In this state:
+When a command fails inside an `Active` transaction, the scope transitions to `Poisoned`. In this state:
- All subsequent `ExecuteNonQueryAsync` / `QueryAs*Async` calls throw immediately.
- `CommitAsync` throws — you cannot commit a poisoned transaction.
- Only `RollbackAsync` and `DisposeAsync` are valid.
-This prevents partial commits after a failure — the entire unit of work must be retried.
+This rule prevents partial commits after a failure. Retry the **entire** unit of work, do not attempt partial recovery.
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
- await tx.ExecuteNonQueryAsync(sp1, cancellationToken); // succeeds
- await tx.ExecuteNonQueryAsync(sp2, cancellationToken); // throws — scope poisoned
+ await tx.ExecuteNonQueryAsync(sp1, ct); // succeeds
+ await tx.ExecuteNonQueryAsync(sp2, ct); // throws — scope is now Poisoned
}
catch (CaeriusNetSqlException)
{
- // tx is now Poisoned. CommitAsync would throw.
- await tx.RollbackAsync(cancellationToken);
+ // CommitAsync would throw here.
+ await tx.RollbackAsync(ct);
}
```
@@ -167,10 +166,10 @@ catch (CaeriusNetSqlException)
### Single in-flight command
-`SqlConnection` is not thread-safe. CaeriusNet enforces a single command at a time within a transaction scope. Attempting concurrent commands throws `InvalidOperationException`:
+`SqlConnection` is not thread-safe. CaeriusNet enforces a single command at a time within a scope. Concurrent commands throw `InvalidOperationException`:
```csharp
-// ❌ DO NOT — concurrent commands on the same transaction
+// ❌ DO NOT — concurrent commands on the same scope
var task1 = tx.ExecuteNonQueryAsync(sp1, ct);
var task2 = tx.ExecuteNonQueryAsync(sp2, ct); // throws InvalidOperationException
await Task.WhenAll(task1, task2);
@@ -184,46 +183,63 @@ await tx.ExecuteNonQueryAsync(sp2, ct);
### No nested transactions
-SQL Server does not support nested transactions. Use SQL `SAVEPOINT` within a stored procedure if you need partial rollback semantics:
+SQL Server does not support nested transactions on a single connection. Calling `BeginTransactionAsync` on a scope throws `NotSupportedException`. Use SQL `SAVEPOINT` inside a Stored Procedure if you need partial-rollback semantics:
```sql
-CREATE PROCEDURE dbo.sp_WithSavepoint
+CREATE PROCEDURE dbo.sp_With_Savepoint
AS
BEGIN
SET NOCOUNT ON;
- SAVE TRANSACTION SavePoint1;
+ SAVE TRANSACTION sp1;
-- ... work ...
IF @@ERROR <> 0
- ROLLBACK TRANSACTION SavePoint1;
+ ROLLBACK TRANSACTION sp1;
END
+GO
```
### Cache bypass
-CaeriusNet bypasses all cache layers (Frozen, InMemory, Redis) inside a transaction. This prevents uncommitted (dirty) data from being published to the cache and served to other consumers.
+CaeriusNet bypasses every cache tier (Frozen, InMemory, Redis) inside a transaction scope. This prevents uncommitted data from being published to the cache and served to other consumers. After `CommitAsync`, subsequent non-transactional reads populate the cache normally.
::: tip Cache bypass is intentional
-After `CommitAsync`, subsequent non-transactional reads will populate the cache normally. Design your cache keys and TTLs accordingly.
+If a cached read is critical, perform it **before** entering the transaction, or **after** committing — never inside.
:::
### Failure poisoning
-Any unhandled exception from a command execution poisons the transaction. This is deliberate — partial success in a transaction is unsafe. The poison state forces you to either:
-1. Roll back explicitly with `RollbackAsync`.
-2. Let `DisposeAsync` auto-rollback.
+Any unhandled exception from a command poisons the scope. Partial success in a transaction is unsafe — you must either `RollbackAsync` explicitly or let `DisposeAsync` auto-rollback.
+
+## Tracing
+
+Every scope emits a parent **`TX` span** (kind = Internal) under which all child SP spans nest. The trace remains a single cohesive workflow in the Aspire dashboard:
+
+```text
+TX (caerius.tx.isolation_level=ReadCommitted, caerius.tx.outcome=committed)
+├── SP Users.usp_Create_User (caerius.tx=true)
+└── SP Users.usp_Create_Order (caerius.tx=true)
+```
+
+| Tag | Description |
+|---|---|
+| `caerius.tx.isolation_level` | The SQL Server isolation level (e.g. `ReadCommitted`) |
+| `caerius.tx.outcome` | `committed`, `rolled-back`, `auto-rollback`, `poisoned-auto-rollback`, `commit-failed`, `rollback-failed` |
+| `caerius.tx` | `true` on every child SP span enlisted in the scope |
+
+See [Aspire Integration — Transaction tracing](/documentation/aspire#transaction-tracing) for full details.
## Best practices
| Practice | Rationale |
|---|---|
-| Always use `await using` | Guarantees `DisposeAsync` runs even on exception paths |
-| Pass `CancellationToken` | Cancels long-running SQL commands on request abort |
-| Keep transactions short | Long transactions hold locks, block other readers/writers |
-| Use appropriate `IsolationLevel` | `ReadCommitted` is the default; use `Serializable` only when required |
-| Avoid I/O inside transactions | HTTP calls, file writes, etc. extend transaction lifetime unpredictably |
-| Retry at the caller, not inside | If poisoned, create a new transaction scope for retry |
-
-::: warning Isolation level guidance
+| Always use `await using` | Guarantees `DisposeAsync` runs on every code path, including exceptions |
+| Pass `CancellationToken` | Cancels in-flight SQL commands when the request is aborted |
+| Keep transactions short | Long transactions hold locks and block other readers / writers |
+| Pick the right `IsolationLevel` | `ReadCommitted` is the default; raise it only when you must |
+| Avoid I/O inside transactions | HTTP calls, file writes, etc. extend lifetime unpredictably |
+| Retry at the caller, not inside | If poisoned, create a fresh scope and retry the whole unit of work |
+
+::: tip Isolation-level guidance
- `ReadUncommitted` — dirty reads acceptable, highest concurrency
- `ReadCommitted` — default, prevents dirty reads
- `RepeatableRead` — prevents non-repeatable reads, higher lock contention
@@ -233,40 +249,38 @@ Any unhandled exception from a command execution poisons the transaction. This i
## Error handling
-All SQL errors inside a transaction are wrapped in `CaeriusNetSqlException`, which includes:
+SQL errors raised inside a transaction are wrapped in `CaeriusNetSqlException`:
-- The original `SqlException` as `InnerException`
-- The stored procedure name that failed
-- The transaction state at the time of failure
+- Original `SqlException` is preserved as `InnerException`
+- The active SP span is tagged `ActivityStatusCode.Error` before the exception bubbles up
```csharp
-await using var tx = await dbContext.BeginTransactionAsync(
- IsolationLevel.ReadCommitted, cancellationToken);
+await using var tx = await DbContext
+ .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
try
{
- await tx.ExecuteNonQueryAsync(sp, cancellationToken);
- await tx.CommitAsync(cancellationToken);
+ await tx.ExecuteNonQueryAsync(sp, ct);
+ await tx.CommitAsync(ct);
}
catch (CaeriusNetSqlException ex) when (ex.InnerException is SqlException sqlEx)
{
- // Log structured error details
logger.LogError(ex, "Transaction failed on {Procedure}", ex.ProcedureName);
- await tx.RollbackAsync(cancellationToken);
+ await tx.RollbackAsync(ct);
}
```
## Logging events
-CaeriusNet emits structured log events for transaction lifecycle:
+CaeriusNet emits structured log events for the transaction lifecycle:
| Event | Level | Description |
|---|---|---|
-| Transaction started | Information | Logs isolation level and connection |
+| Transaction started | Information | Logs isolation level and connection identifier |
| Transaction committed | Information | Logs elapsed time |
-| Transaction rolled back | Warning | Logs whether explicit or auto-rollback |
-| Transaction poisoned | Error | Logs the causing exception |
+| Transaction rolled back | Warning | Logs whether the rollback was explicit or automatic |
+| Transaction poisoned | Error | Logs the originating exception |
---
-**Next:** [Logging & Observability](/documentation/logging) — structured logging, event IDs, and telemetry integration.
+**Next:** [Logging & Observability](/documentation/logging) — structured logging, event IDs, and OTel integration.
diff --git a/Documentations/docs/documentation/tvp.md b/Documentations/docs/documentation/tvp.md
index c11267d..2439ba3 100644
--- a/Documentations/docs/documentation/tvp.md
+++ b/Documentations/docs/documentation/tvp.md
@@ -1,16 +1,18 @@
# Table-Valued Parameters
-Table-Valued Parameters (TVP) let you pass an entire set of rows — IDs, GUIDs, composite keys — as a single parameter to a SQL Server Stored Procedure. CaeriusNet implements TVP streaming via `IEnumerable`, which avoids `DataTable` allocation and streams data directly to the TDS protocol layer.
+Table-Valued Parameters (TVP) let you pass an entire **set of rows** — IDs, GUIDs, composite keys, or wider row shapes — as a single typed parameter to a SQL Server Stored Procedure. CaeriusNet implements TVP transport via streaming `IEnumerable`, which avoids `DataTable` allocations and feeds rows directly to the TDS protocol layer.
-## What is a TVP?
+## Why TVPs?
-A TVP is a SQL Server user-defined table type that can be passed as a read-only parameter (`READONLY`) to Stored Procedures. Instead of sending 1 000 IDs one by one, you send a single structured table with 1 000 rows in one round-trip.
+A TVP is a SQL Server **user-defined table type** that you pass as a `READONLY` parameter. Instead of sending 1 000 IDs one by one (or padding a dynamic SQL `IN`-list), you send a single structured table with 1 000 rows in one round-trip.
```sql
-- 1. Create the SQL Server type
-CREATE TYPE dbo.tvp_int AS TABLE (
+CREATE TYPE dbo.tvp_int AS TABLE
+(
Id INT NOT NULL
);
+GO
-- 2. Use it in a Stored Procedure
CREATE PROCEDURE dbo.sp_GetUsers_By_Tvp_Ids
@@ -19,16 +21,17 @@ AS
BEGIN
SET NOCOUNT ON;
SELECT Id, Username, Age
- FROM dbo.Users
- WHERE Id IN (SELECT Id FROM @Ids);
+ FROM dbo.Users
+ WHERE Id IN (SELECT Id FROM @Ids);
END
+GO
```
## C# implementation
### Source-generated (recommended)
-Annotate a sealed partial record with `[GenerateTvp]`. Provide the SQL schema and type name:
+Annotate a sealed partial record with `[GenerateTvp]` and provide the SQL `Schema` and `TvpName`:
```csharp
using CaeriusNet.Attributes.Tvp;
@@ -37,7 +40,7 @@ using CaeriusNet.Attributes.Tvp;
public sealed partial record UserIdTvp(int Id);
```
-The generator emits `ITvpMapper` with a zero-allocation `SqlDataRecord` streaming implementation. See [Source Generators](/documentation/source-generators) for details on the generated code.
+The generator emits `ITvpMapper` with a zero-allocation `SqlDataRecord` streaming implementation. See [Source Generators](/documentation/source-generators#generatetvp-tvp-mapper) for the generated shape.
### Manual implementation
@@ -55,58 +58,60 @@ public sealed record UserIdTvp(int Id) : ITvpMapper
public IEnumerable MapAsSqlDataRecords(IEnumerable items)
{
var metaData = new[] { new SqlMetaData("Id", SqlDbType.Int) };
- var record = new SqlDataRecord(metaData); // single instance reused across all rows
+ var record = new SqlDataRecord(metaData); // single instance reused across all rows
+
foreach (var item in items)
{
record.SetInt32(0, item.Id);
- yield return record; // Microsoft.Data.SqlClient reads values before advancing
+ yield return record;
}
}
}
```
-::: tip Single-instance reuse
-A single `SqlDataRecord` is created once and its values are overwritten before each `yield return`. `Microsoft.Data.SqlClient` reads all column values synchronously before moving to the next row, making this zero-copy pattern safe.
+::: tip Why a single reused `SqlDataRecord`?
+`Microsoft.Data.SqlClient` reads all column values synchronously before advancing to the next row, so overwriting the same instance between `yield return` calls is safe and avoids one allocation per row.
:::
-## Using TVP with the builder
+## Using a TVP with the builder
Pass the TVP collection via `AddTvpParameter`:
```csharp
var ids = users.Select(u => new UserIdTvp(u.Id)).ToList();
-var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", 1024)
+var sp = new StoredProcedureParametersBuilder("dbo", "sp_GetUsers_By_Tvp_Ids", capacity: 1024)
.AddTvpParameter("Ids", ids)
.Build();
-var users = await dbContext.QueryAsIEnumerableAsync(sp, cancellationToken);
+var matchedUsers = await dbContext.QueryAsIEnumerableAsync(sp, ct);
```
::: warning Empty collections throw
-`AddTvpParameter` validates that the collection is non-empty. Passing an empty `IEnumerable