Idempotency
Declarative, transport-neutral, exactly-once command replay — [Idempotent] over a single-transaction, unique-constrained key store, with the key committed atomically with the operation.
Idempotency in Elarion makes a command handler safe to retry: a client that times out and re-sends, or a
duplicate that arrives concurrently, executes the operation at most once and gets back the first request's
result. You annotate the handler with [Idempotent]; a generated decorator owns a single database transaction
in which it writes the idempotency key atomically with the handler's business writes, lets a unique
constraint reject a duplicate, and replays the stored result — under every transport identically.
It is the same declarative shape as authorization and feature flags: a class-level attribute, a generated decorator, a seam-in-Abstractions / impl-in-package split.
The core guarantee
The key is stored and checked atomically with the operation itself — the safe pattern. A check-then-act approach races: two requests with the same key can both pass the check and both execute. Instead, the decorator inserts the key in the same transaction as the business writes and relies on a database unique constraint to reject the duplicate:
BEGIN
INSERT key ── unique (scope, owner, key), ON CONFLICT DO NOTHING
├─ inserted → run handler (business writes share the tx) → mark completed → COMMIT (atomic)
└─ conflict → key already completed → replay the stored result
crash before COMMIT → nothing persists → the key is retryableBecause the key row and the business rows commit or roll back together, there is no window where one lands without the other, and — since the pending marker is never committed on its own — a crash leaves nothing behind and needs no reaper. This is the model the author of Stripe's reference implementation recommends whenever a handler only mutates local (ACID) database state.
Usage
[Idempotent]
public sealed class CreatePaymentHandler
: IHandler<CreatePaymentCommand, Result<PaymentResponse>> { … }Attribute options (all optional):
| Option | Default | Meaning |
|---|---|---|
RetentionHours | 24 | How long a completed key stays replayable before purge. |
KeyRequired | true | Reject a request with no key (400), rather than run without idempotency. |
Scope | CurrentUser | Key namespace — per authenticated user, or Global. |
Fingerprint | true | Store a request hash and reject reusing the key with a different body (422). |
ConflictBehavior | Conflict | A concurrent in-flight duplicate fails fast with 409, or WaitThenReplay. |
StoreFailures | None | Also store & replay definitive failures (Definitive), via a savepoint. |
When does a command need it?
Default: model the duplicate away with a natural key before reaching for machinery. Most creates in a
CRUD application have a natural identity if you ask "what makes two of these the same?" — often composite:
(TenantId, Email), (CustomerId, ExternalOrderNo), (InvoiceId, LineNo). Declare it as a unique
constraint/index and translate the violation into a friendly AppError.Conflict in the handler (Tier-2
validation: the constraint is the TOCTOU-safe check — a pre-insert lookup alone would race). This is the
default because it deduplicates facts, not just requests: it catches the same client retrying, but also
the second browser tab (which carries a different idempotency key and walks straight past [Idempotent]),
double data-entry by another user, and the import job run twice. It needs no client cooperation and costs
nothing per call.
Escalate to [Idempotent] when you can name the reason — the deviations, ranked by how often they occur:
| Reason to escalate | Example | Why the constraint isn't enough |
|---|---|---|
| No natural key exists — identical submissions are legitimately distinct | "add a note", an order where the same basket twice is a real second order | Only the client knows whether it's a retry; only a client key can dedup. |
| Replay beats rejection — the retrying client should get the created resource, not an error | checkout confirmation after a timeout | A constraint rejects the retry (409, client must re-fetch); [Idempotent] replays the first success. |
| Money / non-repeatable effects | payments, provisioning | The Stripe case — use [Idempotent] here even with a natural key (the key gives replay; the constraint backstops every non-retry duplicate). |
| Increment / accumulate | "add credit", "consume quota" | Deltas never converge; a retry double-applies. (Or redesign to set-style.) |
And the shapes that need neither: set-style updates ("rename", "set address" — last-write-wins
converges), deletes by id (the second delete finds nothing; decide whether NotFound-on-retry is acceptable),
and queries (idempotent by definition; ELIDEM002 warns on non-commands). The default path needs no
justification; every [Idempotent] should be able to say which row of this table it is.
Two things follow from annotating:
- The client must send the key — and reuse it across retries of the same submit. The
Idempotency-Keyheader on HTTP,params._metaon JSON-RPC; minted when the user intends the action (form render, submit click), not per attempt — a key regenerated on every retry deduplicates nothing. The framework cannot invent the key: only the client can distinguish "retry of the same submit" from "a second, deliberate, identical command".[Idempotent]enforces and advertises the contract (the OpenAPI transformer marks the operation); it does not remove the client's half. - Protecting the command protects the whole chain at its source. A replayed command returns its stored result without re-running the handler, so it never re-publishes its integration events — the downstream consumers, PDFs, and emails of a client retry simply never happen twice. Consumers then only need to survive delivery-level duplicates, which the default-on inbox absorbs.
Behavior and status codes
The decorator short-circuits with AppErrors that map to the IETF Idempotency-Key header draft and Stripe:
- Missing key (when
KeyRequired) →400. - Concurrent in-flight duplicate →
409(Conflict mode, the industry default) — the client retries shortly and then replays.WaitThenReplayblocks on the key lock and replays instead — but the wait is still bounded (a longerlock_timeoutthan the fast-fail path), so a stuck winner can never pin the duplicate's database connection forever; on timeout the duplicate degrades to the same409. - Same key, different request body →
422(fingerprint mismatch). - Retried after completion → the stored result is replayed, without re-running the handler.
Success-only by default. A failed result rolls back the transaction — discarding the key — so the same key
stays retryable (a transient failure can succeed on retry). This matches the modern consensus (Stripe v2, AWS
Powertools). Set StoreFailures = Definitive to also store and replay definitive failures (Validation,
BusinessRule, NotFound, Forbidden) via a savepoint that discards the business writes while keeping the key
row — one transaction, no atomicity loss; transient failures stay retryable.
Transport-neutral — one layer, every transport
Idempotency is a pipeline decorator over IHandler<,>, the one code path every transport dispatches into, so
HTTP idempotency keys and the messaging inbox pattern are the same mechanism, differing only in where the
key comes from. The key is captured at the boundary into the dispatch-scope rail (like the current user):
- HTTP — the
Idempotency-Keyheader (or legacyX-Idempotency-Key), viaapp.UseElarionIdempotencyKey(). - JSON-RPC / MCP —
params._meta["dev.wimmesberger.elarion/idempotencyKey"](a per-call key, batch-correct); the HTTP header is accepted as single-call sugar only for a single (non-batch) request. The header applies to the whole HTTP request, so it cannot key a batch's distinct operations — a JSON-RPC batch that carries anIdempotency-Keyheader is rejected with400and a JSON-RPCInvalid Request(-32600) error; each batch item must carry its own key atparams._metainstead. - In-band — a request implementing
IIdempotentRequestcarries its own key (AIP-155 style) — the natural source for event consumers.
Generated TypeScript client
The JSON-RPC schema export marks each [Idempotent] operation with "idempotent": true, and the generated
TypeScript client attaches an idempotency key by default to exactly those operations — a
crypto.randomUUID() at params._meta, so a retry is deduplicated server-side. It only keys operations the
server actually honors (queries never get a key), and it is fully overridable:
createRpcApi({
fetch,
idempotency: { enabled: true, generateKey: () => crypto.randomUUID() }, // on by default
})
rpc.clients.create(params) // auto key on an idempotent op
rpc.clients.create(params, { idempotencyKey: 'k-1' }) // supply your own (see retry note)
rpc.clients.create(params, { idempotencyKey: false }) // opt this call outRetry lives above the client, and the retry layer owns key stability. The generated client attaches keys but
does not retry — that belongs to your data layer (TanStack Query's retry, a fetch middleware, …). For an
idempotent retry to work, every attempt must reuse the same key, so generate it once at the operation boundary
and pass it in (a key auto-generated per call() would change on each retry and defeat the purpose):
const key = crypto.randomUUID()
useMutation({
retry: 3, // TanStack owns retry
mutationFn: (vars) => rpc.clients.create(vars, { idempotencyKey: key }),
})Multi-node concurrency — no distributed lock
When two application nodes process the same key at once, they contend on the same PostgreSQL row, so the
database itself is the cross-node serialization point — no Redis/Redlock. The unique constraint on
(operation, scope, owner, key) makes one INSERT win; the other blocks on the uncommitted index entry and then
either replays (the winner committed) or proceeds (the winner rolled back). This is exactly how MassTransit,
NServiceBus, Stripe, and AWS handle it. A short lock_timeout (Npgsql) turns the wait into a fast 409 for the
Conflict behavior.
The key is discriminated by operation (the handler's request type) as well as scope/owner/key, so two
different [Idempotent] handlers that happen to receive the same client-supplied key never collide on one
record — the second operation claims its own row rather than replaying the first operation's stored response.
Wiring
Core ships an in-memory store for dev/test; the durable, atomic guarantee comes from the EF Core store:
// Register the durable store, the EF unit of work, and the retention purge worker.
builder.Services.AddElarionIdempotencyEntityFrameworkCore<AppDbContext>();
// Or tune the purge worker's cadence:
builder.Services.AddElarionIdempotencyEntityFrameworkCore<AppDbContext>(
o => o.PollingInterval = TimeSpan.FromMinutes(30));
// Map the table (or call ApplyElarionIdempotencyKeys in OnModelCreating) and own the migration.
[GenerateDbSets]
[GenerateElarionIdempotencyKeys(SnakeCase = true)]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}
// Capture the HTTP header into the request scope (after authentication).
app.UseElarionIdempotencyKey();Both mapping forms take the same overrides: [GenerateElarionIdempotencyKeys] accepts optional
TableName and Schema properties alongside SnakeCase, and
modelBuilder.ApplyElarionIdempotencyKeys(tableName, schema, snakeCase) mirrors them (defaults
elarion_idempotency_keys, or ElarionIdempotencyKeys with snakeCase: false; the purge index name is
derived from the table name — ix_{table}_purge / IX_{table}_Purge).
PollingInterval (default 1h) is how often the retention purge worker sweeps expired keys; it is
complementary to the per-handler RetentionHours (which decides when a completed key becomes expired).
The [Idempotent] decorator and the framework transaction decorator
(Elarion.Abstractions.Pipeline.TransactionDecorator, for non-idempotent commands) share the one unit-of-work
boundary — the key row commits atomically inside the same transaction as the handler's business writes. See
Persistence & transactions for how handlers, decorators, and
events share that transaction.
Three properties of that shared EF Core unit of work are worth calling out:
- Commit flushes the change tracker. The scope calls
SaveChangesAsyncbefore committing, so a handler that mutated theDbContextbut forgot to save still has its writes persisted atomically with the transaction (a no-op when the handler already saved). - Nested transactional handlers join, not clash. A transactional command that invokes another transactional
command through
IHandlerSenderon the same scope/DbContextdoes not open a second physical transaction (which the provider would reject). The inner scope joins the ambient transaction with a savepoint: its commit releases the savepoint, its rollback rolls back only the inner handler's writes, and the outer scope still owns the real commit. - The commit is uncancellable. Once the handler has returned success, the finalizing commit runs with
CancellationToken.None, so a cancellation racing in between success and commit can never silently roll back a completed command (or leave its idempotency key claimable again).
The core in-memory IIdempotencyStore and the no-op IUnitOfWork (registered by AddElarionIdempotency alone)
are for single-process dev/test only — they are non-durable, non-cross-node, and non-transactional, and each logs
a one-time warning on first use. Register
AddElarionIdempotencyEntityFrameworkCore<AppDbContext> (which wires the durable store and the EF Core unit
of work) for the production guarantees.
The inbox: the same mechanism for event consumers
Command idempotency and the messaging inbox are the same mechanism — "record a unique id in the same
transaction as the effect; skip if it already exists" — differing only in where the id comes from. A command
sources a client Idempotency-Key; an integration-event consumer sources the delivered message id
(IEventContext.MessageId — for the outbox, the row's id, stable across redeliveries).
Because integration delivery is at-least-once, the inbox is on by default for every handler-form consumer:
the generator attaches the same IdempotencyDecorator with a Consumer-scoped policy — owner = the consuming
handler's identity (so each fan-out consumer of one event claims its own row), key = the message id, seeded into
the delivery scope by the outbox dispatcher and the in-memory pump. A redelivered message replays the recorded
success instead of re-running the consumer; a failed consumer rolls its claim back with its writes and retries.
Opt out with [AllowDuplicates] — a positive declaration that redelivery is harmless (naturally idempotent
effect, or the only effect is a call to a keyed downstream), the consumer-side mirror of [AllowAnonymous].
Rows share the idempotency table and purge loop under a "consumer" scope discriminator and expire after
24 h, well above the outbox's maximum retry window. See
Handling duplicates for the full model.
Side effects and cooperative recipients
The single-transaction model protects database state. A non-rollback-able foreign side effect (charging a card, sending mail) does not belong inline — record it as an integration event in the outbox in the same transaction as the key, and let the outbox deliver it after commit. The consumer's own database effect is then deduped by the built-in inbox (above), which leaves exactly one window: the foreign call itself re-runs if the process dies between the call and the consumer's commit. Closing it requires a cooperative recipient service that accepts an idempotency key and deduplicates — pass the delivered message id (the same value the inbox claims) as that key. For email, SendGrid does not accept idempotency keys, but newer providers — Resend, Brevo, MailPace, Bird — do; for payments use a keyed API (Stripe). A duplicate email is usually tolerable; a duplicate charge is not.
Diagnostics
ELIDEM001(error) — an[Idempotent]handler whose response is notResult<T>/Result(the decorator can't synthesize the400/409/422/replay outcomes).ELIDEM002(warning) —[Idempotent]on a non-ICommandhandler (no effect).ELIDEM003(error) — non-positiveRetentionHours.ELIDEM004(warning) — a handler that is both[Idempotent]and[Cacheable].ELINBX001(warning) —[AllowDuplicates]on a handler whose request is not anIIntegrationEvent(no effect).ELIDEMEF001(error) — a context annotated with[GenerateElarionIdempotencyKeys]but not[GenerateDbSets](add[GenerateDbSets]so the keysDbSetand model-configuration seam are generated).
Angular
The signal-first Angular bindings — provideContributions, injectContributions, and a self-owned *extensionSlot structural directive.
Audit trail
Declarative, transport-neutral audit records — who performed which action on which resource, with field-level change capture, success records committed atomically with the business transaction.