DKNet

DKNet.AspCore.Idempotency.NpgsqlStore

PostgreSQL-backed IIdempotencyKeyStore for DKNet.AspCore.Idempotency, built on the shared relational base.

✨ Why use it?

Reach for this store when Postgres is already part of your stack — see Choosing a store on the core page for how it compares to Redis and to the SQL Server store; that comparison is not repeated here.

🚀 Quick Start

dotnet add package DKNet.AspCore.Idempotency.NpgsqlStore
using DKNet.AspCore.Idempotency;
using DKNet.AspCore.Idempotency.NpgsqlStore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddIdempotencyWithNpgsqlStore(
    builder.Configuration.GetConnectionString("IdempotencyDb")!,
    options =>
    {
        options.Expiration = TimeSpan.FromHours(48);
        options.ConflictHandling = IdempotentConflictHandling.CachedResult;
    });

var app = builder.Build();

app.MapPost("/orders", CreateOrder)
    .RequiredIdempotentKey();

await app.RunAsync();

AddIdempotencyWithNpgsqlStore (in IdempotencyNpgsqlSetup) registers IdempotencyDbContext — a scoped AddDbContext with a singleton DbContextOptions, plus an IDbContextFactory<IdempotencyDbContext> and a hosted service that migrates the schema once at application startup (IdempotencyMigrationHostedService<IdempotencyDbContext>) — against the given Npgsql connection string, then calls AddIdempotentKey<IdempotencyPostgresStore>(config) to wire it up as the IIdempotencyKeyStore. Call AddIdempotencyNpgsqlStore(connectionString) on its own if you only need the DbContext (and the startup migration) registered without replacing the key store.

🧩 Features

Registration entry points

IdempotencyNpgsqlSetup is the package’s only public type, and it declares exactly two extension methods on IServiceCollection:

Method Registers Does not register
AddIdempotencyNpgsqlStore(string connectionString) IdempotencyDbContext via AddDbContext (scoped context, singleton DbContextOptions), AddDbContextFactory<IdempotencyDbContext>, and IdempotencyMigrationHostedService<IdempotencyDbContext> (migrates at startup) The key store. Called on its own, no IIdempotencyKeyStore exists and RequiredIdempotentKey() cannot resolve the filter’s dependency.
AddIdempotencyWithNpgsqlStore(string connectionString, Action<IdempotencyOptions>? config = null) Everything the first method does, then AddIdempotentKey<IdempotencyPostgresStore>(config) Nothing — this is the call an application makes.

Both throw ArgumentNullException on a null services and ArgumentException on a null, empty or whitespace connection string, and both are first-wins: a second call with a different connection string is silently a no-op once IdempotencyDbContext (or any IIdempotencyKeyStore) is already registered.

IdempotencyPostgresStore is internal, so AddIdempotentKey<IdempotencyPostgresStore>() is not something application code can write — AddIdempotencyWithNpgsqlStore(...) is the supported way in. Reach for AddIdempotencyNpgsqlStore(...) on its own only when you want the DbContext registered without replacing the key store, for example to run migrations from a start-up job.

What the package creates, and what you provide

Thing Who provides it
The IdempotencyKeys table, UX_CompositeKey, IX_IdempotencyKeys_ExpiresAt and CK_StatusCode_Valid The package — created by the shipped Initial migration
Applying that migration The package, automatically, once at application startup via a hosted service (or you, ahead of time — see Gotchas & limits)
The migrate.IdempotencyDbContext migrations-history table and the migrate schema The package, through EF Core
A reachable PostgreSQL database and a role that can create tables and schemas in it You
Row expiry / cleanup of keys nobody ever retries You — ExpiresAt is indexed, but nothing sweeps it
Backup, retention and PII policy for cached response bodies You — Body holds the serialized response verbatim

The IdempotencyKeys table

The shipped Initial migration creates one table:

Column Type Notes
Id uuid Primary key PK_IdempotencyKeys
CompositeKey character varying(128) Unique — see below
Endpoint character varying(250)  
IdempotentKey, Method character varying(150) / character varying(20), non-Unicode  
Body text, max 1,048,576 chars PostgreSQL override of the shared entity mapping
ContentType character varying(256), non-Unicode Nullable
StatusCode integer CK_StatusCode_Valid check constraint, 100–599
CreatedAt, ExpiresAt timestamp with time zone ExpiresAt nullable

Atomic reservation via UX_CompositeKey

UX_CompositeKey (unique, on CompositeKey) is what makes reservation atomic under concurrency: only one concurrent insert for a given key can succeed; every other one fails with Postgres SQL state 23505, which the store detects and the relational base turns into “already reserved/processed” instead of a raw exception. IX_IdempotencyKeys_ExpiresAt backs expiry lookups.

protected override bool IsProviderUniqueViolation(DbUpdateException ex) =>
    ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };

Per-connection-string migration guard

For the common case, migration now happens once at application startup via IdempotencyMigrationHostedService<IdempotencyDbContext>, which AddIdempotencyNpgsqlStore registers automatically — see DKNet.AspCore.Idempotency.Relational. The relational base’s older per-request guard still exists underneath as a defensive fallback (for a host that skips or reorders hosted services), and multiple Postgres databases behind the same process — e.g. one store per tenant database — are still each migrated and guarded independently there: the “migrations ensured” state is keyed per connection string, not by a single process-wide flag, so a process working against more than one connection string prepares each one rather than skipping every database after the first (see IdempotencyMultiDatabaseTests).

Design-time tooling

IdempotencyDbContext here is the shared relational context closed over Npgsql’s DbContextOptions. Its DbContextFactory (an IDesignTimeDbContextFactory<IdempotencyDbContext>) exists only for dotnet ef — it reads IDEMPOTENCY_NPGSQL_CONNECTION from the environment, not from app configuration, and throws InvalidOperationException when that variable is unset.

⚙️ Configuration reference

There is no PostgreSQL-specific options type — expiration, conflict handling, header name, and key scope resolution all come from the shared IdempotencyOptions; see the core configuration reference.

Registration bakes in Npgsql-specific EF Core configuration rather than exposing it through IdempotencyOptions:

Setting Value Effect
EnableRetryOnFailure 3 retries, 5 seconds apart Transient-fault resilience on idempotency queries.
UseQuerySplittingBehavior QuerySplittingBehavior.SplitQuery EF Core split-query mode for this context.
MigrationsAssembly this package’s assembly Migrations ship with the store, not with your app.
MigrationsHistoryTable migrate.IdempotencyDbContext Separate from your app’s own __EFMigrationsHistory.
optionsLifetime ServiceLifetime.Singleton Options are shared; the DbContext itself stays scoped.

🧱 Where it fits

Architecture diagram: one AddIdempotencyWithNpgsqlStore call wires the core package's endpoint filter to IdempotencyPostgresStore, which inherits every shared step from IdempotencyRelationalStore and reaches PostgreSQL's IdempotencyKeys table, applying this package's own migrations history on first use.

IdempotencyPostgresStore supplies only the Postgres unique-violation check; the reserve → check → complete flow, the expired-reservation reclaim, and the per-connection-string migration guard all live in IdempotencyRelationalStore<TContext> — see DKNet.AspCore.Idempotency.Relational. Its IdempotencyDbContext and IdempotencyKeyConfiguration override only the two Postgres-specific bits (text body column, quoted-identifier check-constraint SQL); the rest of the entity mapping is the relational base’s. The HTTP-facing pieces — endpoint filter, key scope resolution, IdempotencyOptions — live in DKNet.AspCore.Idempotency.

⚠️ Gotchas & limits