تخطَّ إلى المحتوى

API Design Examples

هذا المحتوى غير متوفر بلغتك بعد.

Illustrative only. The API Design page states the rules; this page shows them on the wire.

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…"
}
// 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.

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 (stringenum), 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.

On the wire:

POST /v1/plans HTTP/1.1
Idempotency-Key: 8f14e45f-ea6a-4c9b-9d0e-2b1a7c3d5e6f
Content-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.

[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.

- 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.

👤 المسؤول: Firas Darwish🗓 آخر مراجعة: 2026-07-26

Read this page in English