Introduction
Have you ever hit “Pay” on a checkout page, the page froze, and you clicked again — only to be charged twice?
That’s exactly what idempotency prevents.
In distributed and cloud-native architectures, duplicate requests happen often:
- client-side retries (mobile poor signal),
- API Gateway or load-balancer retries,
- users refreshing a browser form.
An idempotent API guarantees that executing the same request multiple times produces the same result — no side effects, no duplicates, no surprises.
In this detailed tutorial, we’ll build such an API using:
- .NET 9 Minimal APIs (lightweight and fast)
- Entity Framework Core 9 (to persist requests and responses)
- Polly v8 (for safe retry handling)
- SQLite + MemoryCache (for persistence and replay)
- Docker (for containerized deployment)
All source code is available on GitHub
What Is Idempotency?
Definition:
An operation is idempotent if performing it multiple times has the same effect as doing it once.
Example:
GET /users/5→ safe to call 100 timesPUT /users/5with same data → always same outcomeDELETE /users/5→ deleting an already-deleted resource is harmlessPOST /payments→ ⚠️ usually creates duplicates if repeated
So POST operations must implement custom logic to become idempotent.
This is especially important for financial, order, and API-to-API systems.
Solution Overview
Our approach adds a lightweight Idempotency Layer on top of your API.
Architecture Summary
- The client includes a unique
Idempotency-Keyin the request header. - The API calculates a hash of the request body (payload fingerprint).
- The API checks if this key exists in storage (DB or cache).
- If found, it returns the same stored response instead of re-executing logic.
- If not found, it processes normally, saves the response, and returns it.
This pattern ensures the request can be safely retried without side effects.
Project Architecture
Our solution consists of:
- Minimal API (.NET 9) → lightweight entry point
- EF Core 9 + SQLite → persistent storage for payments & keys
- Idempotency Store → saves the request hash + response
- Memory Cache → fast replay for repeated keys
- Polly v8 → retry policy for resilient HTTP clients
- Docker & GitHub Actions → deployment + CI
OS.Tuto.IdempotentApi/ ├─ Program.cs ├─ Data/ │ └─ AppDbContext.cs ├─ Domain/ │ ├─ Payment.cs │ └─ PaymentStatus.cs ├─ Dtos/ │ ├─ PaymentRequest.cs │ └─ PaymentResponse.cs ├─ Idempotency/ │ ├─ IdempotencyRecord.cs │ └─ IdempotencyStore.cs ├─ Client/ │ └─ Program.cs ├─ Dockerfile ├─ docker-compose.yml ├─ appsettings.json └─ README.md
⚙️ Step 1 — Project Setup (.NET 9 Minimal API)
In your terminal:
dotnet new web -n OS.Tuto.IdempotentApi cd OS.Tuto.IdempotentApi dotnet add package Microsoft.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.Extensions.Http.Resilience
This initializes a clean .NET 9 Minimal API project.
⚙️ Step 2 — Add Database Context and Entities
We’ll persist payments and idempotent requests in SQLite.
Example: AppDbContext
public class AppDbContext : DbContext
{
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
Payment Entity (simplified)
public class Payment
{
public Guid Id { get; set; } = Guid.NewGuid();
public decimal Amount { get; set; }
public string Currency { get; set; } = "EUR";
public string Recipient { get; set; } = default!;
public string IdempotencyKey { get; set; } = default!;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
Each payment stores the key that triggered it — ensuring the same key cannot produce another row.
⚙️ Step 3 — Implement the Idempotency Store
The store is the heart of the system.
It remembers which keys were processed and what the API returned.
Simplified Logic
public class IdempotencyStore
{
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
public IdempotencyStore(AppDbContext db, IMemoryCache cache)
{ _db = db; _cache = cache; }
public async Task<(bool found, string response)> TryGetAsync(string key)
{
if (_cache.TryGetValue(key, out string body))
return (true, body);
var rec = await _db.IdempotencyRecords.FirstOrDefaultAsync(x => x.Key == key);
return rec is null ? (false, "") : (true, rec.ResponseBody);
}
public async Task SaveAsync(string key, object response)
{
var json = JsonSerializer.Serialize(response);
_db.IdempotencyRecords.Add(new() { Key = key, ResponseBody = json });
await _db.SaveChangesAsync();
_cache.Set(key, json);
}
}
Each processed request is cached in-memory and persisted in the database for replay.
⚙️ Step 4 — Make the POST Endpoint Idempotent
Let’s modify our /payments endpoint to use Idempotency-Key.
app.MapPost("/payments", async (PaymentRequest req, HttpContext ctx, AppDbContext db, IdempotencyStore store) =>
{
var key = ctx.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(key)) return Results.BadRequest("Missing Idempotency-Key");
var (found, body) = await store.TryGetAsync(key);
if (found) return Results.Content(body, "application/json");
var payment = new Payment { Amount = req.Amount, Currency = req.Currency, Recipient = req.Recipient, IdempotencyKey = key };
db.Payments.Add(payment);
await db.SaveChangesAsync();
var response = new { payment.Id, payment.Amount, payment.Recipient };
await store.SaveAsync(key, response);
return Results.Ok(response);
});
✅ First call: creates and stores payment.
✅ Repeated call with same key: returns same JSON response instantly.
⚠️ Same key + different body: can be handled as conflict (optional check).
⚙️ Step 5 — Testing with Curl or Postman
Create a payment
curl -s http://localhost:5000/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-123" \
-d '{"amount": 99.99, "currency": "EUR", "recipient": "alice@example.com"}'
Re-run same request
→ Returns same response — no duplicate row.
Change payload, keep same key
→ Returns 409 Conflict (optional validation step).
⚙️ Step 6 — Safe Retry Using Polly v8
When clients or background workers call your API, transient failures can happen.
By combining Polly with our idempotent endpoint, retries become 100% safe.
Simple retry setup
builder.Services.AddHttpClient("PaymentsClient")
.AddResilienceHandler("retry", rb =>
rb.AddRetry(new() { MaxRetryAttempts = 3, Delay = TimeSpan.FromSeconds(2) }));
Each retry uses the same Idempotency-Key.
Even if the client loses connection mid-response, the next call retrieves the same result.
⚙️Step 7 — Dockerize the API
Create a Dockerfile for container deployme
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /src COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=build /app . EXPOSE 8080 ENTRYPOINT ["dotnet", "OS.Tuto.IdempotentApi.dll"]
Then run:
docker build -t idempotent-api . docker run -p 8080:8080 idempotent-api
Access it on http://localhost:8080/payments
⚙️Step 8 — Continuous Integration (GitHub Actions)
GitHub Actions ensure the project builds and tests automatically.
name: dotnet-build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- run: dotnet restore
- run: dotnet build -c Release
Now each commit validates your solution.
Why This Approach Works
| Feature | Description |
|---|---|
| Deterministic behavior | Same request = same result |
| Crash recovery | If app restarts mid-request, replayed call returns saved response |
| No double billing | Perfect for payments and transactions |
| Safe retry | Integrates with Polly or API Gateway retries |
| Scalable | Replace IMemoryCache with Redis for distributed caching |
Going Distributed (Redis Option)
In production, multiple API instances may run behind a load balancer.
To make idempotency global, switch caching to Redis:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = "localhost:6379");
Common Pitfalls and Best Practices
| Pitfall | Fix |
|---|---|
| Reusing the same key with different payload | Compare request hashes; reject with 409 |
| Very long TTL | Keep key records short (e.g., 24 h max) |
| Missing key header | Return 400 Bad Request early |
| Non-deterministic logic | Avoid timestamps/random outputs when replaying |
| Memory cache only | Use distributed cache in multi-instance setups |
When to Use Idempotency
| Use Case | Why It Matters |
|---|---|
| Payment APIs | Prevent double charges |
| Order Checkout | Avoid duplicate orders |
| Webhook Consumers | External systems often resend events |
| IoT Devices | Network drops cause resend |
| Background jobs | Retry logic needs determinism |
Simplified Flow Diagram
Client ──► API with Idempotency-Key
│
├─ Key not found → Execute business logic
│ Store response in DB + cache
└─ Key found → Return stored response

Every retry simply reads the stored result — simple, predictable, safe.
Performance Notes
- First execution = database + compute
- Next executions = cache only → ⚡ fast
- MemoryCache TTL = 5 min (default)
- Redis or SQL backend recommended for multi-region deployments
Technologies Used
| Component | Purpose |
|---|---|
| .NET 9 | Minimal API framework |
| EF Core 9 | ORM + migrations |
| SQLite | Lightweight data store |
| Polly v8 | Retry & resilience |
| Docker | Packaging & deployment |
| Redis (optional) | Distributed cache |
| GitHub Actions | Continuous integration |
Full Source Code
All source code, configuration files, and Docker setup are publicly available on GitHub
Repository: https://github.com/oussama-saidi/os-tuto-net-core-rest-api-Idempotent
The repo includes:
- Complete .NET 9 solution
- EF Core migrations
- Dockerfile + docker-compose
- CI/CD workflow
- Example client with Polly retry logic
Key Takeaways
✅ Idempotency = safe retries and predictable APIs
✅ Protects against duplicate operations
✅ Easy to implement using .NET 9 and EF Core 9
✅ Supports resilience with Polly v8
✅ Ready for Docker & GitHub Actions deployment
Conclusion
Idempotency isn’t just a nice-to-have — it’s a core principle of modern API design.
Whether you’re handling payments, notifications, or background tasks, implementing idempotency ensures your system behaves consistently, even under retries or network issues.
With .NET 9, EF Core 9, and Polly v8, achieving this pattern is simple, elegant, and production-ready.
You can explore and clone the full working example from GitHub:
https://github.com/oussama-saidi/os-tuto-net-core-rest-api-Idempotent