API Design Fundamentals & Architecture
Poorly bounded APIs accumulate breaking changes, force clients into defensive workarounds, and make horizontal scaling brittle. This reference covers the architectural decisions — contract-first specification, strict HTTP semantics, resource modeling, idempotency, caching, and CI enforcement — that prevent those failure modes; it is written for backend engineers and platform teams shipping production REST APIs.
Architecture Overview
A contract-first architecture treats the OpenAPI 3.1.0 (or AsyncAPI) specification as the single, version-controlled source of truth. The implementation is derived from the contract, not the other way around. This reversal of the usual order has a compounding benefit: frontend teams, SDK consumers, and integration partners can all work from the same frozen spec snapshot while the server-side implementation is still in progress.
The sections below map to four specialist areas, each with its own dedicated reference:
- Resource Modeling Best Practices — noun-based URI hierarchies, versioning strategies, and pagination contracts
- HTTP Method Mapping Guidelines — safe/idempotent guarantees, status code mapping, and conditional request headers
- Statelessness & Caching Strategies — bearer-token auth flows,
Cache-Controldirectives,ETagvalidation, and field projection - Idempotency Key Implementation — exactly-once semantics for non-idempotent
POSToperations, storage patterns, and TTL management
The diagram below shows how these areas relate to the full contract lifecycle — from spec commit through CI gates to SDK publication and runtime client execution.
Spec / Schema Definition
The specification is not documentation generated after the fact — it is the boundary that the implementation must satisfy. Below is a minimal but complete OpenAPI 3.1.0 file that demonstrates the key structural decisions: DTO composition with allOf, public/internal boundary marking via vendor extensions, OAuth2 authorization-code flow, and a webhook definition for event-driven consumers.
openapi: 3.1.0
info:
title: Platform Core API
version: 2.0.0
x-public: true # marks this spec as externally publishable
components:
securitySchemes:
oauth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://auth.example.com/authorize
tokenUrl: https://auth.example.com/token
scopes:
read:resources: Read access to resources
write:resources: Create and mutate resources
schemas:
# -- Base schema shared by all resources --
BaseResource:
type: object
required: [id, created_at]
properties:
id:
type: string
format: uuid # stable, opaque, sortable
created_at:
type: string
format: date-time # RFC 3339 / ISO 8601
# -- Public DTO: safe to expose to third-party consumers --
PublicResourceDTO:
allOf:
- $ref: '#/components/schemas/BaseResource'
- type: object
required: [name]
properties:
name:
type: string
maxLength: 200
x-public: true # tooling can strip x-internal schemas before publishing
# -- Internal schema: never serialised in public responses --
InternalAuditLog:
allOf:
- $ref: '#/components/schemas/BaseResource'
- type: object
properties:
actor_id: { type: string, format: uuid }
delta: { type: object } # JSON diff of changed fields
x-internal: true
webhooks:
resourceUpdated:
post:
operationId: onResourceUpdated
summary: Notifies subscribers of state changes to a resource
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PublicResourceDTO'
responses:
'200':
description: Event acknowledged; retry will not be attempted
Key decisions in this spec:
allOfcomposition keeps theBaseResourcefields DRY across every schema; code generators will inline them correctly.x-public/x-internalextensions let a CI step strip confidential schemas before publishing to a developer portal or SDK toolchain.- The webhook block creates a typed contract for event consumers — the same
PublicResourceDTOshape used in REST responses is reused here, ensuring that webhook subscribers get the same DTO as REST clients.
Core Pattern 1 — Resource Modeling & URI Structure
Follow Resource Modeling Best Practices to avoid the two most common URI design mistakes: embedding verbs in paths and flattening nested relationships into ambiguous flat endpoints.
Canonical URI rules:
| Rule | Wrong | Correct |
|---|---|---|
| Nouns, not verbs | POST /createOrder |
POST /orders |
| Nested identity | GET /orders?userId=42 |
GET /users/42/orders |
| Versioning | /orders (no version) |
/v2/orders or Accept: application/vnd.api.v2+json |
| Pagination | GET /orders?page=3 |
GET /orders?cursor=eyJpZCI6MTAwfQ |
Cursor-based pagination prevents the “shifting page” problem inherent to offset strategies: when a row is inserted between requests, every subsequent offset page drifts by one. The cursor encodes the position of the last item seen, typically a base64-encoded JSON object: {"id": 100, "created_at": "2026-06-01T00:00:00Z"}.
TypeScript example — generating and decoding a cursor on the server:
// server/pagination.ts
export interface CursorPayload {
id: string;
created_at: string;
}
export function encodeCursor(payload: CursorPayload): string {
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
export function decodeCursor(cursor: string): CursorPayload {
try {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw new Error('Invalid pagination cursor');
}
}
// Usage in a route handler
export async function listOrders(req: Request, res: Response) {
const after = req.query.cursor
? decodeCursor(req.query.cursor as string)
: null;
const rows = await db.orders.findMany({
where: after
? { created_at: { lt: after.created_at } }
: undefined,
orderBy: { created_at: 'desc' },
take: 25,
});
const nextCursor = rows.length === 25
? encodeCursor({ id: rows[24].id, created_at: rows[24].created_at })
: null;
res.json({ data: rows, next_cursor: nextCursor });
}
Python equivalent using FastAPI:
# server/pagination.py
import base64, json
from typing import Optional
from fastapi import APIRouter, Query
router = APIRouter()
def encode_cursor(payload: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
def decode_cursor(cursor: str) -> dict:
try:
return json.loads(base64.urlsafe_b64decode(cursor).decode())
except Exception:
raise ValueError("Invalid pagination cursor")
@router.get("/orders")
async def list_orders(cursor: Optional[str] = Query(None)):
after = decode_cursor(cursor) if cursor else None
rows = await fetch_orders(after_cursor=after, limit=25)
next_cursor = encode_cursor({"id": rows[-1]["id"], "created_at": rows[-1]["created_at"]}) \
if len(rows) == 25 else None
return {"data": rows, "next_cursor": next_cursor}
Core Pattern 2 — HTTP Method Semantics
Consistent client-side retry logic depends on strict HTTP verb semantics. The HTTP Method Mapping Guidelines reference covers edge cases; the table below captures the baseline contract every API must honour.
| Method | Safe | Idempotent | Success codes | Body in request |
|---|---|---|---|---|
GET |
Yes | Yes | 200 |
No |
HEAD |
Yes | Yes | 200 |
No |
POST |
No | No | 201, 202 |
Yes |
PUT |
No | Yes | 200, 204 |
Yes (full) |
PATCH |
No | Conditional | 200, 204 |
Yes (partial) |
DELETE |
No | Yes | 200, 204 |
Optional |
Conditional updates with If-Match prevent lost-update races on PUT and PATCH. The client includes the ETag received on the previous GET; the server rejects the update with 412 Precondition Failed if the resource has changed in the interim.
// client — conditional PATCH
async function patchResource(id: string, patch: Partial<Resource>, etag: string) {
const res = await fetch(`/v2/resources/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
'If-Match': etag,
},
body: JSON.stringify(patch),
});
if (res.status === 412) throw new Error('Conflict: resource modified by another client');
if (!res.ok) throw new Error(`Unexpected status ${res.status}`);
return res.json();
}
# client — conditional PATCH (httpx)
import httpx
async def patch_resource(id: str, patch: dict, etag: str):
async with httpx.AsyncClient() as client:
r = await client.patch(
f"/v2/resources/{id}",
headers={"Content-Type": "application/merge-patch+json", "If-Match": etag},
json=patch,
)
if r.status_code == 412:
raise RuntimeError("Conflict: resource modified by another client")
r.raise_for_status()
return r.json()
Core Pattern 3 — Idempotency for Non-Idempotent Operations
Network retries on POST endpoints create duplicate records unless the server implements explicit deduplication. Idempotency Key Implementation covers storage patterns and TTL management in depth. The minimum viable implementation stores a hash of (idempotency_key, user_id) in a durable store and replays the cached response on repeat calls within the TTL window.
// middleware/idempotency.ts
import { createHash } from 'crypto';
import { redis } from './redis';
export async function idempotencyMiddleware(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key || req.method !== 'POST') return next();
const cacheKey = `idem:${createHash('sha256')
.update(`${key}:${req.user.id}`)
.digest('hex')}`;
const cached = await redis.get(cacheKey);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
// Intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode < 500) {
redis.setex(cacheKey, 86400, JSON.stringify({ status: res.statusCode, body }));
}
return originalJson(body);
};
next();
}
Clients should generate a UUIDv4 key per logical operation and include it via the Idempotency-Key request header. The key must survive retries: generate it once before the first attempt and reuse it on every retry of the same operation.
# client — idempotent POST (Python)
import uuid, httpx
async def create_order(payload: dict) -> dict:
idempotency_key = str(uuid.uuid4()) # generated once, reused on retry
async with httpx.AsyncClient() as client:
for attempt in range(3):
r = await client.post(
"/v2/orders",
headers={"Idempotency-Key": idempotency_key},
json=payload,
)
if r.status_code in (200, 201):
return r.json()
if r.status_code < 500:
r.raise_for_status() # 4xx: do not retry
r.raise_for_status()
CI/CD Enforcement
Contract validation belongs in the CI pipeline, not in code review. The following GitHub Actions workflow gates every merge on spec lint, mock-server integration tests, and SDK generation — in that order.
# .github/workflows/api-contract.yml
name: API Contract Validation
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint OpenAPI spec (Spectral)
run: |
npx @stoplight/spectral-cli lint api/openapi.yaml \
--ruleset .spectral.yaml \
--fail-severity warn
- name: Lint OpenAPI spec (Redocly)
run: npx @redocly/cli lint api/openapi.yaml
- name: Start Prism mock server
run: |
npx @stoplight/prism-cli mock api/openapi.yaml --port 4010 &
sleep 2 # wait for mock server to be ready
- name: Run contract integration tests
env:
API_BASE_URL: http://localhost:4010
run: npm run test:contract
- name: Generate TypeScript SDK
run: |
npx openapi-typescript api/openapi.yaml \
--output sdk/types.d.ts \
--immutable-types
- name: Generate Python models
run: |
datamodel-codegen \
--input api/openapi.yaml \
--input-file-type openapi \
--output sdk/python/models.py \
--target-python-version 3.11
- name: Publish SDK (main only)
if: github.ref == 'refs/heads/main'
run: npm publish ./sdk --access public
Add a .spectral.yaml at the repository root to enforce organisation-wide rules on top of the OAS ruleset:
# .spectral.yaml
extends:
- spectral:oas
rules:
# Every operation must have a non-empty operationId
operation-operationId: error
# Responses must declare a 4xx and a 5xx range
operation-4xx-response:
description: Operations must define at least one 4xx response
given: "$.paths[*][*].responses"
severity: error
then:
function: schema
functionOptions:
schema:
required: ["400", "401", "403", "404", "422"]
# Prohibit inline schemas on path parameters
no-inline-path-parameters:
given: "$.paths[*].parameters[?(@.in=='path')]"
severity: warn
then:
field: schema.$ref
function: truthy
SDK and Client Impact
Every structural decision in the OpenAPI spec has a downstream effect on generated client libraries. The table below maps spec choices to their SDK consequences.
| Spec decision | TypeScript SDK effect | Python SDK effect |
|---|---|---|
required on every field |
Non-optional interface properties | Non-optional Pydantic fields |
format: uuid on id |
string (no runtime UUID type) |
uuid.UUID in Pydantic v2 |
allOf composition |
Intersection types or merged interfaces | Composed Pydantic models |
nullable: true (OAS 3.0) vs type: [string, "null"] (OAS 3.1) |
string | null |
Optional[str] |
x-internal: true schemas |
Excluded if tooling strips vendor extensions | Excluded by custom generator plugin |
Webhook post operations |
Separate typed handler interface | Separate Pydantic request model |
Upgrading from OpenAPI 3.0 to 3.1.0 changes how nullable types are expressed. In 3.0, nullable: true was a boolean extension; in 3.1.0, nullability is expressed natively via a type array: type: ["string", "null"]. Generators that target 3.1.0 produce cleaner union types, but older generators still targeting 3.0 schemas require a migration step — typically a one-line change per nullable property.
Edge Cases and Anti-Patterns
| Anti-pattern | Recommended approach |
|---|---|
Verbs in URIs: POST /createOrder |
Use POST /orders — the HTTP method carries the verb |
Returning 200 OK for errors |
Use 400/422 for validation failures; 500 for server faults |
| Offset pagination on live datasets | Use cursor-based pagination to prevent shifting pages on insert/delete |
| Storing session state in app memory | Carry all auth state in bearer tokens; stateless nodes can scale horizontally |
Non-idempotent POST with no deduplication |
Require an Idempotency-Key header and cache responses in Redis with a 24-hour TTL |
| Merging breaking schema changes without a version bump | Increment the major version (/v2/) for any removal or type narrowing; use deprecated: true as a grace period |
| Hardcoding client SDKs against a specific server response shape | Generate clients from the spec; pin the SDK version to the API version |
Publishing InternalAuditLog schemas in the public spec |
Mark with x-internal: true and strip them via CI before publishing to a developer portal |
FAQ
Why adopt a contract-first approach over code-first API development?
Contract-first decouples frontend and backend delivery, enables parallel SDK generation, enforces strict type safety, and catches architectural mismatches before any implementation work begins. Schema validation in CI prevents drift between what is documented and what ships.
How do you maintain API boundaries across microservices?
Route traffic through an API gateway, enforce strict OpenAPI contracts per service, implement consumer-driven contract testing (Pact or Dredd), and version endpoints independently. Each service owns its specification and publishes it to a centralised registry for cross-service validation.
When should I use cursor-based pagination instead of offset pagination?
Use cursors whenever the dataset exceeds a few thousand rows, updates frequently, or requires stable page positions between requests. Offset pagination drifts silently when rows are inserted or deleted between pages, causing items to be skipped or repeated.
What is the standard workflow for automated client SDK generation?
Define spec → validate and lint in CI → generate mocks → run contract tests → publish typed SDKs to package registries → notify consuming teams via changelog. Every deployed API version should have a corresponding, version-pinned client library so that consumers can upgrade on their own schedule.
How should breaking changes be handled in a live API?
Use deprecated: true on the affected operation or field, publish a Sunset header with an ISO 8601 date, increment the major version for the replacement endpoint, and maintain both versions in parallel for a defined migration window (typically 6–12 months). Remove the deprecated version only after usage has dropped to zero, as confirmed by gateway telemetry.
Related
- Resource Modeling Best Practices — URI hierarchies, nested resource design, and versioning strategies
- HTTP Method Mapping Guidelines — safe/idempotent semantics, conditional headers, and status code mapping
- Statelessness & Caching Strategies —
Cache-Control,ETag, bearer-token auth, and payload projection - Idempotency Key Implementation — exactly-once POST semantics, storage patterns, and TTL configuration
- Error Contracts & Resilience Mapping — RFC 7807
problem+json, retry logic, and client fallback strategies