REST API Guidelines: The Decisions That Bite Later
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-casein paths,camelCaseorsnake_casein 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}/ordersis fine./customers/{id}/orders/{oid}/items/{iid}/taxis a URL you will regret; makeitemsa 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. AGETwith 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 necessarilyPATCH.
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, not404. Returning404on 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
ETagwithout transferring the body. OPTIONSandAllowmatter more than people think once a gateway is in front of you; a 405 should always carry anAllowheader 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:400is a bug in the client,422is usually something to show the user. If you collapse both to400, every consumer has to string-match your error body to tell them apart. - 401 vs 403.
401means authenticate and try again -- so it must tell the client how, viaWWW-Authenticate.403means do not bother retrying with these credentials. Returning401for an expired token and403for insufficient scope is the behaviour client SDKs expect when deciding whether to refresh. - 404 vs 403 for authorization. Returning
403for objects that exist and404for ones that do not is an enumeration oracle. If existence itself is sensitive, return404uniformly. - 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-Typeon every request with a body, and honourAcceptor return406. In practice most JSON APIs accept anything and always return JSON; that is fine, but be deliberate rather than accidental. Varyis not optional. If a response differs byAccept,Accept-Language, orAuthorization, say so -- otherwise a shared cache or CDN serves one user's response to another.Vary: Accept, Accept-Encoding, Authorizationis the classic minimum, and a missingVary: Authorizationon 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-Afterand 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=paidreturns a full unfiltered collection, which at best is confusing and at worst leaks data the user should not see.400with 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 calledsort, 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=trueis a boolean,?total.gte=10.5a decimal. Reject?active=yesrather 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 bycreatedAtwhen 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 100000makes 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
sortmid-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=truefor the screens that genuinely need it. - Or return
hasMore(fetchlimit + 1rows 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." }
]
}
typeis 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 oftitle/detail. - Field errors use JSON Pointer (RFC 6901) so clients can map them onto form fields without string parsing.
detailis for humans and must be safe to display. No stack traces, no SQL, no internal hostnames. Log those; do not return them.- Never return
200with 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/302from/ordersto/orders/makes many clients re-issue asGETand drop the body -- and some drop theAuthorizationheader across redirects. Pick one path form and return404for the other, or use308, which preserves method and body. GETwith a body. Allowed by the letter of the spec, dropped by proxies, CDNs and some client libraries in practice. If you need a body, usePOST /search.- JSON numbers are doubles in JavaScript. An
int64id 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.
nullvs 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
500in many stacks. Map them to400or 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:
operationIdon 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. requiredand 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
formaton primitives.format: int64,format: date-time,format: uuid,format: decimal. Without them everything becomes astringor a 32-bitint, 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 returnsproblem+json, no inline schemas, descriptions present. oasdiff breakingcompares 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,Model1in generated client code -- unnamed schemas upstream.oneOfwithout adiscriminator-- generators cannot pick a type and fall back toobject.additionalProperties: trueon a response model -- the client gets an untyped dictionary.type: objectwith noproperties-- documents nothing.- Enums with no documented unknown-value policy (see section 9).
- An operation with exactly one documented response.
examplevalues that would not validate against their own schema. They end up in docs and in people's copy-pasted tests.
Checklist
- Paths are plural nouns; no verbs; one nesting level; one casing convention.
- Non-CRUD operations are action sub-resources or first-class resources, never a magic field in PATCH.
GET/HEAD/OPTIONSare side-effect free.PUT/DELETEare idempotent. Deleting twice returns204.- PUT replaces in full; omitted fields are cleared. PATCH declares its format via
Content-Typeand rejects others with415. - Partial updates distinguish absent from null, end to end -- DTO, validation and persistence.
- Unsafe methods require
If-Match;412when stale,428when absent. Single-resource GETs returnETag. - Status codes distinguish 400/422, 401/403, and
404-for-hidden.201carriesLocation;202points at a status resource. Varyis set wherever the response varies -- especiallyAuthorization.Cache-Controlis explicit on every response.- Write endpoints accept
Idempotency-Key.429/503carryRetry-After. Access-Control-Expose-Headerslists every header a browser client must read.- Unknown query parameters are rejected. Sortable and expandable fields are allow-listed.
- Pagination is cursor-based with an opaque cursor, a unique tiebreaker in the sort, a clamped
limit, and no unconditionalCOUNT(*). - Collections return
{ "data": [...] }, never a bare array. - Errors are RFC 9457
application/problem+jsonwith a stabletypeURI, a machinecode, and JSON Pointer field errors. Never200with an error body. - Large integer ids serialise as strings; timestamps are RFC 3339 UTC; money is minor units or a string.
- Every operation has a hand-written
operationId; every schema has a name;requiredand nullability are correct; formats are explicit. - Every operation documents its error responses,
$ref-ed fromcomponents/responses. - 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.