API Design Examples
هذا المحتوى غير متوفر بلغتك بعد.
Illustrative only. The API Design page states the rules; this page shows them on the wire.
1. One error body, three failures
Section titled “1. One error body, three failures”422 — validation:
{ "type": "https://datatracker.ietf.org/doc/html/rfc9457", "title": "One or more validation errors occurred.", "status": 422, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "errors": { "latitude": ["must be between -90 and 90"] }}409 — domain conflict. errorCode is the stable field a client may branch on:
{ "title": "The branch already has an active plan for this week.", "status": 409, "traceId": "00-4bf92f35…", "errorCode": "PLAN_ALREADY_ACTIVE"}500 — unhandled. Fixed title, no detail, and the traceId is how support finds the rest:
{ "title": "An unexpected error occurred.", "status": 500, "traceId": "00-4bf92f35…"}2. What a 500 must never return
Section titled “2. What a 500 must never return”// Never. Every line of this is a gift to an attacker and a contract// no client can code against, because the string changes with the stack trace.{ "status": 500, "reason": "Object reference not set to an instance of an object. ---> " + "Microsoft.Data.SqlClient.SqlException: Login failed for user " + "'sos_app' on server 'sql-prod-01.internal'. ---> ..."}The client learns the database server name, the service account, and the ORM. The developer learns nothing they could not have got from the log — where the exception belongs.
3. Versioning a breaking change
Section titled “3. Versioning a breaking change”Renaming name to displayName is breaking. Both versions ship; v1 keeps working for the mobile app already in users’ hands.
// v1 — unchanged, still served.public sealed class GetBranchV1Endpoint : Endpoint<GetBranchRequest, BranchV1Response>{ public override void Configure() { Get("/branches/{id}"); Version(1); }}
// v2 — the new shape.public sealed class GetBranchV2Endpoint : Endpoint<GetBranchRequest, BranchV2Response>{ public override void Configure() { Get("/branches/{id}"); Version(2); }}| Change | Breaking? |
|---|---|
| Add an optional response field | No — ship it in v1 |
| Add an optional request field with a default | No |
| Remove or rename a response field | Yes — new version |
Narrow a type (string → enum), or make an optional request field required |
Yes — new version |
| Change the meaning of an existing field without changing its type | Yes, and the worst kind — no client detects it |
v1 is retired only once the server-enforced minimum client version has moved past every app that calls it.
4. An idempotent POST
Section titled “4. An idempotent POST”On the wire:
POST /v1/plans HTTP/1.1Idempotency-Key: 8f14e45f-ea6a-4c9b-9d0e-2b1a7c3d5e6fContent-Type: application/json
{ "branchId": "b-1", "weekOf": "2026-07-27" }The device times out, the user taps again, the same key arrives. The server replays the stored 201 — same body, same Location. One plan exists.
Four things have to be true for that sentence to hold, and the obvious implementation gets none of them:
| Situation | Response |
|---|---|
| Header missing, or not a well-formed key | 400 — it is a required part of the contract |
| Same key and body, the first request is still running | 409 |
| Same key, different body | 422 |
| Same key and body, the first request finished | Replay the stored status and body |
public override async Task HandleAsync(CreatePlanRequest req, CancellationToken ct){ // A missing header is StringValues.Empty, and its ToString() is "" — so // `Headers["Idempotency-Key"].ToString()` hands every keyless POST the same // key, and the second caller replays the first caller's plan. Validate it. if (!HttpContext.Request.Headers.TryGetValue("Idempotency-Key", out var header) || !Guid.TryParse(header.ToString(), out var key)) { AddError(r => r.BranchId, "A well-formed Idempotency-Key header is required."); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); return; }
// Scope the key to the caller so two tenants cannot collide on one GUID, // and fingerprint the body so a reused key with different content is caught. var scope = User.TenantId(); var fingerprint = Fingerprint.Of(req);
// Claim the key BEFORE any side effect. The insert is the lock: two // concurrent requests race on the unique index, and exactly one wins. switch (await store.ClaimAsync(scope, key, fingerprint, ct)) { case Completed done: await SendAsync(done.Body, done.StatusCode, ct); // replay, don't reject return;
case InFlight: // the winner has not finished await SendErrorsAsync(StatusCodes.Status409Conflict, ct); return;
case FingerprintMismatch: // same key, different body await SendErrorsAsync(StatusCodes.Status422UnprocessableEntity, ct); return; }
// Only the request that won the claim reaches here. The business change and // the stored response commit in ONE transaction: split them and a crash in // between leaves a plan that can never be replayed, so the client's retry // creates a second one. var plan = await createPlan.ExecuteAsync(req, ct); await store.CompleteAsync(scope, key, plan, StatusCodes.Status201Created, ct); await db.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetPlanEndpoint>(new { id = plan.Id }, plan, cancellation: ct);}The claim is only atomic because the database says so. No application-level check-then-act is a substitute:
// One unique index does the work. ClaimAsync inserts; a duplicate-key violation// means someone else won, and the row it collided with tells you which case it is.builder.HasIndex(c => new { c.Scope, c.Key }).IsUnique();Claims expire, and the window is a real decision. Keep a completed claim for at least as long as the longest client retry schedule — 24 hours is a defensible default, and the number belongs in config, not here. After expiry the key is forgotten, so the same key creates a second plan. Sweep expired rows on a schedule, and never sweep one that is still in flight.
Returning 429 on a duplicate key looks defensive but is wrong: the client cannot tell “your retry was ignored, the first one worked” from “you are rate-limited”, so it retries again or shows the user a failure for an operation that succeeded.
The three tests that hold it
Section titled “The three tests that hold it”[Fact]public async Task Twenty_simultaneous_duplicates_create_one_plan(){ var ct = TestContext.Current.CancellationToken; var key = Guid.NewGuid().ToString();
var responses = await Task.WhenAll( Enumerable.Range(0, 20).Select(_ => PostPlanAsync(key, Body, ct)));
responses.Count(r => r.StatusCode == HttpStatusCode.Created).ShouldBe(1); responses.ShouldAllBe(r => r.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict); (await CountPlansAsync(ct)).ShouldBe(1);}
[Fact]public async Task A_crash_after_commit_replays_instead_of_creating_a_second_plan(){ // The response never reached the client, but the transaction committed. var ct = TestContext.Current.CancellationToken; var key = Guid.NewGuid().ToString();
var first = await PostPlanAsync(key, Body, ct); var retry = await PostPlanAsync(key, Body, ct);
retry.StatusCode.ShouldBe(HttpStatusCode.Created); retry.Headers.Location.ShouldBe(first.Headers.Location); // same plan, not a new one (await CountPlansAsync(ct)).ShouldBe(1);}
[Fact]public async Task Reusing_a_key_with_a_different_body_is_422(){ var ct = TestContext.Current.CancellationToken; var key = Guid.NewGuid().ToString();
await PostPlanAsync(key, Body, ct); var reused = await PostPlanAsync(key, Body with { BranchId = "b-2" }, ct);
reused.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); (await CountPlansAsync(ct)).ShouldBe(1);}422 on a fingerprint mismatch and 409 only while the original is in flight follow the IETF Idempotency-Key draft. The distinction matters to the client: 409 means wait and retry the same request; 422 means your request is wrong, retrying will not help.
5. The contract check
Section titled “5. The contract check”- script: | dotnet run --project src/Api -- --generate-openapi > /tmp/openapi.json diff -u api/openapi.json /tmp/openapi.json displayName: 'OpenAPI matches the committed contract'A field rename fails here, in the backend developer’s own PR — not three weeks later in a mobile crash report.
Back to API Design.