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
/// <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:
// 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:
[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
| Guarantee | Stated in |
|---|---|
| What the key is, what a repeat replays, and what a concurrent repeat does | Request idempotency |
| The header's rule, and what a repeat answers over HTTP | A repeatable write |
| The key table and its migration | Migrations |
| How long a key replays | Idempotency key retention |
Failure messages
Next steps
Add an endpoint
Serve a Feature over HTTP with an endpoint beside it that sends its command or query through the Module's Gateway and answers its Result.
Publish and subscribe to an Integration Event
Tell other Modules what happened, with an Integration Event declared in Contracts, published from a Notification Handler and applied in each subscribing Module.