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:
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.
A MediatR IPipelineBehavior runs after deserialization and validation — it requires a persistent idempotency store per handler type. HTTP-level middleware:
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.
| 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 |
POST, PUT, PATCH only. GET and DELETE pass through unconditionally.
IMemoryCache (single-node default). For multi-replica deployments, replace with IDistributedCache (Redis or SQL Server) to share state across pods.
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.
24 hours (configurable via IdempotencyOptions). After TTL expiry, a re-submitted key is treated as a new request.
// Program.cs / DependencyInjection
services.AddMemoryCache(); // required for single-node IdempotencyStore
app.UseIdempotency(); // must come after UseCorrelationId, before routing
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.
IDistributedCache in production2xx responses only — error responses are not cached (client should retry on failure with the same key)Idempotency-Key is a request-level concept; it cannot prevent duplicate events if the same command is issued with different keys by a misbehaving clientIdempotencyMiddleware — no UMS-specific import; depends only on IMemoryCache and ASP.NET Core abstractionsIdempotencyOptions — simple POCO with TTL and enabled/disabled flagUseIdempotency() extension method| ADR Registry | CP-07 Idempotency |