API Versioning & REST Contracts
Key Points
- Pick a versioning strategy early: URL segment (
/v1/orders), query string (?api-version=1.0), header (api-version: 1.0), or media-type (application/vnd.myapi.v1+json). URL-segment is most operator-friendly. - Use
Asp.Versioning(the official .NET API versioning library) over hand-rolling. Handles all four schemes plus deprecation policies. - Idempotency keys for non-idempotent operations (POST that creates state). Caller passes
Idempotency-Key; server stores and dedupes. ETag+If-Matchfor optimistic concurrency. Caller GETs, getsETag; subsequent PUT includesIf-Match: <etag>.- RFC 9457 ProblemDetails is the standard error response format. ASP.NET Core has built-in support via
AddProblemDetails. - Deprecation:
SunsetandDeprecationheaders signal end-of-life dates to clients. Couple with version-discovery responses.
Concepts (deep dive)
Versioning schemes
| Scheme | Example | Pros | Cons |
|---|---|---|---|
| URL segment | /v1/orders | Visible; cache-friendly; routing-easy | URL changes break links |
| Query string | /orders?api-version=1.0 | URLs stable | Easy to forget |
| Header | api-version: 1.0 | Clean URLs | Hidden; harder ops |
| Media type | Accept: application/vnd.myapi.v1+json | RESTful purist | Tooling overhead |
Pragmatic recommendation: URL segment for public-facing APIs (debuggable, log-friendly), header for internal microservices (clean URLs).
Asp.Versioning setup
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true; // adds api-supported-versions / api-deprecated-versions headers
o.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("api-version"),
new QueryStringApiVersionReader("api-version"));
}).AddApiExplorer(o =>
{
o.GroupNameFormat = "'v'VVV";
o.SubstituteApiVersionInUrl = true;
});
Versioning Minimal APIs
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
var v1 = app.MapGroup("/v{version:apiVersion}")
.WithApiVersionSet(versionSet)
.HasApiVersion(1, 0);
v1.MapGet("/orders", () => /* v1 logic */);
var v2 = app.MapGroup("/v{version:apiVersion}")
.WithApiVersionSet(versionSet)
.HasApiVersion(2, 0);
v2.MapGet("/orders", () => /* v2 logic */);
Versioning controllers
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet, MapToApiVersion("1.0")]
public IActionResult GetV1() => Ok(/* ... */);
[HttpGet, MapToApiVersion("2.0")]
public IActionResult GetV2() => Ok(/* ... */);
}
Idempotency keys
For POST/PATCH that creates or modifies state, support an Idempotency-Key header:
app.MapPost("/orders", async (CreateOrderRequest req, HttpContext ctx, IIdempotencyStore store) =>
{
var key = ctx.Request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrEmpty(key))
return Results.BadRequest("Idempotency-Key header required");
var existing = await store.GetAsync(key);
if (existing is not null) return Results.Json(existing.Response,
statusCode: existing.StatusCode);
var order = await CreateAsync(req);
await store.SetAsync(key, order, statusCode: 201, ttl: TimeSpan.FromHours(24));
return Results.Created($"/orders/{order.Id}", order);
});
The store keeps (key, response, status) for some TTL. Repeats return the original response. Client retries are safe.
💡 Idempotency keys are typically client-generated UUIDs. Document max length and retention.
ETag + If-Match (optimistic concurrency)
app.MapGet("/orders/{id:int}", async (int id, IRepo r, HttpContext ctx) =>
{
var order = await r.GetAsync(id);
if (order is null) return Results.NotFound();
var etag = $"\"{order.Version}\"";
ctx.Response.Headers.ETag = etag;
return Results.Ok(order);
});
app.MapPut("/orders/{id:int}", async (int id, UpdateRequest req, IRepo r, HttpContext ctx) =>
{
var ifMatch = ctx.Request.Headers["If-Match"].ToString().Trim('"');
var order = await r.GetAsync(id);
if (order is null) return Results.NotFound();
if (order.Version.ToString() != ifMatch) return Results.StatusCode(412); // Precondition Failed
/* update */
return Results.NoContent();
});
Server includes ETag on GET; client sends If-Match on PUT/PATCH. If versions don't match → 412 Precondition Failed — client refetches and retries with the new version.
ProblemDetails (RFC 7807 / 9457)
builder.Services.AddProblemDetails();
app.UseExceptionHandler(); // produces ProblemDetails for unhandled exceptions
app.UseStatusCodePages(); // ProblemDetails for non-success without bodies
Standard error shape:
{
"type": "https://example.com/probs/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "Order 12345 does not exist.",
"instance": "/orders/12345",
"traceId": "00-abc123..."
}
Returning manually:
return Results.Problem(detail: "...", statusCode: 422, type: "https://example.com/probs/validation");
See ProblemDetails & Error Handling for the full treatment.
Deprecation signals
Asp.Versioning adds api-deprecated-versions: 1.0 header to responses. For finer control, set:
ctx.Response.Headers.Append("Deprecation", "Wed, 11 Nov 2026 23:59:59 GMT");
ctx.Response.Headers.Append("Sunset", "Wed, 11 Feb 2027 23:59:59 GMT");
ctx.Response.Headers.Append("Link", "</docs/migration-v2>; rel=\"deprecation\"");
REST conventions / status codes
| Code | Meaning |
|---|---|
| 200 OK | Success with body |
| 201 Created | Resource created; include Location header |
| 202 Accepted | Async; include polling URL |
| 204 No Content | Success, no body |
| 400 Bad Request | Client error in request |
| 401 Unauthorized | Auth failed |
| 403 Forbidden | Auth ok, no permission |
| 404 Not Found | Resource doesn't exist |
| 409 Conflict | Idempotent retry conflict |
| 412 Precondition Failed | If-Match mismatch |
| 422 Unprocessable Entity | Semantic validation failure |
| 429 Too Many Requests | Rate limited |
| 500 Internal Server Error | Bug |
| 503 Service Unavailable | Temporary; include Retry-After |
Pagination
GET /orders?cursor=eyJpZCI6MTAwfQ&limit=50
200 OK
{
"items": [...],
"next": "/orders?cursor=eyJpZCI6MTUwfQ&limit=50",
"prev": "/orders?cursor=eyJpZCI6NTB9&limit=50"
}
Cursor-based pagination scales with arbitrary data sizes; offset-based degrades on later pages. Encode cursor opaquely (Base64-encoded JSON of the relevant key).
Richardson Maturity Model — REST levels 0–3
A useful ladder for judging "how RESTful is this API really?" Most APIs that call themselves REST are actually Level 2 — and that's fine. Senior point: aim for Level 2 + good versioning + ProblemDetails. Don't chase Level 3.
Level 3 Hypermedia / HATEOAS <-- theoretical pinnacle
^ (almost no service-to-service APIs do this)
|
Level 2 HTTP verbs + status codes <-- where modern APIs land
^ GET/POST/PUT/PATCH/DELETE used semantically
| 201 Created, 204 No Content, 409 Conflict, etc.
Level 1 Resources <-- 90% of "REST" APIs in the wild
^ meaningful URLs, often single verb (POST /orders/create)
|
Level 0 The Swamp of POX <-- single endpoint, single verb, payload-driven
(think SOAP-over-HTTP: POST /api with action in body)
| Level | What you get | Example |
|---|---|---|
| 0 | One URL, one verb, RPC-in-disguise | POST /api { "action": "GetOrder", "id": 1 } |
| 1 | Resource URLs, still verb-poor | POST /orders/1/cancel |
| 2 | Verbs + status codes used correctly | DELETE /orders/1 → 204 No Content |
| 3 | Responses include links to next valid actions | { "id": 1, "_links": { "cancel": "/orders/1/cancel" } } |
- Level 0 — "POX" = Plain Old XML (or JSON now). Single endpoint, single verb, behavior in the payload. SOAP and most pre-2010 enterprise integrations live here.
- Level 1 — meaningful resource URLs, but the verbs are smuggled into the path (
POST /orders/create,POST /orders/cancel). Easy to ship, easy to grow into a mess. - Level 2 — HTTP verbs + status codes used semantically.
GETis safe + idempotent,PUTis idempotent,POSTcreates,DELETEremoves.201 CreatedincludesLocation,409 Conflictfor idempotency clashes,412 Precondition Failedfor ETag mismatches. Most modern .NET APIs land here. - Level 3 — HATEOAS: the response embeds links the client follows to discover next actions. Theoretically powerful (the API drives the client), but tooling is poor for service-to-service and clients almost always hard-code URLs anyway. 💡 Where hypermedia does shine in 2026: HTML responses for UIs — see HTMX with ASP.NET Core. HTMX brings Level-3 ergonomics to server-rendered apps without forcing it on JSON APIs.
⚠️ Don't over-index on the model. It's a rhetorical tool, not a checklist. "We're at Level 2" is a fine answer in interviews; nobody seriously builds Level 3 JSON APIs.
Code: correct vs wrong
❌ Wrong: breaking changes without versioning
// v1 of the API:
public record OrderDto(int Id, string Status);
// "v2" change drops Status:
public record OrderDto(int Id); // ❌ existing v1 clients break
✅ Correct: separate versions
public record OrderDtoV1(int Id, string Status);
public record OrderDtoV2(int Id, OrderState State);
// Both endpoints live; deprecation policy retires v1 over time
❌ Wrong: 200 OK with error in body
✅ Correct: proper status code
❌ Wrong: PUT without If-Match on a concurrent resource
✅ Correct: ETag + 412
See "ETag + If-Match" above.
Design patterns for this topic
Pattern 1 — "URL-segment versioning for public APIs"
- Intent: visible in logs and URLs.
Pattern 2 — "Idempotency-Key header for POST"
- Intent: safe retries.
Pattern 3 — "ETag/If-Match for state mutations"
- Intent: optimistic concurrency.
Pattern 4 — "ProblemDetails for all errors"
- Intent: standardized error shape.
Pattern 5 — "Cursor-based pagination"
- Intent: scales arbitrarily.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| URL versioning | Visible | URL churn |
| Header versioning | Clean URLs | Hidden |
| Idempotency keys | Safe retries | Server-side store cost |
| ETag concurrency | Predictable | Client must support |
| Cursor pagination | Scales | Stateless cursor design |
| ProblemDetails | Standard | New tooling |
When to use / when to avoid
- Always version APIs from day one.
- Always support idempotency for state-modifying POSTs.
- Always use ProblemDetails for errors.
- Avoid breaking changes within a major version — only between.
- Avoid offset pagination for large datasets.
Interview Q&A
Q1. What versioning schemes does Asp.Versioning support? URL segment, query string, header, media-type, or any combination via ApiVersionReader.Combine.
Q2. When do you increment the major version of an API? On any breaking change to public contract: removing fields, renaming, changing types, changing semantics.
Q3. What's an idempotency key? A client-generated unique identifier (typically UUID) sent on a non-idempotent request. Server stores (key → response); subsequent retries with same key return original result.
Q4. How does ETag concurrency work? GET returns ETag: "v1". Client edits and PUTs with If-Match: "v1". If server's current version is still v1, accept; else 412 Precondition Failed.
Q5. Difference between 422 and 400? 400: malformed request (bad JSON, missing required field). 422: well-formed but semantically invalid (business rule violation).
Q6. Why prefer cursor-based over offset-based pagination? Offset degrades: SQL OFFSET 100000 LIMIT 50 scans 100k rows. Cursor: SQL WHERE id > 100050 LIMIT 50 uses index. Also cursor pagination is stable across inserts.
Q7. What's the Sunset HTTP header? Signals end-of-life date for an API endpoint. Combined with Deprecation and Link, gives clients fair warning to migrate.
Q8. How do you discover which versions an API supports? Asp.Versioning adds api-supported-versions: 1.0, 2.0 and api-deprecated-versions: 1.0 response headers when ReportApiVersions = true. Or expose a versions-discovery endpoint.
Q9. Should you version internal microservice APIs? Yes, but the friction can be lower (header versioning + small team coordinating deprecations). Don't skip versioning entirely.
Q10. What's the standard error response shape in modern .NET? RFC 9457 ProblemDetails: type, title, status, detail, instance, plus extensions like traceId, errors. ASP.NET Core has built-in support via AddProblemDetails().
Gotchas / common mistakes
- ⚠️ Breaking changes mid-version — clients break.
- ⚠️ No idempotency key on POST — retries duplicate.
- ⚠️ 200 OK with error body — lies about success.
- ⚠️ Offset pagination on large data — slows over time.
- ⚠️ Forgetting
Locationheader on 201 — clients can't navigate to new resource. - ⚠️ Mixing version schemes inconsistently — surprising behavior.