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

  1. 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.
  2. 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.
  3. 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.

LevelDescriptorKey FeatureIndustry Reality
0HTTP TunnelPOST everything to one endpointLegacy SOAP-style systems
1ResourcesSeparate URIs per resourceMinimum for any new API
2HTTP VerbsGET/POST/PUT/DELETE with correct semanticsDe-facto industry standard
3HATEOASHypermedia links drive state transitionsTheoretically 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 cancel link 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:

FieldTypePurpose
typeURIMachine-readable problem type identifier (relative or absolute URI)
titlestringHuman-readable summary — same for all instances of the same type
statusintegerHTTP status code (mirrors the HTTP response status)
detailstringHuman-readable explanation specific to this occurrence
instanceURIURI 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 Entity for validation failures, 404 Not Found for missing resources, 401 Unauthorized for 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 ProblemDetail natively (Spring Framework 6.0+). Enable via spring.mvc.problemdetails.enabled=true or use ProblemDetail directly in exception handlers.
  • The instance field (added in RFC 9457 as a standard field) provides a correlation ID for support and logging — include it.
Do not hand-roll a custom error response class in Java

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:

StrategyExampleCache-friendlyVisible in logsComplexity
URI path/api/v2/ordersYesYesLow
Custom headerAPI-Version: 2No (needs Vary)NoMedium
Query parameter/api/orders?version=2YesYesLow
Content negotiationAccept: application/vnd.api.v2+jsonNo (needs Vary)NoHigh

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:

  • Deprecation header (IETF draft draft-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 at https://datatracker.ietf.org/doc/draft-ietf-httpapi-deprecation-header/.
  • Sunset header (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:

DimensionContract-FirstCode-First
Source of truthOpenAPI spec fileCode annotations (@ApiOperation etc.)
WorkflowDesign → Validate → Generate → ImplementImplement → Annotate → Export spec
Parallel developmentYes — client and server teams work in parallelNo — server must exist before client SDK
Spec accuracyExact — spec drives implementationRisk of drift — annotations may lag code
Upfront costHigher — spec review before codeLower — 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

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 contract

Java 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 code

Lineage

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.

PatternRelationship
BFF-PatternBFF typically exposes a REST API to its frontend client; REST design patterns apply directly to BFF contract design
BFF-VersioningBFF-specific versioning strategies (gateway-level route management, frontend environment configuration); this note covers general public API versioning lifecycle
API-Gateway-PatternAPI gateways route, aggregate, and version REST APIs; URI path versioning is trivially distinguishable in gateway routing rules
Idempotent-ConsumerHTTP idempotency keys (client-driven UUID header for safe retries) vs messaging-layer Idempotent Consumer (server-side deduplication store) — distinct patterns, often confused
Facade-PatternREST API resource models often act as facades over complex domain models, projecting a simplified surface to clients
CQRS-PatternCQRS command/query separation maps naturally to REST verb semantics — GET maps to query, POST/PUT/DELETE map to commands

Sources