The RK Times
← All posts REST API Guidelines: The Decisions That Bite Later
Backend

REST API Guidelines: The Decisions That Bite Later

By Romaan · Sep 25, 2026 · 29 min read · 19 views

Almost nobody ships a REST API that is wrong on day one. What happens instead is that a dozen small decisions -- a status code here, a query parameter shape there, a PATCH that quietly clears a field -- each look fine in isolation, and then eighteen months later you are running a versioned endpoint you cannot delete because four consumers depend on a bug.

This is a guide to those decisions. It is deliberately language agnostic, because none of it is a framework feature: HTTP already specifies most of the answers, and the job is mostly knowing which part of the spec to reach for. Where an example helps, you get it twice -- ASP.NET Core and Spring Boot -- so the shape is obvious regardless of which stack you are in.

The running example throughout is an orders API for a shop: orders belong to customers, contain line items, and move through a lifecycle.

1. Resources, not remote procedures

The single most useful constraint: URLs name things, verbs act on them. If the verb is in the path, you have drifted back into RPC with extra steps.

GET    /orders                 list
POST   /orders                 create
GET    /orders/{id}            read
PUT    /orders/{id}            replace
PATCH  /orders/{id}            partial update
DELETE /orders/{id}            remove

GET    /customers/{id}/orders  sub-collection (relationship is real)

# Wrong -- verbs in the path
POST   /createOrder
GET    /getOrderById?id=123
POST   /orders/{id}/doCancel

Conventions worth fixing once, in writing, before the second team starts:

  • Plural collection nouns. /orders, not /order. Mixed conventions cost more than either choice.
  • kebab-case in paths, camelCase or snake_case in JSON bodies -- pick one for bodies and never mix. Path segments are case-sensitive; do not rely on the server lowercasing them.
  • Opaque identifiers. Sequential integers leak volume and invite enumeration. If you must expose database IDs, at least stop treating "guessable" as "authorized".
  • No trailing slashes (or always -- but one of them, enforced). The redirect that fixes a mismatch is a real gotcha: see section 10.
  • Nest exactly one level. /customers/{id}/orders is fine. /customers/{id}/orders/{oid}/items/{iid}/tax is a URL you will regret; make items a top-level resource and filter.

1.1 The actions problem

Some operations genuinely are not CRUD. Cancelling an order is not "setting status to cancelled" -- it may refund a payment, release stock and send mail. Two defensible options:

POST /orders/{id}/cancel          # action sub-resource, body carries the reason
POST /order-cancellations         # the action IS the resource, and is itself addressable

The second is more RESTful and more useful: the cancellation gets an id, a timestamp and a status you can poll. Use it when the action is long-running or auditable. Use the first when it is a trivial state flip. What you should not do is PATCH /orders/{id} with {"status": "cancelled"} and hide half a business process behind a field assignment -- now every client that PATCHes the status field triggers refunds.

2. HTTP methods: safety, idempotency, and when to reach for which

Two properties drive nearly every method decision, and most developers can define them but not apply them under pressure.

  • Safe -- the request does not change server state. GET, HEAD, OPTIONS. Crawlers, prefetchers and browsers assume this. A GET with side effects will eventually be triggered by a link preview bot.
  • Idempotent -- performing it N times leaves the same state as performing it once. GET, HEAD, OPTIONS, PUT, DELETE. Not: POST, and not necessarily PATCH.

Idempotency is not a philosophical nicety. It decides whether a client, a proxy or a service mesh may safely retry after a timeout, which is the difference between a blip and a duplicate charge.

Method   Safe   Idempotent   Body    Typical success
GET       yes      yes        no     200 + representation
HEAD      yes      yes        no     200, headers only
POST      no       no         yes    201 + Location, or 202
PUT       no       yes        yes    200 / 204 (201 if created)
PATCH     no       no*        yes    200 / 204
DELETE    no       yes        no     204 (or 200 with body)
OPTIONS   yes      yes        no     204 + Allow

* PATCH can be made idempotent, and should be. See 3.3.

Rules that follow directly:

  • Never use GET for anything that mutates. Not even "just a counter".
  • DELETE is idempotent, which means the second call is not an error. Deleting an already-deleted order returns 204, not 404. Returning 404 on the retry breaks every client that retries on timeout -- they cannot distinguish "never existed" from "I already succeeded".
  • POST is the escape hatch. Anything that is not safely repeatable is a POST. That includes searches with large or sensitive criteria (section 6.4).
  • Support HEAD wherever you support GET. Most frameworks do it free. It lets clients check existence and ETag without transferring the body.
  • OPTIONS and Allow matter more than people think once a gateway is in front of you; a 405 should always carry an Allow header listing what is permitted.

3. PUT vs PATCH, properly

This is the single most commonly botched distinction in REST, and the bugs it produces are data-loss bugs, which are the expensive kind.

3.1 PUT replaces the entire resource

PUT /orders/123 means: whatever is at this URL, make it exactly this. The body is the complete new representation. Fields you omit are not "left alone" -- they are removed.

PUT /orders/123
Content-Type: application/json
If-Match: "a1b2c3"

{ "customerId": "c-77", "items": [{ "sku": "A1", "qty": 2 }], "note": "leave at door" }

If the stored order also had giftMessage, a correct PUT implementation clears it. The classic bug is a server that treats PUT as a merge: it looks harmless, it passes tests, and then a client that legitimately wants to clear a field finds it cannot -- and another client silently keeps stale data it meant to remove.

Because PUT is a full replacement, it is idempotent: sending it five times leaves the same state as sending it once. That is what makes it retry-safe.

PUT may also create the resource if the client chooses the id (a UUID, a natural key). Then 201 Created on first call, 200/204 afterwards. Do not use PUT-as-create with server-generated ids -- that is POST's job.

3.2 PATCH applies a partial modification

PATCH /orders/123 means: apply this change description. The body is not a partial resource by default -- it is a patch document, and you must say which format you are speaking via Content-Type.

JSON Merge Patch (RFC 7396) -- application/merge-patch+json. Looks like a sparse object. null means delete this member.

PATCH /orders/123
Content-Type: application/merge-patch+json

{ "note": "ring the bell", "giftMessage": null }

Simple, and it covers 90% of real needs. Its limitation is structural: because null is overloaded to mean deletion, you cannot set a member to null, and it cannot address array elements -- arrays are replaced wholesale.

JSON Patch (RFC 6902) -- application/json-patch+json. An ordered list of operations.

PATCH /orders/123
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/note",        "value": "ring the bell" },
  { "op": "remove",  "path": "/giftMessage" },
  { "op": "add",     "path": "/items/-",     "value": { "sku": "B2", "qty": 1 } },
  { "op": "test",    "path": "/status",      "value": "pending" }
]

More powerful -- it can target array positions, and test gives you optimistic concurrency at the field level. The cost is that it is awkward to hand-write, awkward to validate, and awkward to document in OpenAPI (the request body is just "an array of operations", so your schema tells clients almost nothing about what may be patched).

Recommendation: default to JSON Merge Patch. Reach for JSON Patch only when clients genuinely need array-element surgery. And whichever you choose, reject the one you did not implement with 415 Unsupported Media Type rather than guessing -- a server that accepts application/json for PATCH and applies merge semantics is guessing.

3.3 Make PATCH idempotent anyway

PATCH is not required to be idempotent, which is why retry logic will not repeat it by default. But most PATCHes can be made idempotent trivially -- {"note": "x"} applied twice gives the same result -- and then you can advertise that fact and let clients retry.

The ones that are not: anything relative, such as {"op": "add", "path": "/items/-"} (appends each time) or a hypothetical {"incrementBy": 1}. If you need those, pair them with an Idempotency-Key (section 5.3).

3.4 The tri-state problem, and how each stack fumbles it

For a partial update you must distinguish three cases for every field:

absent from body   ->  leave unchanged
present, null      ->  clear it
present, value     ->  set it

Most deserialisers collapse the first two. Bind {"note": null} and {} into the same DTO and both give you note == null, at which point you cannot tell "clear it" from "don't touch it". This is the data-loss bug from 3.1 wearing a different hat.

ASP.NET Core -- a small wrapper restores the distinction:

public readonly struct Patch<T>
{
    public bool IsSet { get; init; }
    public T? Value  { get; init; }
}

public sealed class UpdateOrderRequest
{
    public Patch<string?> Note        { get; init; }
    public Patch<string?> GiftMessage { get; init; }
}

// In the handler -- only touch what the client actually sent.
if (req.Note.IsSet)        order.Note        = req.Note.Value;
if (req.GiftMessage.IsSet) order.GiftMessage = req.GiftMessage.Value;

(You supply a JsonConverter<Patch<T>> that sets IsSet = true whenever the property appears. The built-in JsonPatchDocument<T> solves the same problem for RFC 6902, though historically it required the Microsoft.AspNetCore.Mvc.NewtonsoftJson package.)

Spring Boot -- the same idea, via JsonNullable from org.openapitools.jackson.nullable:

public record UpdateOrderRequest(
    JsonNullable<String> note,
    JsonNullable<String> giftMessage
) {}

@PatchMapping(path = "/orders/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<OrderResponse> patch(@PathVariable String id,
                                           @RequestBody UpdateOrderRequest req) {
    Order order = orders.require(id);
    req.note().ifPresent(order::setNote);                 // present -> set (may be null)
    req.giftMessage().ifPresent(order::setGiftMessage);
    return ResponseEntity.ok(OrderResponse.from(orders.save(order)));
}

Register JsonNullableModule with the ObjectMapper or every field deserialises as absent.

3.5 Concurrency: neither is safe without preconditions

Both PUT and PATCH read-modify-write, so both are exposed to lost updates. Two clients GET an order, both change one field, both write -- the second silently overwrites the first.

The fix is an ETag on reads and If-Match on writes:

GET /orders/123            ->  200, ETag: "a1b2c3"
PUT /orders/123            <-  If-Match: "a1b2c3"
                           ->  412 Precondition Failed   (someone else got there first)
                           ->  428 Precondition Required (client sent no If-Match at all)

Returning 428 for a missing If-Match on unsafe methods is how you make preconditions mandatory rather than optional. It is a one-line policy that eliminates an entire bug class.

4. Status codes that actually carry information

You need perhaps a dozen. Using them precisely is free; using them loosely means clients parse error strings.

200 OK                      GET/PUT/PATCH succeeded, body follows
201 Created                 POST created something -> MUST include Location
202 Accepted                accepted, not done yet -> point at a status resource
204 No Content              success, deliberately empty (DELETE, or PUT with Prefer: return=minimal)

304 Not Modified            conditional GET, client cache is still valid

400 Bad Request             malformed -- unparseable JSON, wrong types
401 Unauthorized            not authenticated -> MUST include WWW-Authenticate
403 Forbidden               authenticated, not allowed
404 Not Found               no such resource (or hidden for authz reasons -- see below)
405 Method Not Allowed      -> MUST include Allow
409 Conflict                state conflict: duplicate key, illegal transition
412 Precondition Failed     If-Match did not match
415 Unsupported Media Type  wrong Content-Type -- including the wrong PATCH format
422 Unprocessable Content   syntactically fine, semantically invalid (business rules)
428 Precondition Required   you demand If-Match and got none
429 Too Many Requests       -> SHOULD include Retry-After

500 Internal Server Error   your bug -- never leak the stack trace
503 Service Unavailable     down / overloaded -> SHOULD include Retry-After

The distinctions that matter in practice:

  • 400 vs 422. 400 = "I could not parse this." 422 = "I parsed it and it violates a rule." Clients handle them differently: 400 is a bug in the client, 422 is usually something to show the user. If you collapse both to 400, every consumer has to string-match your error body to tell them apart.
  • 401 vs 403. 401 means authenticate and try again -- so it must tell the client how, via WWW-Authenticate. 403 means do not bother retrying with these credentials. Returning 401 for an expired token and 403 for insufficient scope is the behaviour client SDKs expect when deciding whether to refresh.
  • 404 vs 403 for authorization. Returning 403 for objects that exist and 404 for ones that do not is an enumeration oracle. If existence itself is sensitive, return 404 uniformly.
  • 201 must carry Location. Without it the client has to guess the URL of the thing it just made.
  • 202 must point somewhere. Return a Location (or a body) naming a status resource the client can poll, or it has no way to learn the outcome.

5. Headers: the part everyone skips

Headers are where REST keeps its metadata, and ignoring them is how you end up reinventing caching, concurrency and rate limiting inside your JSON envelope.

5.1 Content negotiation

Accept: application/json                  # what the client wants back
Content-Type: application/json            # what the client is sending
Accept-Language: en-AU, en;q=0.8
Content-Encoding: gzip
  • Send Content-Type on every request with a body, and honour Accept or return 406. In practice most JSON APIs accept anything and always return JSON; that is fine, but be deliberate rather than accidental.
  • Vary is not optional. If a response differs by Accept, Accept-Language, or Authorization, say so -- otherwise a shared cache or CDN serves one user's response to another. Vary: Accept, Accept-Encoding, Authorization is the classic minimum, and a missing Vary: Authorization on a cacheable endpoint is a genuine data-leak bug.
  • Use specific media types for patches (application/merge-patch+json) and errors (application/problem+json). They let clients dispatch on the type rather than sniffing the body.

5.2 Caching and concurrency

# response
ETag: "a1b2c3"
Last-Modified: Wed, 24 Sep 2026 09:12:00 GMT
Cache-Control: private, max-age=60, must-revalidate

# subsequent request
If-None-Match: "a1b2c3"      ->  304 Not Modified  (cheap re-read)
If-Match:      "a1b2c3"      ->  412 if stale      (safe write)

ETag does double duty: cache validation on reads (If-None-Match) and optimistic locking on writes (If-Match). Emit it on every single-resource GET. A weak ETag (W/"...") is fine for caching; use a strong one if you rely on it for If-Match.

State Cache-Control explicitly on every response. The default behaviour of intermediaries when you say nothing is not something to leave to chance -- no-store for anything personal, private, max-age=N for per-user data, public only when you mean it.

5.3 Idempotency for POST

POST is not idempotent, but real clients retry after timeouts anyway, and a timeout is indistinguishable from a failure. The convention (an IETF draft, already widely deployed by payment providers) is a client-generated key:

POST /orders
Idempotency-Key: 7f1c2d0e-1d2b-4a3e-9f10-6c2a5b8e4d11

# First call:  201 Created, Location: /orders/123     -- stored against the key
# Retry:       201 Created, Location: /orders/123     -- replayed, no second order
# Same key, different body: 422 (or 409)              -- the key is being misused

Store the key with the response for a bounded window (24h is typical), scoped per client. This is the single highest-value header you can add to a write API.

5.4 Rate limiting and retries

429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 30

Retry-After also belongs on 503. Without it clients invent their own backoff, and their invention is usually "retry immediately, forever". The RateLimit-* family is still an IETF draft and the exact names have churned between revisions -- pick a version, document it, and do not change it silently.

5.5 Pagination, tracing and correlation

Link: </orders?cursor=eyJpZCI6MTIzfQ&limit=50>; rel="next"
X-Request-Id: 01JC8Z...           # echo back what the client sent, or mint one
traceparent: 00-4bf92f...-01      # W3C Trace Context -- propagate it, do not strip it

Link (RFC 8288) is the standards-based way to paginate. Gateways and service meshes will happily drop headers they do not recognise, so verify yours survive the whole path -- a Link header that works in local dev and vanishes behind the ingress is a classic afternoon lost.

5.6 Custom headers

  • Do not prefix with X-. RFC 6648 deprecated that in 2012. Use a namespaced name: Acme-Trace-Id.
  • Keep them out of the contract where you can. Anything a client must read is better off in the body, where it is typed, documented and testable.
  • Browsers cannot read your headers by default. This is the gotcha that eats an afternoon: with CORS, JavaScript only sees a handful of safe-listed response headers. ETag, Link, Location, Retry-After and everything custom are invisible unless you list them:
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, RateLimit-Remaining

Your curl tests pass, the browser client sees nothing, and nobody suspects the server.

6. Collections: listing, filtering, sorting, searching

Collection endpoints are where APIs sprawl. Fix the grammar early, because every consumer encodes it.

6.1 Filtering

GET /orders?status=paid&customerId=c-77            # equality, AND-ed
GET /orders?status=paid,shipped                    # IN -- comma-separated
GET /orders?createdAt.gte=2026-01-01&total.lt=500  # operator suffix

Three workable conventions -- flat equality, suffixed operators, or a bracket form (filter[status]=paid). Any is fine. Mixing them in one API is not. Rules that hold regardless:

  • Unknown query parameters should be rejected, not ignored. Silently dropping ?statuss=paid returns a full unfiltered collection, which at best is confusing and at worst leaks data the user should not see. 400 with the offending parameter named.
  • Reserve a small vocabulary -- limit, cursor, offset, sort, fields, q, expand -- and never let a filter field collide with it. If you have a field called sort, you have a problem.
  • Filters are AND. The moment someone asks for OR or nesting, you are building a query language; go to 6.4 instead of growing the grammar.
  • Values are typed. ?active=true is a boolean, ?total.gte=10.5 a decimal. Reject ?active=yes rather than coercing.

6.2 Sorting

GET /orders?sort=-createdAt,id

Leading - for descending. Two rules, both about correctness rather than taste:

  • Allow-list the sortable fields. Passing the parameter to your ORM is an invitation to sort by an unindexed column and table-scan production.
  • Always append a unique tiebreaker (here, id). Sorting only by createdAt when timestamps collide gives a non-deterministic order, and non-deterministic order means paginated results silently skip and duplicate rows. This is the most common pagination bug there is, and it looks like data corruption.

6.3 Sparse fieldsets and expansion

GET /orders?fields=id,total,status      # trim the response
GET /orders?expand=customer             # inline a related resource

Both are worth having; both need limits. Cap expand at one level and allow-list what may be expanded, or you have shipped a graph query engine with no depth limit and no cost accounting.

6.4 When the query outgrows the URL

Use POST to a search sub-resource once filters become complex, long, or sensitive:

POST /orders/search
Content-Type: application/json

{ "status": ["paid","shipped"], "createdAt": { "gte": "2026-01-01" }, "limit": 50 }

You lose caching and the URL stops being shareable, so do not reach for it early. But URLs have practical length limits, and query strings are logged by every proxy, browser history and access log in the path -- if the filter contains an email address or a customer name, it should not be in a URL. The trade is deliberate, not a defeat.

7. Pagination: cursor by default

Every collection endpoint is unbounded until proven otherwise. Add pagination on day one -- retrofitting it is a breaking change, because the shape of the response has to change.

7.1 Offset pagination is the trap

GET /orders?offset=1000&limit=50

It is obvious, every ORM supports it, and it breaks in two specific ways:

  • It drifts. Rows inserted or deleted while a client pages shift the window. The client sees duplicates and misses records -- silently, with a 200.
  • It degrades. OFFSET 100000 makes the database walk and discard 100,000 rows on every page. Deep pages get slower in proportion to how deep they are.

Offset is acceptable for small, static, admin-facing lists where a user jumps to page 7. It is the wrong default for anything a machine iterates.

7.2 Cursor pagination

GET /orders?limit=50
GET /orders?limit=50&cursor=eyJjIjoiMjAyNi0wMS0wMVQwMDowMDowMFoiLCJpIjoiMTIzIn0
{
  "data": [ { "id": "..." } ],
  "page": {
    "nextCursor": "eyJjIjoi...",
    "hasMore": true
  }
}

The cursor encodes the sort key of the last row -- here (createdAt, id) -- and the query becomes a keyset seek: WHERE (createdAt, id) < (:c, :i) ORDER BY createdAt DESC, id DESC LIMIT 50. Constant cost per page, and stable under concurrent writes.

Non-negotiables:

  • The cursor is opaque. Base64 of a JSON blob is fine; the point is that clients must never construct or parse one. Say so in the docs, and sign it if you want to be sure -- an unsigned cursor is a free SQL parameter for an attacker.
  • The cursor must encode the sort. If a client changes sort mid-iteration, reject the cursor (400) rather than returning nonsense.
  • Always include a tiebreaker in the key, per 6.2.
  • Cap limit. Document a default (25-50) and a maximum (100-200), clamp rather than error, and never let a client ask for everything.

7.3 Total counts are not free

"total": 84321 looks harmless and costs a full COUNT(*) with your filters applied, on every page request. On a large table that is often slower than the page query itself.

  • Omit it by default; offer ?includeTotal=true for the screens that genuinely need it.
  • Or return hasMore (fetch limit + 1 rows and discard the extra), which is what UIs usually need anyway.
  • Or return an estimate and name it as one.

7.4 Envelope or Link?

Link: </orders?cursor=abc&limit=50>; rel="next",
      </orders?limit=50>; rel="first"

Link is the standards-compliant choice and keeps the body a clean array. The envelope form ({ "data": [...], "page": {...} }) is easier for browser clients, which -- as in 5.6 -- cannot read Link at all unless you expose it through CORS. Doing both is defensible. Doing neither, and returning a bare array with no pagination metadata, means clients cannot tell a last page from a truncated one.

One-line version: return { "data": [...] } from day one, never a bare top-level array. It gives you somewhere to put pagination, warnings and metadata later without a breaking change.

8. Errors: one shape, everywhere

Use RFC 9457 Problem Details (which obsoletes RFC 7807). It is a standard, it is boring, and it means clients write one error handler instead of five.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.acme.com/problems/insufficient-stock",
  "title": "Insufficient stock",
  "status": 422,
  "detail": "SKU A1 has 2 units available, 5 requested.",
  "instance": "/orders/123",
  "code": "INSUFFICIENT_STOCK",
  "errors": [
    { "pointer": "/items/0/qty", "code": "MAX_EXCEEDED", "message": "At most 2." }
  ]
}
  • type is a stable URI and the real identifier. It is what clients branch on. It does not have to resolve, but it is much better if it does -- a page explaining the error is worth a support ticket.
  • Add a short machine code. Humans grep for it in logs and it survives translation of title/detail.
  • Field errors use JSON Pointer (RFC 6901) so clients can map them onto form fields without string parsing.
  • detail is for humans and must be safe to display. No stack traces, no SQL, no internal hostnames. Log those; do not return them.
  • Never return 200 with an error body. It defeats every generic HTTP client, proxy and retry policy between you and the caller.

ASP.NET Core produces this natively:

builder.Services.AddProblemDetails(o =>
    o.CustomizeProblemDetails = ctx =>
        ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);

app.UseExceptionHandler();   // unhandled -> 500 problem+json, no stack trace
app.UseStatusCodePages();

// explicit
return TypedResults.Problem(
    title: "Insufficient stock",
    detail: $"SKU {sku} has {available} units available, {requested} requested.",
    statusCode: StatusCodes.Status422UnprocessableEntity,
    type: "https://api.acme.com/problems/insufficient-stock");

Spring Boot (Spring Framework 6+) has ProblemDetail built in:

@ExceptionHandler(InsufficientStockException.class)
ProblemDetail handle(InsufficientStockException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
    pd.setTitle("Insufficient stock");
    pd.setType(URI.create("https://api.acme.com/problems/insufficient-stock"));
    pd.setProperty("code", "INSUFFICIENT_STOCK");
    return pd;
}

Set spring.mvc.problemdetails.enabled=true so framework-generated errors use the same shape as yours; otherwise clients face two error formats from one API.

9. Versioning and evolution

The cheapest version is the one you never ship. Most changes can be made additively:

Safe (additive)                      Breaking
-----------------------------------  ------------------------------------
add an optional request field        add a required request field
add a response field                 remove or rename a response field
add a new endpoint                   change a field's type
add a new optional query parameter   change the meaning of a value
add a new enum value*                narrow validation on an existing field
                                     change a status code for a given case

* -- only if you told clients up front how to handle unknown values. Generated clients routinely deserialise enums into closed types and throw on anything new, so document the unknown-value policy on day one or treat every new enum member as breaking. This catches people constantly.

When you must version, pick one mechanism and apply it everywhere:

  • URL path (/v1/orders) -- ugly, unambiguous, trivially routable, works in a browser. The pragmatic default.
  • Media type (Accept: application/vnd.acme.order.v2+json) -- purer, and harder for everyone: caches, logs, CLI users.
  • Custom header (Acme-Version: 2026-01-15) -- date-based versions work well for APIs that evolve continuously.

Version the API, not each resource. Per-resource versions multiply into a matrix nobody can test.

10. Gotchas that survive code review

  • Redirects eat things. A 301/302 from /orders to /orders/ makes many clients re-issue as GET and drop the body -- and some drop the Authorization header across redirects. Pick one path form and return 404 for the other, or use 308, which preserves method and body.
  • GET with a body. Allowed by the letter of the spec, dropped by proxies, CDNs and some client libraries in practice. If you need a body, use POST /search.
  • JSON numbers are doubles in JavaScript. An int64 id above 2^53 silently loses precision in a browser. Serialise large integer ids as strings.
  • Dates: RFC 3339, UTC, always. 2026-09-24T09:12:00Z. Not epoch seconds in one field and an ISO string in another, not local time without an offset, not /Date(1234567890)/.
  • Decimals are not floats. Money as a JSON float will eventually be wrong. Use minor units as an integer, or a string.
  • null vs absent vs []. Decide whether an empty collection is [] or omitted, and be consistent. [] is nearly always the better answer.
  • Trailing-slash and case sensitivity differ between your framework, your reverse proxy and your CDN. Test the combination, not the framework.
  • The 500 that is really a 400. Unhandled deserialisation failures surface as 500 in many stacks. Map them to 400 or clients will retry a request that can never succeed.
  • Timeouts without retries, retries without idempotency. Either alone is survivable; the combination of "client retries" and "POST has no idempotency key" is how duplicate orders happen.
  • Compression and Accept-Encoding. Turn on gzip/brotli for JSON; a large collection response is mostly repeated key names and compresses by 80-90%.
  • **Pagination defaults applied after filtering, not before.** Sounds obvious; is a real bug in enough codebases to mention.

11. An OpenAPI spec clients can safely generate from

Most teams now produce OpenAPI automatically and assume the job is done. It is not. A spec that renders nicely in Swagger UI can still generate a client SDK that is wrong, ugly, or unstable between builds. The difference is a handful of details.

11.1 Code-first or design-first?

Code-first (annotate the implementation, emit the spec) keeps the spec honest -- it cannot drift from the code. It is the right default for a single team owning both sides.

Design-first (write the YAML, generate server stubs and clients) is better when several teams must agree before anyone builds, or when the API is the product. Its weakness is drift: nothing stops the implementation diverging unless you test against the spec.

Either way, the spec must be a build artefact under version control, not something generated on the fly at /swagger.json in production. Generate it in CI, commit or publish it, and diff it. That one change is what turns OpenAPI from documentation into a contract.

11.2 What generators actually need

A client generator turns your spec into method names, class names and types. Four things decide whether that output is usable:

  • operationId on every operation, unique and stable. It becomes the method name. Leave it out and generators synthesise something from the path and verb -- ordersGet_1, ordersIdPatch -- which changes whenever you reorder or add routes, producing gratuitous breaking changes in every downstream SDK. Set it by hand.
  • Named schemas, not inline ones. Anonymous inline objects become InlineResponse200, InlineObject3. Give every DTO a real name and reference it with $ref.
  • required and nullability, correct and distinct. These are different things and generators map them to different code:
required, not nullable   ->  string Name            (always present)
required, nullable       ->  string? Name           (present, may be null)
optional, not nullable   ->  string? Name           (may be absent)
optional and nullable    ->  the tri-state from 3.4 -- avoid unless you mean it

In OpenAPI 3.0 nullability is nullable: true; in 3.1 it is a type union (type: ["string","null"]), because 3.1 aligns with JSON Schema 2020-12. Know which version you emit -- some older generators still do not handle 3.1.

  • Explicit format on primitives. format: int64, format: date-time, format: uuid, format: decimal. Without them everything becomes a string or a 32-bit int, and your large ids overflow in the client.

11.3 Declare every response, not just the happy path

The commonest failing: an operation that documents only 200. The generated client then has no typed error, and consumers parse errors by hand -- exactly what section 8 was meant to prevent.

paths:
  /orders/{orderId}:
    patch:
      operationId: patchOrder
      summary: Apply a partial update to an order
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: If-Match
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/OrderPatch' }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '422': { $ref: '#/components/responses/ValidationProblem' }
        '429': { $ref: '#/components/responses/RateLimited' }
components:
  responses:
    ValidationProblem:
      description: Validation failed
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/ProblemDetails' }

Define the error responses once under components/responses and $ref them everywhere. It keeps the spec small and the generated client consistent.

11.4 Emitting it from ASP.NET Core

.NET 9 ships OpenAPI generation in the framework (Microsoft.AspNetCore.OpenApi); Swashbuckle and NSwag remain popular for their richer tooling.

builder.Services.AddOpenApi();

app.MapOpenApi();        // /openapi/v1.json

app.MapPatch("/orders/{orderId:guid}", PatchOrder)
   .WithName("patchOrder")                       // -> operationId
   .WithSummary("Apply a partial update to an order")
   .Accepts<OrderPatch>("application/merge-patch+json")
   .Produces<Order>(StatusCodes.Status200OK)
   .ProducesProblem(StatusCodes.Status412PreconditionFailed)
   .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity);

For controllers the equivalents are [EndpointName("patchOrder")] and [ProducesResponseType(typeof(Order), 200)]. Annotate every status code you can return; the generator cannot infer them from return BadRequest().

Emit the file in CI rather than scraping a running server:

dotnet tool install -g Microsoft.dotnet-openapi
dotnet build
dotnet swagger tofile --output openapi.json bin/Release/net9.0/Orders.dll v1   # Swashbuckle CLI

11.5 Emitting it from Spring Boot

springdoc-openapi reads your controllers, Jackson annotations and Bean Validation constraints:

@PatchMapping(path = "/orders/{orderId}", consumes = "application/merge-patch+json")
@Operation(operationId = "patchOrder", summary = "Apply a partial update to an order")
@ApiResponse(responseCode = "200", description = "Updated")
@ApiResponse(responseCode = "412", ref = "#/components/responses/PreconditionFailed")
@ApiResponse(responseCode = "422", ref = "#/components/responses/ValidationProblem")
public ResponseEntity<OrderResponse> patchOrder(
        @PathVariable UUID orderId,
        @RequestHeader(HttpHeaders.IF_MATCH) String ifMatch,
        @RequestBody @Valid UpdateOrderRequest body) { ... }

Bean Validation annotations carry into the schema -- @NotNull becomes required, @Size(max = 64) becomes maxLength -- so validating properly and documenting properly become the same task. Dump the spec in CI with the Maven plugin:

<plugin>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-maven-plugin</artifactId>
  <configuration>
    <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
    <outputFileName>openapi.json</outputFileName>
  </configuration>
</plugin>

11.6 Gate it in CI

This is what makes the spec safe to consume. Three steps, none optional:

- name: Generate spec
  run: ./scripts/emit-openapi.sh        # writes openapi.json

- name: Fail if the committed spec is stale
  run: git diff --exit-code openapi.json

- name: Lint
  run: npx @stoplight/spectral-cli lint openapi.json --ruleset .spectral.yaml

- name: Block breaking changes
  run: |
    oasdiff breaking \
      https://api.acme.com/openapi.json \
      openapi.json --fail-on ERR
  • The staleness check is the one people skip and the one that matters most -- it guarantees the committed contract matches the code.
  • Spectral enforces your house rules mechanically: every operation has an operationId, every 4xx returns problem+json, no inline schemas, descriptions present.
  • oasdiff breaking compares against the currently published spec and fails the build on anything that would break a generated client -- a removed field, a narrowed type, a new required parameter. This is section 9's table, enforced.

Then generate clients from the same artefact, and let consumers pin a version:

openapi-generator-cli generate -i openapi.json -g java -o ./clients/java
npx @hey-api/openapi-ts -i openapi.json -o ./clients/ts
kiota generate -d openapi.json -l CSharp -o ./clients/csharp

11.7 Spec smells worth grepping for

  • InlineResponse, InlineObject, Model1 in generated client code -- unnamed schemas upstream.
  • oneOf without a discriminator -- generators cannot pick a type and fall back to object.
  • additionalProperties: true on a response model -- the client gets an untyped dictionary.
  • type: object with no properties -- documents nothing.
  • Enums with no documented unknown-value policy (see section 9).
  • An operation with exactly one documented response.
  • example values that would not validate against their own schema. They end up in docs and in people's copy-pasted tests.

Checklist

  1. Paths are plural nouns; no verbs; one nesting level; one casing convention.
  2. Non-CRUD operations are action sub-resources or first-class resources, never a magic field in PATCH.
  3. GET/HEAD/OPTIONS are side-effect free. PUT/DELETE are idempotent. Deleting twice returns 204.
  4. PUT replaces in full; omitted fields are cleared. PATCH declares its format via Content-Type and rejects others with 415.
  5. Partial updates distinguish absent from null, end to end -- DTO, validation and persistence.
  6. Unsafe methods require If-Match; 412 when stale, 428 when absent. Single-resource GETs return ETag.
  7. Status codes distinguish 400/422, 401/403, and 404-for-hidden. 201 carries Location; 202 points at a status resource.
  8. Vary is set wherever the response varies -- especially Authorization. Cache-Control is explicit on every response.
  9. Write endpoints accept Idempotency-Key. 429/503 carry Retry-After.
  10. Access-Control-Expose-Headers lists every header a browser client must read.
  11. Unknown query parameters are rejected. Sortable and expandable fields are allow-listed.
  12. Pagination is cursor-based with an opaque cursor, a unique tiebreaker in the sort, a clamped limit, and no unconditional COUNT(*).
  13. Collections return { "data": [...] }, never a bare array.
  14. Errors are RFC 9457 application/problem+json with a stable type URI, a machine code, and JSON Pointer field errors. Never 200 with an error body.
  15. Large integer ids serialise as strings; timestamps are RFC 3339 UTC; money is minor units or a string.
  16. Every operation has a hand-written operationId; every schema has a name; required and nullability are correct; formats are explicit.
  17. Every operation documents its error responses, $ref-ed from components/responses.
  18. The spec is committed, checked for staleness, linted, and diffed against production to block breaking changes before clients ever see them.

Almost none of this is exotic. It is HTTP used as specified, plus the discipline to decide once and write it down. The APIs that stay pleasant at year three are not the clever ones -- they are the boring ones where every endpoint answers the same questions the same way, and the spec in the repository is the same spec the clients were generated from.


Comments (0)

Be the first to comment.