A

API Design Overview

apiarchitecturerestgraphqlrpcdesign-patterns

API Design Overview

An API is a contract between systems. Before writing a single line of server code, you need to decide what your API exposes, how clients interact with it, and what guarantees you provide. Poor API design creates technical debt that compounds with every integration—changing a public API is exponentially harder than changing internal code.

This section covers the fundamentals of designing robust, scalable communication layers. Whether you're building a REST API for a mobile app, a GraphQL gateway for a complex frontend, or type-safe RPC for internal microservices, the principles here apply.

The Contract-First Approach

Contract-first (or API-first) design means defining your API specification before implementing it. This inverts the typical workflow where developers build endpoints and document them afterward.

Why contract-first matters:

  1. Frontend and backend work in parallel. Once the contract is agreed upon, teams can develop against mocks.
  2. Breaking changes are caught early. Spec changes require explicit approval, not accidental discovery in production.
  3. Documentation stays accurate. The spec is the documentation—they can't drift apart.
  4. Code generation becomes possible. Client SDKs, server stubs, and validation logic can be auto-generated.

API-First Workflow

A typical API-first workflow looks like this:

1. Design    → Write OpenAPI/GraphQL schema
2. Review    → Team reviews contract for consistency, usability
3. Mock      → Generate mock server for frontend development
4. Implement → Backend implements against the spec
5. Validate  → Tests verify implementation matches spec
6. Document  → Spec auto-generates API documentation

OpenAPI (Swagger) for REST APIs

OpenAPI is the industry standard for describing REST APIs. A spec file defines endpoints, request/response shapes, authentication, and more.

# openapi.yaml
openapi: 3.1.0
info:
  title: User Service API
  version: 1.0.0
  description: Manages user accounts and profiles

servers:
  - url: https://api.example.com/v1
    description: Production
  - url: http://localhost:3000/v1
    description: Local development

paths:
  /users:
    get:
      summary: List all users
      operationId: listUsers
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
        - name: cursor
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Paginated list of users
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'
    post:
      summary: Create a new user
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /users/{userId}:
    get:
      summary: Get user by ID
      operationId: getUser
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          description: User not found

components:
  schemas:
    User:
      type: object
      required: [id, email, createdAt]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        createdAt:
          type: string
          format: date-time

    CreateUserRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
        name:
          type: string

    UserList:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/User'
        nextCursor:
          type: string
          nullable: true

    Error:
      type: object
      properties:
        code:
          type: string
        message:
          type: string

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

security:
  - bearerAuth: []

Tooling for OpenAPI

Generate client code, documentation, and mock servers from your spec:

# Generate TypeScript client with openapi-typescript
pnpm add -D openapi-typescript openapi-fetch
pnpm openapi-typescript ./openapi.yaml -o ./src/api/schema.d.ts

# Generate documentation with Redoc
pnpm add -D @redocly/cli
pnpm redocly build-docs openapi.yaml --output docs/api.html

# Run a mock server with Prism
pnpm add -D @stoplight/prism-cli
pnpm prism mock openapi.yaml

Using the generated types with openapi-fetch:

// src/api/client.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema';

const client = createClient<paths>({
  baseUrl: 'https://api.example.com/v1',
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

// Fully typed - IDE knows the response shape
const { data, error } = await client.GET('/users/{userId}', {
  params: { path: { userId: '123' } },
});

if (data) {
  console.log(data.email); // TypeScript knows this exists
}

Documentation as Interface

API documentation isn't an afterthought—it's part of the product. Developers will judge your API by how quickly they can understand and integrate it.

Essential documentation includes:

  • Getting started guide: Authentication, first API call, common workflows
  • Reference docs: Every endpoint, parameter, and response code
  • Code examples: In multiple languages if possible
  • Error guide: What errors mean and how to handle them
  • Changelog: What changed between versions

Tools like Redoc, Swagger UI, and Stoplight generate interactive docs from OpenAPI specs. For GraphQL, GraphiQL and Apollo Studio provide built-in exploration.

Paradigms Overview

This section covers three major API paradigms. Each has distinct strengths:

Paradigm Best For Key Trait
REST Public APIs, CRUD operations, caching Resource-oriented, HTTP-native
GraphQL Complex frontends, mobile apps Client-driven queries, single endpoint
RPC Internal services, TypeScript monorepos Action-oriented, type-safe contracts

See the Paradigms article for a deep comparison with code examples and decision criteria.

What This Section Covers

  1. Paradigms: REST vs GraphQL vs RPC — When to use each, with concrete examples and trade-offs.

  2. HTTP Best Practices — Status codes, versioning strategies, pagination patterns, and error handling for REST APIs.

  3. Authentication Strategies — OAuth 2.0 flows, sessions vs JWTs, and security considerations.

  4. Access Control — RBAC, ABAC, and implementing authorization in practice.

Design Principles

Regardless of which paradigm you choose, these principles improve API quality:

Consistency

Use consistent naming conventions, response shapes, and error formats. If GET /users returns { data: [...] }, then GET /posts should too.

// Consistent response envelope
interface ApiResponse<T> {
  data: T;
  meta?: {
    total?: number;
    cursor?: string;
  };
}

interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
}

Predictability

Clients should be able to guess how your API works. If DELETE /users/{id} deletes a user, then DELETE /posts/{id} should delete a post—not archive it.

Minimal Surface Area

Expose only what clients need. Every public endpoint is a commitment you'll maintain. It's easier to add endpoints than remove them.

Idempotency

Operations that can be safely retried are easier to work with. PUT and DELETE should be idempotent by design. For POST operations that create resources, consider accepting a client-generated idempotency key:

// Client sends idempotency key for safe retries
const response = await fetch('/api/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({ amount: 1000, currency: 'usd' }),
});

See Also

Last updated: March 23, 2026