Problem Context
REST is 25 years old and still the default for "HTTP API" โ but most APIs called "REST" are really "HTTP+JSON with verbs." In 2026 the bar has moved: HTTP/3 is mainstream, RFC 9457 (Problem Details, March 2023) replaced RFC 7807, and .NET 9 ships first-class OpenAPI generation built into Microsoft.AspNetCore.OpenApi(Swashbuckle is no longer the default template). The question isn't "is it RESTful?" โ it's "does it have stable contracts, idempotent writes, machine-readable errors, and pagination that doesn't fall over at 10M rows?"
- Your
POSTendpoint sometimes creates two rows because a client retried - Your error responses are HTML in some cases and JSON in others
- You return
200 OKwith{"success": false} - Your pagination breaks when someone inserts a row mid-scan
Use proper status codes, RFC 9457 problem details, idempotency keys, and cursor pagination.
Concept Explanation
The five constraints that actually matter in practice (the academic six minus "code on demand"):
- Resource-oriented URLs โ nouns, not verbs.
/orders/123/itemsnot/getOrderItems?id=123. - Standard HTTP methods โ GET (safe + idempotent), PUT/DELETE (idempotent), POST (neither), PATCH (partial).
- Status codes that mean what they say โ 200/201/204/400/401/403/404/409/422/429/500/503.
- Stateless โ every request carries auth; no server session.
- Cacheable โ
ETag+Cache-Control+ conditional requests.
flowchart LR
C["Client"] -->|POST /orders<br/>Idempotency-Key| API["API"]
API -->|201 Created<br/>Location: /orders/123| C
C -->|GET /orders/123<br/>If-None-Match: etag| API
API -->|304 Not Modified| C
style API fill:#0078D4,color:#fff,stroke:#005a9e
Implementation
Step 1: Resource-oriented routing in ASP.NET Core 9
var app = WebApplication.CreateBuilder(args).Build();
var orders = app.MapGroup("/orders").WithTags("Orders");
orders.MapGet("/", ListOrders); // 200 + array
orders.MapGet("/{id:long}", GetOrder); // 200 / 404
orders.MapPost("/", CreateOrder); // 201 + Location
orders.MapPut("/{id:long}", ReplaceOrder); // 200 / 204
orders.MapPatch("/{id:long}", PatchOrder); // 200 + JSON Patch (RFC 6902)
orders.MapDelete("/{id:long}", DeleteOrder); // 204
orders.MapGet("/{id:long}/items", ListOrderItems); // sub-resource
app.Run();Step 2: Idempotency keys for safe POST retries
// Stripe-style idempotency: client supplies a UUID; server stores response 24h
async Task<IResult> CreateOrder(
HttpContext ctx, OrderRequest body, IIdempotencyStore store)
{
var key = ctx.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(key))
return Results.Problem(
title: "Idempotency-Key header required",
statusCode: 400, type: "https://errors.example.com/missing-idempotency-key");
if (await store.TryGetAsync(key) is { } cached)
return Results.Json(cached.Body, statusCode: cached.Status);
var order = await CreateOrderInDb(body);
await store.SaveAsync(key, 201, order, ttl: TimeSpan.FromHours(24));
return Results.Created($"/orders/{order.Id}", order);
}Step 3: RFC 9457 Problem Details for errors
// .NET 9 ships ProblemDetails by default; just enable it
builder.Services.AddProblemDetails();
// Throw a structured error
throw new BadHttpRequestException("inventory exhausted");
// Or return one explicitly
return Results.Problem(
type: "https://errors.example.com/inventory/exhausted",
title: "Inventory exhausted",
detail: "SKU widget-42 has 0 units available",
instance: $"/orders/{id}",
statusCode: 409,
extensions: new Dictionary<string, object?> { ["sku"] = "widget-42" });
// Wire response:
// Content-Type: application/problem+json
// { "type": "...", "title": "...", "status": 409, "detail": "...",
// "instance": "/orders/123", "sku": "widget-42" }Step 4: Cursor pagination (not OFFSET)
// Keyset / cursor pagination โ stable under inserts, O(log n) lookups
public record OrderPage(IReadOnlyList<Order> Items, string? NextCursor);
async Task<OrderPage> ListOrders(string? cursor, int limit = 50)
{
limit = Math.Clamp(limit, 1, 200);
var afterId = cursor is null ? long.MaxValue : DecodeCursor(cursor);
var items = await db.Orders
.Where(o => o.Id < afterId)
.OrderByDescending(o => o.Id)
.Take(limit + 1)
.ToListAsync();
string? next = items.Count > limit ? EncodeCursor(items[^1].Id) : null;
return new OrderPage(items.Take(limit).ToList(), next);
}
// Response: { items: [...], nextCursor: "eyJpZCI6MTIzfQ==" }Step 5: ETags + conditional requests
async Task<IResult> GetOrder(long id, HttpContext ctx)
{
var order = await db.Orders.FindAsync(id);
if (order is null) return Results.NotFound();
var etag = $"\"{order.RowVersion.GetHashCode():X}\"";
if (ctx.Request.Headers.IfNoneMatch == etag)
return Results.StatusCode(304);
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "private, max-age=60";
return Results.Ok(order);
}Step 6: Versioning at the URL or media type
// URL versioning โ easiest to debug, hardest to evolve
app.MapGroup("/v1/orders")...
app.MapGroup("/v2/orders")...
// Accept-header versioning โ cleaner but harder to test in a browser
// Accept: application/vnd.example.order+json; version=2
// See the API Versioning article for the full breakdown.Common Pitfalls
- Returning 200 for errors.
200 OK+{success: false}defeats every HTTP caching and monitoring tool. Use 4xx/5xx with problem+json. - Non-idempotent POST without idempotency keys. Mobile clients retry. You will get duplicate orders. Stripe-style
Idempotency-Keyis the cheapest fix. - OFFSET pagination on big tables.
OFFSET 1_000_000reads a million rows to skip them. Use cursor pagination keyed on (timestamp, id). - Verbs in URLs.
POST /createOrderis RPC, not REST. Pick one โ both is the worst of both. - No
Locationheader on 201. Clients shouldn't have to construct the URL of the resource they just created. - Different error shapes per endpoint. Pick one (RFC 9457 problem details), enforce it via middleware, document it once.
Practical Takeaways
- Resource URLs (nouns), correct HTTP verbs, correct status codes โ get these three right and you're 80% there.
- RFC 9457
application/problem+jsonis the 2026 standard for errors. .NET 9 has it built in. - Always require an
Idempotency-Keyon POST endpoints that create or charge. - Cursor pagination, not OFFSET. Always.
- ETags + 304 cut bandwidth dramatically for hot reads.
- Generate the OpenAPI doc from the code (built-in to .NET 9). The doc is the contract.
- Don't reinvent. RFC 9457, RFC 7233 (range), RFC 5988 (Link), RFC 6585 (429) cover most needs.

