Skip to content

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.

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.

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

👤 Owner: Firas Darwish🗓 Last reviewed: 2026-07-25

اقرأ هذه الصفحة بالعربية