DKNet

DKNet.AspCore.Idempotency.Relational

Shared EF Core building blocks — entity, mapping, DbContext, and the concurrency-safe reserve/check/complete flow — that every relational idempotency store for DKNet.AspCore.Idempotency derives from.

Not for app authors. This package has no AddIdempotency... extension of its own and nothing in it is public. If you are wiring idempotency into an app, use DKNet.AspCore.Idempotency — its Choosing a store section covers when a relational store is the right call — plus a concrete provider package (MsSqlStore / NpgsqlStore). Read on only if you are adding support for a new relational database (MySQL, SQLite, …) to the DKNet idempotency family.

✨ Why use it?

🚀 Quick Start

dotnet add package DKNet.AspCore.Idempotency.Relational

A new provider package supplies exactly four pieces, following the pattern IdempotencySqlServerStore / IdempotencyPostgresStore already establish.

1. A closed DbContext — a one-line internal sealed subclass of IdempotencyDbContext that pins the provider’s own closed DbContextOptions<TContext>:

internal sealed class IdempotencyDbContext(DbContextOptions<IdempotencyDbContext> options)
    : DKNet.AspCore.Idempotency.Relational.Data.IdempotencyDbContext(options);

2. A concrete entity configuration — subclass IdempotencyKeyConfiguration and override the two provider-specific members:

internal sealed class IdempotencyKeyConfiguration
    : DKNet.AspCore.Idempotency.Relational.Data.Configurations.IdempotencyKeyConfiguration
{
    protected override string BodyColumnType => "nvarchar(max)";                 // "text" for Npgsql
    protected override string StatusCodeCheckConstraintSql => "[StatusCode] BETWEEN 100 AND 599";
}

3. A concrete store — subclass IdempotencyRelationalStore<TContext> and override IsProviderUniqueViolation:

internal sealed class IdempotencySqlServerStore(
    IServiceProvider serviceProvider,
    IOptions<IdempotencyOptions> options,
    ILogger<IdempotencySqlServerStore> logger)
    : IdempotencyRelationalStore<IdempotencyDbContext>(serviceProvider, options, logger)
{
    protected override bool IsProviderUniqueViolation(DbUpdateException ex) =>
        ex.InnerException is SqlException { Number: 2601 or 2627 };
}

4. A DI registration extension — register the closed DbContext and its factory, register the shared IdempotencyMigrationHostedService<TContext> to migrate at startup, then hand the store to AddIdempotentKey<TStore> from the core package:

public static IServiceCollection AddIdempotencyMsSqlStore(this IServiceCollection services, string connectionString)
{
    ArgumentNullException.ThrowIfNull(services);
    ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);

    if (services.IsRegistered<IdempotencyDbContext>())
        return services;

    services.AddDbContext<IdempotencyDbContext>(options =>
        {
            options.UseSqlServer(connectionString, sqlOptions => sqlOptions
                .MigrationsAssembly(typeof(IdempotencyMsSqlSetup).Assembly)
                .MigrationsHistoryTable(nameof(IdempotencyDbContext), "migrate"));
        }, optionsLifetime: ServiceLifetime.Singleton)
        .AddDbContextFactory<IdempotencyDbContext>();

    // Migrate once at startup rather than on the request path.
    services.AddHostedService<IdempotencyMigrationHostedService<IdempotencyDbContext>>();

    return services;
}

public static IServiceCollection AddIdempotencyWithMsSqlStore(
    this IServiceCollection services, string connectionString, Action<IdempotencyOptions>? config = null)
{
    services.AddIdempotencyMsSqlStore(connectionString);
    return services.AddIdempotentKey<IdempotencySqlServerStore>(config);
}

IsRegistered<TService>() is the first-wins guard from DKNet.Fw.Extensions (namespace Microsoft.Extensions.DependencyInjection). AddDbContextFactory<TContext> is required — IdempotencyRelationalStore<TContext> resolves IDbContextFactory<TContext> per operation rather than injecting the context directly, so each reserve/check/complete call gets its own short-lived context instance instead of sharing one across a longer-lived scope. The AddHostedService<IdempotencyMigrationHostedService<TContext>>() call is what moves migration off the request path — see IdempotencyMigrationHostedService<TContext> below.

Own the migrations too: they live in the derived provider project (a Migrations/ folder, its own MigrationsAssembly), never in this base package — see Gotchas & limits.

🧩 Features

IdempotencyKeyEntity — the shared row shape

Constructed from an IdempotentKeyInfo plus a CachedResponse, with private setters so state only changes through its own methods:

IdempotencyKeyConfiguration — the shared mapping

The IEntityTypeConfiguration<IdempotencyKeyEntity> base every provider’s own configuration derives from. It owns every mapping detail that is identical across providers — key, lengths, unicode flags, the ExpiresAt index, the unique UX_CompositeKey index — and defers exactly two protected abstract members to the derived type (see Configuration reference).

IdempotencyDbContext — mapping discovery from the derived assembly

Its OnModelCreating calls modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly). Because that scans the derived type’s assembly, each provider only has to place its own IdempotencyKeyConfiguration next to its DbContext subclass; nothing needs registering explicitly. The base constructor accepts the non-generic DbContextOptions, so a single base works regardless of which closed DbContextOptions<TContext> the derived context declares.

IdempotencyRelationalStore<TContext> — reserve, check, complete

The IIdempotencyKeyStore implementation itself:

IdempotencyMigrationHostedService<TContext> — migrate once at startup

internal sealed class IdempotencyMigrationHostedService<TContext>(IDbContextFactory<TContext> dbContextFactory)
    : IHostedLifecycleService
    where TContext : DbContext

An IHostedLifecycleService that applies any pending migrations for TContext once, in StartAsync, before the host begins serving requests. The migration still runs in that single start moment — the other lifecycle moments (starting, started, stopping, stop, stopped) do no work. Both AddIdempotencyMsSqlStore and AddIdempotencyNpgsqlStore register it (services.AddHostedService<IdempotencyMigrationHostedService<IdempotencyDbContext>>()) as part of their DbContext registration — an app that calls either extension gets it automatically, with nothing further to wire up.

This requires the host to actually run hosted services — a normal ASP.NET Core WebApplication.RunAsync() (or Run()) does this as part of its lifecycle, so most apps need not think about it. It matters if you host IdempotencyDbContext some other way (a worker service with a custom IHost build, a test harness that never calls RunAsync/StartAsync on the host) — in that case the migration never runs at startup, and the store falls back to the per-request guard described above, which self-heals the schema on the first request instead of failing outright.

⚙️ Configuration reference

This package has no public API surface at all, so it has no customisation reference in the sense the other pages on this site do: IdempotencyRelationalStore<TContext>, IdempotencyDbContext, IdempotencyKeyEntity and IdempotencyKeyConfiguration are every type it declares, and all four are internal. There is no options class, no Add… extension, and nothing a consuming application can name, configure, or subclass. Runtime behaviour is driven entirely by the core package’s IdempotencyOptions — see the core configuration reference.

What follows is the internal implementation contract for the in-repo provider packages that this package’s InternalsVisibleTo list already names, reproduced here because that is the only audience this page has. None of it is available outside those assemblies.

What a derived configuration must supply:

Member Type Purpose
BodyColumnType protected abstract string Provider column type for the response body — nvarchar(max) on SQL Server, text on PostgreSQL.
StatusCodeCheckConstraintSql protected abstract string CK_StatusCode_Valid SQL, differing only in identifier quoting — [StatusCode] BETWEEN 100 AND 599 vs "StatusCode" BETWEEN 100 AND 599.

Everything else is fixed by the shared mapping, and a derived provider cannot change it:

Column Type / constraint Notes
Id Guid (PK) Guid.CreateVersion7(), time-ordered
IdempotentKey max 150, required, non-Unicode The raw caller-supplied key
Endpoint max 250, required, Unicode Route template, upper-invariant
Method max 20, required, non-Unicode HTTP method
CompositeKey max 128, required, Unicode, unique (UX_CompositeKey) Uppercase hex SHA-256 of the raw composite key
StatusCode int, required, check CK_StatusCode_Valid 100–599; provider supplies the constraint SQL
Body provider column type, max 1,048,576 chars Unicode; null while reserved
ContentType max 256, non-Unicode MIME type, nullable
CreatedAt / ExpiresAt DateTimeOffset / DateTimeOffset? ExpiresAt is indexed (IX_IdempotencyKeys_ExpiresAt) for cleanup queries

Of the core package’s options, the shared store itself reads only InFlightReservationTimeout (the reservation window on both the fresh-insert and reclaim paths). Expiration, the status-code window and every other option are applied by the endpoint filter before the store is ever called.

🧱 Where it fits

Workflow diagram of the shared check-and-reserve flow: a SELECT for an unexpired row either returns a duplicate or falls through to an INSERT of a 102 reservation row; the insert either wins outright or raises a provider unique violation, which is classified, and a still-live blocking row returns the winner's state while an expired one is reclaimed by a conditional UPDATE whose affected-row count picks a single winner.

The two “Proceed” exits on the right are the only paths that let a caller run the protected handler, and each is reached through exactly one atomic step — the unique-index insert, or the conditional UPDATE whose affected-row count is 1. Everything else converges on a duplicate answer.

DKNet.AspCore.Idempotency            core: IIdempotencyKeyStore, IdempotencyOptions, AddIdempotentKey<TStore>
        ▲
        │ implements
DKNet.AspCore.Idempotency.Relational shared base: entity, mapping, DbContext, reserve/check/complete
        ▲                    ▲
        │ derives            │ derives
DKNet.AspCore.Idempotency     DKNet.AspCore.Idempotency
   .MsSqlStore                   .NpgsqlStore

App code never references this package directly — it depends on DKNet.AspCore.Idempotency for IIdempotencyKeyStore/AddIdempotentKey<TStore> and on a concrete provider package (MsSqlStore/NpgsqlStore) for the AddIdempotencyWithXxxStore(...) registration that wires a store built on this base into DI.

⚠️ Gotchas & limits