REST is an architectural style for networked systems, not a synonym for JSON over HTTP. It constrains interactions around stable resource identities, a uniform interface, and explicit cache/representation behavior so clients, servers, and intermediaries can evolve with fewer assumptions.

REST fits broad interoperability, stable resource contracts, and cacheable reads. GraphQL fits clients that need flexible projections and can support query governance. GRPC fits schema-coupled service calls and streaming between controlled peers.

REST Constraints and Method Semantics

REST is defined by contract boundaries rather than framework syntax:

  • Client-server separation
  • Stateless request handling
  • Cacheable responses
  • Uniform interface and media semantics
  • Layered intermediaries
  • Optional code on demand
PUT /orders/42 HTTP/1.1
Content-Type: application/json
If-Match: "order-v7"
 
{"id":42,"status":"shipped"}

PUT is idempotent and may be retried after a communication failure. With If-Match, however, a successful first attempt can advance the entity tag so the retry returns 412 Precondition Failed. That response does not prove the first attempt failed. Reconcile the current representation before deciding the next action.

Status codes should describe the boundary that actually failed or succeeded:

  • 201 plus Location for creation
  • 409 for state conflict
  • 412 for failed preconditions
  • 422 for syntactically valid content whose instructions the server cannot process
  • 429/503 when retry policy applies

API Design and Compatibility Surface

Resource naming is the external boundary. Framework choice is local.

IntentHTTP contractBoundary rule
Create a cartPOST /carts with Idempotency-KeyPersist idempotency key, request fingerprint, and prior result with the created resource
Read a cartGET /carts/{cartId}Authorize the specific resource and emit validators like ETag
Replace known dataPUT /carts/{cartId}/items/{sku}Preserve intended meaning through idempotent contracts
Patch mutable fieldsPATCH ... with defined patch format and If-MatchReject stale payloads with 412
Delete cartDELETE /carts/{cartId}Define idempotent behavior and tombstone policy
Search cartsGET /carts?owner=42&status=open&limit=50&after=...Bind pagination and filter state to authorization and principal

A creation response uses 201 Created and Location when the server creates a resource. Error responses use application/problem+json with stable problem type identifiers and avoid leaking whether another tenant’s resource exists.

Idempotency keys solve an ambiguous retry only when their store is durable and scoped. The server must reject the same key with a different request fingerprint, return the original result for a completed request, and define expiry longer than the client’s maximum retry window.

Compatibility strategy:

Additive fields and new optional capabilities usually preserve compatibility. A new version is earned when the old and new contracts cannot coexist safely.

StrategyStrengthCost
Path version (/v2/carts)Visible and easy to routeDuplicates resource lifetimes and links
Media type or headerKeeps resource URI stableHarder discovery, gateway, and cache configuration
New resource or operationMakes a genuinely new capability explicitClients may coordinate across old and new workflows

One strategy should govern each API surface, with published deprecation, migration, and removal dates. Adding v1 before a real breaking change creates a permanent compatibility surface without solving a compatibility problem.

REST and GraphQL expose different control surfaces:

DimensionRESTGraphQL
Contract ownerServer owns resources and representationsServer owns schema. Clients own operation documents
Cache keyMethod, URL, and Vary work with generic cachesArbitrary operations need persisted IDs or GraphQL-aware caches
AuthorizationChecked per endpoint and resourceChecked through nested field and object resolution
Cost controlEndpoint work is comparatively predictableDepth, aliases, list sizes, and resolver fan-out need budgets
Strong fitStable resource interactions and broad HTTP reachMultiple clients need different graph projections

Neither removes backend fan-out. Choose GraphQL only when selectable graph projections repay the schema governance and execution controls.

networks rest

The visual is a design prompt, not a protocol mandate. POST becomes retry-safe only with durable idempotency handling, path versioning is one compatibility option, and action resources are legitimate when the operation is not a collection mutation.

Offset, Keyset, and Cursor Pagination

Pagination limits response size and does not imply snapshot consistency.

Keyset traversal keeps cursor positions stable under concurrent writes when ordering is strict and total:

SELECT id, created_at, total
FROM orders
WHERE tenant_id = @tenantId
  AND status = @status
  AND (created_at, id) < (@createdAt, @id)
ORDER BY created_at DESC, id DESC
LIMIT 51;
GET /orders?status=open&limit=50&after=eyJjcmVhdGVkQWQiOiIyMDI2LTA3LTE2VDEwOjAwOjAwWiIsImlkIjo5MDF9.sig

Encode cursor scope and policy (tenant, sort order, direction, filters, page-size limits). A cursor from one query shape must not be reused for another.

Fetch one extra row to determine whether another page exists. The unique id tie-breaker prevents an ambiguous boundary when rows share a timestamp. The index should begin with equality filters and then the ordered keys, for example (tenant_id, status, created_at DESC, id DESC).

Encode and integrity-protect the cursor rather than exposing an editable database token. Bind it to tenant, principal or authorization scope, filters, sort order, direction, page-size policy, and expiry.

What a Cursor Does Not Snapshot

A cursor identifies a position, not a snapshot. With live keyset traversal:

  • Later inserts before the cursor may not appear in subsequent pages.
  • Deletes can remove rows the client has not reached.
  • An update to the sort key can move a row across the boundary.

If exact snapshot traversal matters, include a snapshot or version boundary, or query storage that can retain a snapshot for the traversal lifetime. That costs storage and retention and needs an expiry response when the snapshot is gone.

For backward traversal, encode direction and boundary explicitly. Query in the direction that uses the index, then reverse returned rows if the presentation order requires it.

NeedOffset or page numberKeyset cursor
Direct jump to page 20NaturalNot natural
Stable deep traversal under insertsRows can shiftStable relative boundary
Deep query costOften scans or skips earlier rowsSeeks from indexed boundary
Human-readable navigationEasyCursor is opaque

Offset pagination fits small, stable administrative lists and approximate page jumps. Keyset pagination fits large or changing collections. Both need independent limits for page size and total backend work.

Enforcing ETags and Pagination in ASP.NET Core

Framework mapping is mechanical. Contract enforcement is architectural:

app.MapGet("/api/orders/{id:int}", async (
    int id,
    AppDbContext db,
    CancellationToken ct) =>
    await db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct) is { } order
        ? Results.Ok(order)
        : Results.NotFound());
 
app.MapPost("/api/orders", async (
    CreateOrderDto request,
    AppDbContext db,
    CancellationToken ct) =>
{
    var order = new Order
    {
        CustomerId = request.CustomerId,
        Total = request.Total,
        Currency = request.Currency
    };
 
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);
    return Results.Created($"/api/orders/{order.Id}", order);
});

A missing resource returns 404. Creation returns 201 and a Location value. Production creation also needs authorization, input validation, problem details, and an idempotency contract before a caller may retry it.

app.MapPut("/api/orders/{id:int}", async (
    int id,
    UpdateOrder request,
    HttpRequest http,
    AppDbContext db,
    CancellationToken ct) =>
{
    if (!http.Headers.TryGetValue("If-Match", out var raw) ||
        !long.TryParse(raw.ToString().Trim('"'), out var expectedVersion))
    {
        return Results.StatusCode(StatusCodes.Status428PreconditionRequired);
    }
 
    var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
        UPDATE orders
        SET shipping_address = {request.ShippingAddress}, version = version + 1
        WHERE id = {id} AND version = {expectedVersion}
        """, ct);
 
    return affected == 1
        ? Results.NoContent()
        : Results.StatusCode(StatusCodes.Status412PreconditionFailed);
});

Optimistic concurrency is the server-side boundary. Zero rows changed means the caller must reread and reconcile before retry.

Operational boundaries remain part of the resource contract:

  • Pass request cancellation through database and dependency calls.
  • Set request-body size and JSON-depth limits before deserialization work grows without bound.
  • Return RFC 9457 problem details without exception traces or secret values.
  • Measure dependency time and queue time separately from serialization time.
  • Keep retries in the caller or a clearly owned resilience layer. A handler must not replay a non-idempotent downstream operation blindly.

Uniform Interface and Richardson Levels

The REST uniform interface has four parts:

  1. Stable URI references identify resources independently of one representation.
  2. A client manipulates a resource through a transferred representation.
  3. Method, target, fields, media type, content, and status make messages self-descriptive.
  4. Hypermedia controls can expose valid next transitions.
{
  "id": 42,
  "status": "awaiting-payment",
  "links": [
    { "rel": "self", "href": "/orders/42", "method": "GET" },
    { "rel": "pay", "href": "/orders/42/payment", "method": "POST" },
    { "rel": "cancel", "href": "/orders/42/cancellation", "method": "PUT" }
  ]
}

The client still needs the media type and link-relation contract. Hypermedia reduces hard-coded workflow URLs. It does not eliminate versioning, authorization, or schema evolution.

The Richardson maturity model is a teaching model, not the definition of REST or a universal score:

LevelCapabilityExampleMissing boundary
0One endpoint used as a message tunnelPOST /api with an action fieldResource identity and standard method semantics
1Resource-oriented URI references/orders and /orders/42Methods and statuses may still be used as a tunnel
2Methods, statuses, fields, and representations carry intentGET, conditional PUT, 201, 412Valid next transitions remain out-of-band
3Hypermedia controls advertise available transitionspay, cancel, and track links by order stateClient and server still share media-type and relation semantics

Level 2 is common because it works with ordinary tooling and generated clients. Level 3 earns its cost when runtime discoverability, long-lived workflow evolution, or generic clients matter. A closed application whose client and server release together may get little value from hypermedia controls.

Performance and Error Tradeoffs

Performance gains are not free. Each one adds a contract:

  • Cache: speedups with freshness/invalidation cost
  • Compression: saves bandwidth, adds CPU
  • Asynchronous work: hides latency, increases eventual-consistency and duplicate risk
  • Queues/pools: control backpressure, require bounded retries and timeouts

networks rest

Treat the visual as a technique inventory. Deep offset pagination can become slower, asynchronous logging needs a loss policy, caches need authorization-safe keys and invalidation, compression spends CPU, and pools need bounds and refresh behavior.

API Style Comparison

StyleStrong fitCost
RESTExternal or cross-team APIs with stable resourcesRepresentation design, compatibility, and caching discipline
GraphQLSelective projections for evolving clientsQuery cost control, resolver load, authorization complexity
gRPCControlled service meshes with typed streamingSchema/toolchain coupling and browser bridge needs

No style removes governance. It changes where the cost is paid.

References