Skip to main content

Resource Modeling & HTTP Semantics

1. Modeling Resources

Start from consumer tasks, not tables. A resource is a concept clients name, which may span several tables or be computed.

SmellSymptomBetter
CRUD over tables/order_items, /order_headers exposedOne Order resource with embedded items
Chatty clientsMobile needs 6 calls to render a screenEmbedded/expandable fields, or a BFF (Backend for Frontend)
RPC in disguisePOST /doEverythingResources + explicit state transitions
God resource120-field UserSplit by lifecycle/ownership: /users/me/profile, /users/me/preferences
Leaky internalsFields named after columns/ORM entitiesStable domain vocabulary

Embedding vs Linking vs Expanding

Compact: { "id": "ord_1", "customerId": "cus_9" }
Expanded: GET /orders/ord_1?expand=customer
{ "id": "ord_1", "customer": { "id": "cus_9", "name": "Ada" } }

Rules: default compact; allow expand for named relations (with a depth limit); never change the type of a field by context without documenting the union (string | object).

2. HTTP Method Semantics

MethodSafeIdempotentCacheableTypical use
GETYesYesYesRead
HEADYesYesYesMetadata
OPTIONSYesYesNoCapabilities / CORS preflight
PUTNoYesNoFull replace / create at known URL
DELETENoYesNoRemove (second call returns 404 or 204 - still idempotent in effect)
POSTNoNo (unless keyed)RarelyCreate in collection, commands
PATCHNoNo by definition (can be made so)NoPartial update

Safe = no observable state change requested. Idempotent = N identical requests have the same effect as one. Idempotent does not mean same response.

Gotcha: GET with side effects (GET /unsubscribe?token=) gets triggered by email scanners, crawlers and prefetchers. Always use POST for state changes.

3. PUT vs PATCH

AspectPUTPATCH (JSON Merge Patch, RFC 7396)PATCH (JSON Patch, RFC 6902)
SemanticsReplace entire resourceMerge sent fields; null deletesOrdered list of operations
Content-Typeapplication/jsonapplication/merge-patch+jsonapplication/json-patch+json
IdempotentYesYes (usually)Depends (add to array is not)
Clear a fieldOmit it{"nickname": null}{"op":"remove","path":"/nickname"}
Array updateSend entire arrayReplaces whole arrayIndex/test ops - fragile under concurrency
PitfallLost updates; clients drop unknown fieldsCannot set a field to JSON null meaningfullyIndex-based paths shift
PATCH /customers/cus_9 HTTP/1.1
Content-Type: application/merge-patch+json
If-Match: "v7"

{ "phone": "+84901234567", "nickname": null }

In Java, distinguish absent from null (JsonNullable<T> from jackson-databind-nullable, or Optional<Optional<T>>), otherwise PATCH cannot tell "don't touch" from "clear".

4. Choosing Status Codes

SituationCodeNotes
Read OK200
Created201 + Location headerReturn the resource or an empty body
Accepted for async processing202 + status URLNot finished; see LRO below
Success, no body204DELETE, PUT/PATCH when not returning the entity
Not modified (conditional GET)304Empty body
Malformed syntax, unparseable400Invalid JSON, wrong type
Not authenticated401Misnamed: means unauthenticated; include WWW-Authenticate
Authenticated, not allowed403Or 404 to hide existence
Not found404
Method not supported on resource405 + Allow
Not acceptable media type406Accept mismatch
Conflict with current state409Duplicate, illegal transition, idempotency-key payload mismatch
Precondition failed412If-Match ETag mismatch
Unsupported media type415
Semantic validation error422 (or 400 - choose and be consistent)Field-level errors in problem body
Precondition required428Require If-Match on updates
Too many requests429 + Retry-After
Server bug500Never leak stack traces
Bad upstream502 / 504
Overloaded / maintenance503 + Retry-After

400 vs 422: pick one rule - "400 = request could not be parsed; 422 = parsed but violates business/validation rules" is common and precise.

5. Conditional Requests & Optimistic Concurrency

GET /orders/ord_1 -> 200 ETag: "v7"
PUT /orders/ord_1 If-Match: "v7" -> 200 (new ETag "v8")
PUT /orders/ord_1 If-Match: "v7" -> 412 Precondition Failed (someone else updated)
GET /orders/ord_1 If-None-Match: "v8" -> 304 Not Modified (saves bandwidth)

ETag sources: DB row version column (JPA @Version), a content hash, or updatedAt at ms precision. Use strong ETags for concurrency and require them with 428.

@PutMapping("/{id}")
public ResponseEntity<OrderResponse> replace(@PathVariable String id,
@RequestHeader(value = "If-Match", required = false) String ifMatch,
@Valid @RequestBody ReplaceOrderRequest body) {
if (ifMatch == null) {
throw new ResponseStatusException(HttpStatus.PRECONDITION_REQUIRED, "If-Match header required");
}
OrderResponse updated = service.replace(id, parseVersion(ifMatch), body); // throws on mismatch -> 412
return ResponseEntity.ok().eTag("\"" + updated.version() + "\"").body(updated);
}

6. Long-Running Operations (LRO)

Never hold an HTTP request open for minutes (gateway/LB timeouts at 30-60 s, thread exhaustion).

POST /reports -> 202 Accepted
Location: /operations/op_77
Retry-After: 5
GET /operations/op_77 -> 200 { "status": "RUNNING", "progressPercent": 40 }
GET /operations/op_77 -> 200 { "status": "SUCCEEDED", "resultUrl": "/reports/rep_5" }
(or 303 See Other -> /reports/rep_5)
DELETE /operations/op_77 -> cancel (best-effort)

Operation resource fields: id, status (PENDING|RUNNING|SUCCEEDED|FAILED|CANCELLED), createdAt, error (problem object), result. Alternative: push completion via a signed webhook. Store operations durably (DB/outbox), not in memory, so they survive restarts.

7. Bulk & Batch APIs

ApproachExampleTrade-off
Native batch endpointPOST /orders:batchCreate with items[]Efficient; define partial-failure semantics
JSON:API/OData $batchMultipart of sub-requestsComplex, hard to cache
Client-side concurrencyMany single callsSimple; subject to rate limits

Partial failure contract: return 207 Multi-Status-style body (per-item status) or all-or-nothing with 422 - state which in the spec. Cap batch size (maxItems: 100). Each item needs its own idempotency key or the batch gets one key with deterministic per-item keys.

8. Hypermedia (HATEOAS) - Pragmatic View

LevelMeaningReality
0One endpoint, RPCSOAP-style
1ResourcesMost "REST" APIs
2HTTP verbs + status codesThe practical target
3Hypermedia controls (links)Valuable for workflow-driven APIs (payments next-actions, pagination next links); rarely worth it for CRUD

Use links where the server must steer the client (state machines: _links.cancel present only if cancellable) and for pagination. Don't build a full HAL/Siren client unless consumers genuinely follow links.

9. Content Negotiation & Compression

  • Accept / Content-Type for format and versioned media types (application/vnd.acme.order.v2+json).
  • Accept-Encoding: gzip, br: compress JSON (typically 70-90%); avoid compressing responses containing secrets reflected from the request (BREACH).
  • Vary: Accept, Accept-Encoding so shared caches key correctly.

10. Caching

HeaderUse
Cache-Control: private, max-age=60Per-user data
Cache-Control: public, max-age=300, stale-while-revalidate=60Shared reference data
Cache-Control: no-storeSensitive data (tokens, PII)
ETag + If-None-MatchCheap revalidation
Vary: AuthorizationPrevent cross-user cache poisoning on shared caches

Next: Errors, Pagination & Filtering.

πŸ“–
Track Page Progress0 / 635 Read
Knowledge Base Completion0%