REST API Design
REST API Design
REST API design patterns provide the vocabulary for evaluating API maturity, standardising error contracts, managing versioning lifecycle, and choosing between contract-first and code-first workflows.
Intent
REST APIs require design-level decisions beyond "use HTTP methods correctly." This note covers four complementary design patterns: (1) the Richardson Maturity Model for evaluating how well an API uses HTTP, (2) RFC 9457 for standardised, machine-readable error responses, (3) versioning strategy selection and public API lifecycle management, and (4) the OpenAPI contract-first workflow for parallel client/server development. Each is a decision framework, not an HTTP tutorial.
These patterns answer the questions a team faces before and after an API ships: which REST maturity level to target, how to structure errors so clients can handle them programmatically, how to evolve the API without breaking consumers, and how to design contracts before writing code.
When NOT to Use
- Internal service-to-service communication where gRPC or messaging is more appropriate — REST adds serialisation overhead and request-response latency when you control both sides of the wire and need streaming, strict binary contracts, or high-throughput paths. gRPC or a message broker is usually the better fit.
- Real-time bidirectional communication — REST request-response is the wrong model. Use WebSocket for bidirectional streaming or Server-Sent Events (SSE) for server-push. REST can trigger SSE streams but cannot replace them.
- High-throughput binary protocols — REST's text-based JSON payloads add serialisation overhead compared to protobuf. When throughput or payload size is the dominant constraint, gRPC or a binary message format is more appropriate.
When to Use
- Public-facing APIs where discoverability and tooling matter — REST + OpenAPI is the industry default for third-party integrations
- Client-server communication where request-response semantics fit naturally (CRUD operations, query endpoints, action endpoints)
- Systems targeting Level 2 RMM (HTTP verbs + resources) as the pragmatic standard for new APIs
- APIs consumed by unknown or diverse clients (browsers, mobile apps, third-party integrations) where HTTP semantics provide a universal contract
How It Works
Richardson Maturity Model (REST-01)
The Richardson Maturity Model, articulated by Martin Fowler and Leonard Richardson in 2010, provides four levels for evaluating how well an API uses HTTP as its application protocol.
| Level | Descriptor | Key Feature | Industry Reality |
|---|---|---|---|
| 0 | HTTP Tunnel | POST everything to one endpoint | Legacy SOAP-style systems |
| 1 | Resources | Separate URIs per resource | Minimum for any new API |
| 2 | HTTP Verbs | GET/POST/PUT/DELETE with correct semantics | De-facto industry standard |
| 3 | HATEOAS | Hypermedia links drive state transitions | Theoretically pure; rarely in production |
Level 0 — HTTP Tunnel: A single endpoint receives all requests, differentiated by a payload field (e.g., action: "getOrder"). This is the SOAP/XML-RPC pattern. REST tooling, caching, and HTTP-level error handling do not apply. Avoid in any new system.
Level 1 — Resources: Separate URIs per resource (/orders, /orders/42). Resources are named and addressable. This is the minimum for any new API — HTTP semantics are not yet exploited but the resource model is in place.
Level 2 — HTTP Verbs: HTTP methods carry semantic meaning — GET retrieves and is safe and idempotent, POST creates, PUT replaces and is idempotent, DELETE removes and is idempotent, PATCH partially updates. HTTP status codes signal outcomes. Level 2 is what 95%+ of production APIs use. It is the pragmatic standard. Most teams should target Level 2 and stop there — HATEOAS adds significant complexity for limited practical benefit in the majority of use cases.
Level 3 — HATEOAS (Hypermedia As The Engine Of Application State): The server includes hypermedia links in each response that describe the available transitions from the current state. Clients discover actions from responses rather than hardcoding URLs or action logic.
HATEOAS is rarely used in production — be honest about this. Level 2 is the industry default. HATEOAS genuinely helps in specific contexts:
- Public APIs with unknown clients where clients must discover available actions without hardcoding URLs
- APIs where client state machines must stay in sync with server — for example, a workflow API where "cancel" is only available in certain states; the server embeds or omits the
cancellink based on current state, eliminating the need for clients to replicate state machine logic
Common hypermedia formats: HAL (application/hal+json), Siren, JSON:API. No single format is universally correct — choose based on ecosystem tooling and client needs.
Content negotiation at Level 3: Content negotiation is the HTTP mechanism that enables HATEOAS alongside plain JSON. A client sends Accept: application/hal+json to request hypermedia links, or Accept: application/json for a plain response. The server responds with the appropriate representation. Content negotiation is also used as a versioning strategy — see Versioning Strategies below.
When Level 2 is the correct choice: Most production APIs stop at Level 2. HATEOAS requires clients to implement hypermedia parsing, server teams to maintain link construction logic, and both sides to agree on link relation semantics. Unless the API is targeting a genuinely unknown client population or managing complex server-side state transitions, Level 2 provides the best tradeoff between REST purity and implementation cost.
Error Contracts — RFC 9457 (REST-02)
RFC 9457 (IETF, 2023) defines the standard for machine-readable HTTP error responses. It supersedes RFC 7807 (2016). The media type is application/problem+json.
Five standard fields:
| Field | Type | Purpose |
|---|---|---|
type | URI | Machine-readable problem type identifier (relative or absolute URI) |
title | string | Human-readable summary — same for all instances of the same type |
status | integer | HTTP status code (mirrors the HTTP response status) |
detail | string | Human-readable explanation specific to this occurrence |
instance | URI | URI identifying this specific occurrence (for support/logging correlation) |
4xx vs 5xx distinction:
4xx(client error) — the request is malformed, invalid, or unauthorized. The client can fix the problem by correcting the request. Example:422 Unprocessable Entityfor validation failures,404 Not Foundfor missing resources,401 Unauthorizedfor missing credentials.5xx(server error) — the server failed to process a valid request. The client cannot fix the problem. The client should retry with exponential backoff (503 Service Unavailable) or report the error (500 Internal Server Error).
Extension fields: RFC 9457 allows custom fields in the problem object. A common pattern is an invalidParams array for validation errors, listing each field and the specific validation failure. Custom fields extend the standard contract without breaking RFC 9457 compliance.
Implementation notes:
- RFC 9457 supersedes RFC 7807 (2023). All new implementations should cite and implement RFC 9457.
- Spring Boot 3.x ships
ProblemDetailnatively (Spring Framework 6.0+). Enable viaspring.mvc.problemdetails.enabled=trueor useProblemDetaildirectly in exception handlers. - The
instancefield (added in RFC 9457 as a standard field) provides a correlation ID for support and logging — include it.
Spring Framework 6+ ships ProblemDetail with built-in RFC 9457 support. A custom class
requires maintenance, lacks @ControllerAdvice auto-mapping, and risks diverging from
the standard. Use ProblemDetail directly.
Versioning Strategies (REST-03)
Four strategies for versioning a public REST API, each with distinct tradeoffs:
| Strategy | Example | Cache-friendly | Visible in logs | Complexity |
|---|---|---|---|---|
| URI path | /api/v2/orders | Yes | Yes | Low |
| Custom header | API-Version: 2 | No (needs Vary) | No | Medium |
| Query parameter | /api/orders?version=2 | Yes | Yes | Low |
| Content negotiation | Accept: application/vnd.api.v2+json | No (needs Vary) | No | High |
Default recommendation: URI path versioning. It is explicit, cache-friendly, visible in logs, and trivially distinguishable in routing rules and access logs. This matches industry practice — Stripe, Twitter, and Salesforce all use URI path versioning for their public APIs. Query parameter versioning shares these advantages but pollutes query strings and is easy to omit accidentally. Custom header and content negotiation versioning require Vary headers for correct caching and are invisible in standard access logs.
Do not add /v1/ pre-emptively. Version when there is an actual breaking change. Pre-emptive versioning adds maintenance overhead with no benefit.
Sunset and Deprecation header lifecycle:
When deprecating an API version, two HTTP response headers signal the lifecycle to clients:
Deprecationheader (IETF draftdraft-ietf-httpapi-deprecation-header): signals that an API version is deprecated and clients should migrate. The value is the deprecation date. This is an IETF draft — confirm final RFC publication athttps://datatracker.ietf.org/doc/draft-ietf-httpapi-deprecation-header/.Sunsetheader (RFC 8594): signals when the resource will no longer be available. The value is the sunset date after which the endpoint will stop responding.
Both headers are advisory — they do not break existing clients. They inform clients to migrate. Use a Link header to point to the successor version.
Example response headers on a deprecated endpoint:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Sun, 01 Dec 2025 00:00:00 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version"
After the Sunset date, return 410 Gone with an RFC 9457 problem detail pointing to the successor version.
BFF-specific versioning strategies — including gateway-level route management and frontend environment configuration — are covered in BFF-Versioning. This note covers general public API versioning lifecycle only.
OpenAPI Contract-First Workflow (REST-04)
Contract-first design means writing the OpenAPI specification before writing any implementation code. The spec is the source of truth; server stubs and client SDKs are generated from it.
Contract-first vs code-first comparison:
| Dimension | Contract-First | Code-First |
|---|---|---|
| Source of truth | OpenAPI spec file | Code annotations (@ApiOperation etc.) |
| Workflow | Design → Validate → Generate → Implement | Implement → Annotate → Export spec |
| Parallel development | Yes — client and server teams work in parallel | No — server must exist before client SDK |
| Spec accuracy | Exact — spec drives implementation | Risk of drift — annotations may lag code |
| Upfront cost | Higher — spec review before code | Lower — start coding immediately |
Code generation tradeoffs:
Benefits: type-safe client SDKs, server stub generation, parallel team development, spec-as-documentation that is never out of sync with the actual contract.
Risks: generated code quality varies significantly by generator and target language; teams may over-generate boilerplate that becomes difficult to customise; specification discipline is required upfront — a poorly designed spec produces poor generated code.
Primary tooling: openapi-generator (primary, actively maintained, broad language support), swagger-codegen (older, the predecessor to openapi-generator).
OpenAPI version: Use OpenAPI 3.1.0 for new projects. It aligns fully with JSON Schema Draft 2020-12 and is the current production standard.
OpenAPI 3.2.0 (2025) adds a native QUERY method and streaming media types — relevant if you need SSE or MCP connector definitions in a single spec file. Tooling support (openapi-generator, Spectral, swagger-ui) may not have caught up. Default to 3.1.0; evaluate 3.2.0 only for streaming-specific use cases.
Minimal OpenAPI 3.1.0 spec fragment illustrating the contract-first concept:
# Source: OpenAPI Initiative — OpenAPI Specification 3.1.0
# https://spec.openapis.org/oas/v3.1.0.html
# Design-first: spec defines contract before any implementation
openapi: "3.1.0"
info:
title: Orders API
version: "2.0.0"
paths:
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: integer
responses:
"200":
description: Order found
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"404":
description: Order not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"This is a workflow pattern note, not an OpenAPI syntax tutorial. For syntax reference: https://spec.openapis.org/oas/v3.1.0.html.
Sequence Diagram
HATEOAS request/response cycle (Level 3 Richardson Maturity Model) -- client discovers available actions from hypermedia links in each response, never hardcoding URLs.
sequenceDiagram
participant C as Client
participant S as REST Server
Note over C,S: Level 3 HATEOAS -- links drive state transitions
C->>S: GET /orders<br/>Accept: application/hal+json
S-->>C: 200 OK<br/>{ orders: [...],<br/> _links: {<br/> self: "/orders",<br/> next: "/orders?cursor=abc",<br/> create: { href: "/orders", method: "POST" }<br/> }}
Note right of C: Client discovers create action<br/>from _links -- not hardcoded
C->>S: POST /orders (from _links.create)<br/>{ item: "Widget", qty: 2 }
S-->>C: 201 Created<br/>{ id: 42, status: "pending",<br/> _links: {<br/> self: "/orders/42",<br/> cancel: { href: "/orders/42/cancel", method: "POST" },<br/> pay: { href: "/orders/42/pay", method: "POST" }<br/> }}
Note right of C: Available actions depend on<br/>current resource state
C->>S: POST /orders/42/pay (from _links.pay)<br/>{ method: "credit_card", token: "tok_..." }
S-->>C: 200 OK<br/>{ id: 42, status: "paid",<br/> _links: {<br/> self: "/orders/42",<br/> receipt: "/orders/42/receipt"<br/> }}
Note right of C: cancel link removed --<br/>server controls valid transitions
TypeScript Examples
RFC 9457 Problem Detail (Express 5.x)
// Source: RFC 9457 section 3 — Problem Details for HTTP APIs
// Content-Type: application/problem+json
interface ProblemDetail {
type: string; // URI identifying the problem type
title: string; // Human-readable summary (stable for this type)
status: number; // HTTP status code
detail?: string; // Human-readable explanation for this occurrence
instance?: string; // URI identifying this specific occurrence
[key: string]: unknown; // Extension fields allowed
}
// Express 5 error handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
const problem: ProblemDetail = {
type: 'https://api.example.com/errors/validation-failed',
title: 'Validation Failed',
status: 422,
detail: err.message,
instance: req.path,
};
res.status(422).type('application/problem+json').json(problem);
});HATEOAS Response (Richardson Maturity Level 3)
// Richardson Maturity Level 3 — HAL response shape
interface HalLink { href: string; }
interface OrderResponse {
orderId: number;
status: string;
_links: {
self: HalLink;
cancel?: HalLink; // optional — only present when cancellation is valid
orders: HalLink;
};
}
// Express handler returning HAL — Accept: application/hal+json
app.get('/api/v2/orders/:id', (req: Request, res: Response) => {
const order = orderService.findById(Number(req.params.id));
const halResponse: OrderResponse = {
orderId: order.id,
status: order.status,
_links: {
self: { href: `/api/v2/orders/${order.id}` },
...(order.canCancel && { cancel: { href: `/api/v2/orders/${order.id}/cancel` } }),
orders: { href: '/api/v2/orders' },
},
};
res.type('application/hal+json').json(halResponse);
});Versioning with Sunset/Deprecation Headers
// URI path versioning — explicit, cache-friendly, log-visible
app.get('/api/v2/orders/:id', getOrderV2);
// Sunset header on deprecated version
app.get('/api/v1/orders/:id', (req: Request, res: Response, next: NextFunction) => {
res.set('Sunset', 'Sun, 01 Dec 2025 00:00:00 GMT');
res.set('Deprecation', 'Sun, 01 Jun 2025 00:00:00 GMT');
res.set('Link', `</api/v2/orders/${req.params.id}>; rel="successor-version"`);
getOrderV1(req, res, next);
});OpenAPI Contract-First — Code Generation
// Code generation: npx openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o ./client
// Produces type-safe client SDK from the OpenAPI spec
// No TypeScript example needed — the YAML fragment in How It Works covers the contractJava Examples
RFC 9457 Problem Detail (Spring Boot 3.x)
// Source: Spring Framework 6 ProblemDetail — built-in RFC 9457 support
// spring.mvc.problemdetails.enabled=true in application.properties
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, "Validation failed");
problem.setType(URI.create("https://api.example.com/errors/validation-failed"));
problem.setProperty("invalidFields",
ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.toList());
return problem;
}
}HATEOAS Level 3 (Spring HATEOAS)
// Source: Spring HATEOAS — HAL (application/hal+json) link construction
// Shows Richardson Maturity Level 3 response with _links
// Response: { "orderId": 42, "_links": { "self": {...}, "cancel": {...}, "orders": {...} } }
@GetMapping("/orders/{id}")
EntityModel<OrderView> getOrder(@PathVariable Long id) {
OrderView order = orderService.findById(id);
return EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrder(id)).withSelfRel(),
linkTo(methodOn(OrderController.class).cancelOrder(id)).withRel("cancel"),
linkTo(methodOn(OrderController.class).listOrders()).withRel("orders")
);
}Versioning (Spring MVC, URI path)
// URI path versioning — industry default
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
OrderView getOrder(@PathVariable Long id) { /* ... */ }
}
// Deprecated v1 endpoint with Sunset and Deprecation headers
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
ResponseEntity<OrderView> getOrder(@PathVariable Long id) {
return ResponseEntity.ok()
.header("Sunset", "Sun, 01 Dec 2025 00:00:00 GMT")
.header("Deprecation", "Sun, 01 Jun 2025 00:00:00 GMT")
.header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"")
.body(orderServiceV1.findById(id));
}
}OpenAPI Contract-First — Code Generation
// Code generation: openapi-generator generate -i openapi.yaml -g spring -o ./server
// Produces Spring MVC controller interfaces and model classes from the OpenAPI spec
// Server team implements the generated interfaces; contract is the OpenAPI spec, not the codeLineage
This note participates in two lineage chains formalised in Phase 27 (API-Protocol-Selection-MOC):
- Chain 13 (API Contract):
OpenAPI/Contract-First → REST Maturity Model → HATEOAS— this note is the primary node for REST maturity and HATEOAS context - Chain 14 (Query Flexibility):
REST → GraphQL (field selection) → Federation (distributed graph)— this note anchors the start of the query flexibility chain
Full chain tables live in the Phase 27 MOC.
Related Concepts
| Pattern | Relationship |
|---|---|
| BFF-Pattern | BFF typically exposes a REST API to its frontend client; REST design patterns apply directly to BFF contract design |
| BFF-Versioning | BFF-specific versioning strategies (gateway-level route management, frontend environment configuration); this note covers general public API versioning lifecycle |
| API-Gateway-Pattern | API gateways route, aggregate, and version REST APIs; URI path versioning is trivially distinguishable in gateway routing rules |
| Idempotent-Consumer | HTTP idempotency keys (client-driven UUID header for safe retries) vs messaging-layer Idempotent Consumer (server-side deduplication store) — distinct patterns, often confused |
| Facade-Pattern | REST API resource models often act as facades over complex domain models, projecting a simplified surface to clients |
| CQRS-Pattern | CQRS command/query separation maps naturally to REST verb semantics — GET maps to query, POST/PUT/DELETE map to commands |
Sources
- RFC 9457 — Problem Details for HTTP APIs (IETF, 2023): https://datatracker.ietf.org/doc/html/rfc9457
- RFC 9110 — HTTP Semantics (IETF, 2022): https://datatracker.ietf.org/doc/html/rfc9110
- RFC 8594 — The Sunset HTTP Header Field (IETF, 2019): https://datatracker.ietf.org/doc/html/rfc8594
- RFC 8288 — Web Linking (IETF, 2017): https://datatracker.ietf.org/doc/html/rfc8288
- "Richardson Maturity Model" — martinfowler.com (Fowler, 2010): https://martinfowler.com/articles/richardsonMaturityModel.html
- OpenAPI Specification 3.1.0 (OpenAPI Initiative, 2021): https://spec.openapis.org/oas/v3.1.0.html
- Spring Framework 6+ ProblemDetail documentation: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html
- Spring HATEOAS 2.x reference documentation: https://docs.spring.io/spring-hateoas/docs/current/reference/html/