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:

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.

Contract-first API lifecycle A left-to-right flow diagram showing five stages: OpenAPI Spec commit, CI Lint and Validate, Mock Server and Tests, SDK Generation, and Runtime Client. Arrows connect each stage in sequence. OpenAPI 3.1 Spec Commit CI Lint & Validate (Spectral) Mock Server + Contract Tests SDK Generation (TS / Python / Go) Runtime Client Typed & Versioned source of truth blocks merge on fail pre-merge gate publish to registry consumers pin version Resource Modeling + HTTP Methods Idempotency + Caching

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:

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.