// Resilience & Messaging · 01
Idempotency
Doing it twice has the same effect as doing it once.
How it fits together
- Client uses / calls Payments service (1 POST k1)
- Client uses / calls Payments service (2 repeat k1)
- Payments service uses / calls Idempotency store (3 lookup key)
- Payments service creates Payment gateway (4 charge once)
- Payments service creates Idempotency store (5 save result)
Spot it
- Duplicate orders after a consumer restart
- Double charges when a user clicks twice
- Client retries create extra rows
It costs
A lookup and a write per request, plus a retention window to manage.
In code
public sealed record PaymentRequest(string IdempotencyKey, string RequestHash, decimal Amount);public sealed class PaymentService(IKeyStore keys, IPaymentGateway gateway, IDatabase db){ public async Task<PaymentResult> PayAsync(PaymentRequest request, CancellationToken ct) { var stored = await keys.FindAsync(request.IdempotencyKey, ct); if (stored is not null) { return stored.RequestHash == request.RequestHash ? stored.Result : throw new IdempotencyKeyReusedException(request.IdempotencyKey); } // The unique index on IdempotencyKey decides a race between two identical requests. await using var tx = await db.BeginTransactionAsync(ct); var charge = await gateway.ChargeAsync(request.Amount, request.IdempotencyKey, ct); var result = new PaymentResult(charge.Id, charge.Status); var record = new IdempotencyRecord(request.IdempotencyKey, request.RequestHash, result); await keys.InsertAsync(record, ct); await tx.CommitAsync(ct); return result; }}Go deeperThe full explanation and what to ask for · 1 min readExpandCollapse
Networks and brokers deliver at least once, so duplicates are normal, not exotic. Exactly-once processing is built from at-least-once delivery plus an idempotent receiver.
The classic mistake is check-then-act: read "seen this key?", then write. Two concurrent copies both pass the check. Let a unique constraint decide, and record the key in the same transaction as the side effect, or a crash between the two breaks the guarantee.
Some operations are naturally idempotent (set status to Shipped); others are not (add 10 to the balance). Idempotency is what makes retries, outbox relays and redelivered queue messages safe at all.
Ask for it
- idempotency key
- at-least-once delivery
- processed-messages (inbox) table
- unique constraint
- natural idempotency
Make POST /payments idempotent: require an Idempotency-Key header, store key, request hash and response for 24h under a unique index, and replay the stored response on a repeat. In the message consumer, record the message id in a processed-messages table in the same transaction.