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

From contract to generated client

The patterns above decide what the wire contract says. Two further concerns decide what that contract is worth to the people consuming it: how the schemas are composed, and how the client libraries that embody them are produced.

Composition is where a clean resource model either survives contact with real payloads or dissolves into a schema where every property is optional. A Payment that is really four different shapes needs OpenAPI Schema CompositionallOf to factor the fields every variant shares, oneOf to state that a payload is exactly one variant, and a discriminator so tooling can select the right branch from a single property lookup. Get that right and a generated TypeScript client hands callers a union the compiler can narrow; get it wrong and every call site needs a cast and a runtime check.

Generation is the second concern. A client produced once and hand-edited for eighteen months has silently become a second, undocumented API. Client SDK Generation Pipelines covers the machinery that keeps it honest: one bundled input document, a pinned generator version, a clean output directory, and a CI check that regenerates on every pull request and fails when the committed client no longer matches the spec. Once regeneration is a non-event, hand edits stop being tempting.

Both depend on naming decisions made much earlier. An operation without an operationId becomes a method named after its URL, so a later path change renames it for every consumer; a schema defined inline instead of under components/schemas becomes InlineObject7 and churns whenever endpoints are reordered. Those are contract decisions with client-facing consequences, which is why they belong here rather than in a build script.

From design decisions to generated client surface Resource modeling, method semantics and schema composition combine into the OpenAPI document. The generator turns that document into typed clients. Operation identifiers and component names determine the readability of the resulting method and type names. resource modeling method semantics schema composition OpenAPI document the single input generator pinned version TypeScript Python Go operationId + component names decide every methodand type name

How the sections fit together

The topics in this reference are ordered by how expensive they are to change later. Resource boundaries and URL shapes are the most expensive: they appear in customer configuration, in cached links and in every integration’s code, so Resource Modeling Best Practices comes first and the plurality decision — collection or singleton — is worth arguing about before the first release rather than after.

Method semantics come next, because they determine what a client may safely retry. HTTP Method Mapping Guidelines covers the mapping, and the two places it interacts with everything else are idempotency and caching: Idempotency Key Implementation makes a POST safe to repeat, while Statelessness & Caching Strategies makes a GET cheap to repeat and a PUT safe to serialise against concurrent writers.

Around all of it sits governance. Contract Linting & Governance is what stops the rules in this reference from being advice: a naming convention that is not enforced in CI is a naming convention that drifts within two quarters, and an error shape that is not linted becomes three error shapes across three services. The linting rules and the generation pipeline reinforce each other — the lint guarantees the inputs the generator needs, and the generated client is where a violation becomes visible to a developer.

What contract-first actually costs

Contract-first design is often sold as free, and it is not. Writing the specification before the handler adds a step to every feature, and a team shipping its first release will move faster by writing code and generating a document afterwards. The argument for paying the cost anyway is about the second year, not the first.

Three costs are real and worth naming. The specification must be reviewed like code, or it becomes a document that describes what someone intended in a meeting; that means a linting gate, a reviewer who reads it, and a build that fails when it drifts. The generated clients must be regenerated continuously rather than occasionally, or the divergence they were meant to prevent reappears one hand-edit at a time. And the team must resist adding fields to the implementation before adding them to the contract, which is a discipline problem rather than a tooling one.

What you buy is the ability to change the API deliberately. A documented contract can be diffed, so a breaking change is detected before it merges rather than reported by a consumer. It can be linted, so a naming convention holds across forty services instead of eroding. It can be generated from, so a new consumer integrates in an afternoon with a typed client instead of a week with a wiki page. Each of those is worth more than the step it costs, but only once the API has more than one consumer and more than one author — which is precisely when most teams discover they needed it.

Where each workflow detects a problem In a code-first workflow the handler changes, the document is regenerated afterwards and consumers discover the break in production. In a contract-first workflow the document changes first, a diff classifies it before merge and consumers are never exposed. code-first contract-first handler edited doc regenerated deployed break found by a consumer document edited lint + diff break found before merge handler + client

Two habits make the cost smaller than it first appears. Write the specification for the endpoint you are about to build rather than for the quarter ahead, so review stays proportionate to the change. And keep the generated client committed, so that a contract change arrives in review as a readable diff of method signatures and types rather than as an abstract YAML edit whose consequences nobody can picture.

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.