PouryaKit
Guides

Make a command idempotent

Let a client repeat a request it never got an answer to, and replay the first success instead of running the command again.

What you build

A command a client may send again, after a response it never received, opts in with [Idempotent]. The client gives each request a request id, over HTTP in a header the endpoint requires. Inside the command's Unit of Work the Kit reserves a key for that request, so a repeat replays the first success instead of running the handler again. Request idempotency says what the key holds and what a repeat does.

The key alone decides

The Kit compares no body, so a client gives each distinct request its own key and reuses a key only to repeat one. Only a success is kept: a request that failed recorded nothing, and its repeat runs again.

When to use it

For a write a client may retry after a lost response, such as creating a product. A command that runs only inside another, or a query, never needs it: a nested command joins the outer Operation, and a query ignores the request id.

Steps

Opt the command in

CreateProductCommand.cs
/// <summary>
/// Creates a product; returns its id. A client that repeats a request with the same request id, after a response it
/// never received, gets the first answer instead of a conflict on the id it chose.
/// </summary>
[Idempotent]
internal sealed record CreateProductCommand(Guid ProductId, Guid CategoryId, string Name)
    : Command<Guid>;

Require the key on its endpoint

Call RequireIdempotencyKey() on the Module's write endpoint that sends the command. Every request to it must then carry the key, which becomes the request's id. A repeatable write shows CreateProduct's endpoint and names the header. Another Gateway caller sets the request id on the context it builds.

Add the key table to the Module's schema

The keys live in a table of the Module's own schema, outside its model. Add an empty migration with dotnet ef migrations add IdempotencyKeys and the Module's options, as in Add a migration, then fill it:

20261007120000_IdempotencyKeys.cs
// The keys of Catalog's idempotent commands. The table is not in the model, so it leaves the snapshot unchanged.
[DbContext(typeof(CatalogDbContext))]
[Migration("20261007120000_IdempotencyKeys")]
internal sealed class IdempotencyKeys : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.CreateIdempotencyTable("catalog");

    protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropIdempotencyTable("catalog");
}

Decide how long a key replays

The Host keeps each key for its retention, then removes it, and a later repeat runs as a new request. Choose a retention longer than any client keeps retrying: see Idempotency key retention for the setting and its default.

Test the repeat

Send the request twice with one key, and assert the same status, location and body, one row, and nothing sent the second time:

IdempotencyTests.cs
[Fact]
public async Task A_repeated_idempotency_key_answers_the_first_status_and_body_without_creating_or_sending_anything()
{
    using var client = Fixture.Client("ada", AppFixture.TestTenant);
    var category = Guid.CreateVersion7();
    await Fixture
        .Gateway<CatalogModule>()
        .SendAsync(
            new CreateCategoryCommand(category, "Lamps"),
            TestContext.Current.CancellationToken
        );
    var product = Guid.CreateVersion7();
    var body = new
    {
        productId = product,
        categoryId = category,
        name = "Desk lamp",
    };
    var key = IdempotentRequests.NewKey();

    var (first, _) = await Fixture.WaitForDeliveriesAsync(
        () =>
            client.PostAsJsonAsync(
                "/catalog/products",
                body,
                key,
                TestContext.Current.CancellationToken
            ),
        TestContext.Current.CancellationToken
    );
    var repeat = await Fixture.AssertNothingSentAsync(
        () =>
            client.PostAsJsonAsync(
                "/catalog/products",
                body,
                key,
                TestContext.Current.CancellationToken
            ),
        TestContext.Current.CancellationToken
    );

    Assert.Equal(HttpStatusCode.Created, first.StatusCode);
    Assert.Equal(HttpStatusCode.Created, repeat.StatusCode);
    Assert.Equal($"/catalog/v2/products/{product}", repeat.Headers.Location?.OriginalString);
    Assert.Equal(first.Headers.Location, repeat.Headers.Location);
    Assert.Equal(
        $$"""{"id":"{{product}}"}""",
        await repeat.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)
    );
    Assert.Equal(
        await first.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
        await repeat.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)
    );
    Assert.Equal(1L, await Fixture.ScalarAsync<long>("select count(*) from catalog.products"));
}

What the Kit guarantees

GuaranteeStated in
What the key is, what a repeat replays, and what a concurrent repeat doesRequest idempotency
The header's rule, and what a repeat answers over HTTPA repeatable write
The key table and its migrationMigrations
How long a key replaysIdempotency key retention

Failure messages

Next steps

On this page