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:
- Frontend and backend work in parallel. Once the contract is agreed upon, teams can develop against mocks.
- Breaking changes are caught early. Spec changes require explicit approval, not accidental discovery in production.
- Documentation stays accurate. The spec is the documentation—they can't drift apart.
- 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
Paradigms: REST vs GraphQL vs RPC — When to use each, with concrete examples and trade-offs.
HTTP Best Practices — Status codes, versioning strategies, pagination patterns, and error handling for REST APIs.
Authentication Strategies — OAuth 2.0 flows, sessions vs JWTs, and security considerations.
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
- Node.js article — Server implementation with Express/Fastify
- Bun article — High-performance runtime for API servers
- Docker deployment guide — Containerizing API services
- PostgreSQL article — Database design for API backends