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.
public override async Task HandleAsync(CreatePlanRequest req, CancellationToken ct){ var key = HttpContext.Request.Headers["Idempotency-Key"].ToString();
if (await store.TryGetResultAsync(key, ct) is { } replayed) { await SendAsync(replayed.Body, replayed.StatusCode, ct); // replay, don't reject return; }
var plan = await createPlan.ExecuteAsync(req, ct); await store.SaveResultAsync(key, plan, StatusCodes.Status201Created, ct);
await SendCreatedAtAsync<GetPlanEndpoint>(new { id = plan.Id }, plan, cancellation: ct);}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.
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.