Oussama SAIDI

0 %
Oussama SAIDI
Consultant .Net
Sharepoint Office 365

  • Résidence:
    France
  • Ville:
    Lille
Arabe
Français
Anglais
.Net
Html/Css/Js
Web Api
Base de données
  • C#,.Net Core, .Net MVC
  • Rest Api
  • Docker
  • GIT,TFS

How to Build an Idempotent API with .NET 9, EF Core 9 and Polly v8

novembre 6, 2025

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 times
  • PUT /users/5 with same data → always same outcome
  • DELETE /users/5 → deleting an already-deleted resource is harmless
  • POST /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

  1. The client includes a unique Idempotency-Key in the request header.
  2. The API calculates a hash of the request body (payload fingerprint).
  3. The API checks if this key exists in storage (DB or cache).
  4. If found, it returns the same stored response instead of re-executing logic.
  5. 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

FeatureDescription
Deterministic behaviorSame request = same result
Crash recoveryIf app restarts mid-request, replayed call returns saved response
No double billingPerfect for payments and transactions
Safe retryIntegrates with Polly or API Gateway retries
ScalableReplace 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

PitfallFix
Reusing the same key with different payloadCompare request hashes; reject with 409
Very long TTLKeep key records short (e.g., 24 h max)
Missing key headerReturn 400 Bad Request early
Non-deterministic logicAvoid timestamps/random outputs when replaying
Memory cache onlyUse distributed cache in multi-instance setups

When to Use Idempotency

Use CaseWhy It Matters
Payment APIsPrevent double charges
Order CheckoutAvoid duplicate orders
Webhook ConsumersExternal systems often resend events
IoT DevicesNetwork drops cause resend
Background jobsRetry 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

ComponentPurpose
.NET 9Minimal API framework
EF Core 9ORM + migrations
SQLiteLightweight data store
Polly v8Retry & resilience
DockerPackaging & deployment
Redis (optional)Distributed cache
GitHub ActionsContinuous 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

Publié dans .Net Core, Api, Asp .Net, C Sharp, c#, Design, Docker, ef core, entity framework core, entity-framework-core, SOLID, Technology, web apiTags: