ums

ADR-0063: Idempotency Key Middleware (FIX-06 / RISK-05)

Status: Accepted
Date: 2026-05-24
Decision Owner: Architecture
Evolith disposition: Proposed for Evolith adoption — HTTP-level request deduplication is runtime-neutral; applicable to any ASP.NET Core satellite
Related:


Context

UMS command handlers are state-mutating operations: they create aggregates, update status, publish domain events to the outbox, and interact with external identity providers. A network retry, a double-tap from a mobile client, or a saga compensation step re-issuing a command can execute the same operation twice — creating duplicate tenants, double-charged fees, or conflicting state.

The domain layer enforces business invariants (e.g., “tenant code already exists”), but returning a domain error to the second identical call is not always the right behaviour. The client may expect the same successful response as the first call.

Why middleware over a MediatR behavior

A MediatR IPipelineBehavior runs after deserialization and validation — it requires a persistent idempotency store per handler type. HTTP-level middleware:

  1. Intercepts before deserialization — the response cache can be returned without running the full pipeline
  2. Is handler-agnostic — one registration covers all endpoints
  3. Operates on the HTTP response as a byte buffer — the reply is identical to the original, including headers and status code

Decision

Implement request deduplication as an ASP.NET Core middleware reading the Idempotency-Key header, caching the first response, and replaying it verbatim for duplicate requests.

Behaviour matrix

Scenario Response
No Idempotency-Key header Pass through — key is optional
New key, first request Execute pipeline, cache response (TTL: 24h), return result
Known key, request completed Return cached response immediately — handler NOT invoked
Known key, request in-flight Return HTTP 409 “request already in progress”
Non-mutating method (GET, DELETE) Pass through — naturally idempotent

Covered methods

POST, PUT, PATCH only. GET and DELETE pass through unconditionally.

Cache backend

IMemoryCache (single-node default). For multi-replica deployments, replace with IDistributedCache (Redis or SQL Server) to share state across pods.

Key format

Client-generated UUID (v4), e.g. 550e8400-e29b-41d4-a716-446655440000. The middleware does not generate keys — the client is responsible for generating and retrying with the same key.

TTL

24 hours (configurable via IdempotencyOptions). After TTL expiry, a re-submitted key is treated as a new request.

DI registration

// Program.cs / DependencyInjection
services.AddMemoryCache();      // required for single-node IdempotencyStore
app.UseIdempotency();           // must come after UseCorrelationId, before routing

Middleware pipeline position

UseCorrelationId
  → UseSessionTracking
    → UseGlobalExceptionHandler
      → UseIdempotency          ← here
        → UseRateLimiter
          → Routes

Position after UseGlobalExceptionHandler ensures that exceptions during pipeline execution are caught and not cached. Position before routing ensures the replay occurs before endpoint selection.


Consequences

Positive

Trade-offs


Evolith Extraction Checklist


ADR Registry CP-07 Idempotency