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 ships first-class OpenAPI generation built into Microsoft.AspNetCore.OpenApi (Swashbuckle is no longer the default template) with TypedResultsas the recommended way to make Minimal API endpoints self-documenting on .NET 10. 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 10
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 TypedResults.Problem(
title: "Idempotency-Key header required",
statusCode: 400, type: "https://errors.example.com/missing-idempotency-key");
if (await store.TryGetAsync(key) is { } cached)
return TypedResults.Json(cached.Body, statusCode: cached.Status);
var order = await CreateOrderInDb(body);
await store.SaveAsync(key, 201, order, ttl: TimeSpan.FromHours(24));
return TypedResults.Created($"/orders/{order.Id}", order);
}
// Left as Task<IResult> here because the cached-replay branch needs a dynamic
// status code โ a Results<...> union shines when the branches are fixed (Step 5).Step 3: RFC 9457 Problem Details for errors
// ProblemDetails has been built in since .NET 8; just enable it
builder.Services.AddProblemDetails();
// Throw a structured error
throw new BadHttpRequestException("inventory exhausted");
// Or return one explicitly โ TypedResults.Problem over Results.Problem on .NET 10
return TypedResults.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
// Results<...> + TypedResults (the .NET 10 idiom) instead of Task<IResult> + Results:
// the union return type is the OpenAPI schema, so there's nothing left to drift.
async Task<Results<NotFound, StatusCodeHttpResult, Ok<Order>>> GetOrder(long id, HttpContext ctx)
{
var order = await db.Orders.FindAsync(id);
if (order is null) return TypedResults.NotFound();
var etag = $"\"{order.RowVersion.GetHashCode():X}\"";
if (ctx.Request.Headers.IfNoneMatch == etag)
return TypedResults.StatusCode(304);
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "private, max-age=60";
return TypedResults.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 has had it built in since .NET 8. - 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 since .NET 9). Prefer
TypedResultsoverResultson .NET 10 so the doc can't drift from what the endpoint actually returns. - Don't reinvent. RFC 9457, RFC 7233 (range), RFC 5988 (Link), RFC 6585 (429) cover most needs.

