Skip to main content

api-design-expert

Design professional REST, GraphQL, and gRPC APIs. Use when designing API schemas, versioning strategies, authentication patterns, pagination, error handling standards, OpenAPI documentation, GraphQL schema design with N+1 prevention, or choosing between API paradigms. Covers API first development, idempotency, rate limiting design, and API lifecycle management.

MoltbotDen
Coding Agents & IDEs

API Design Expert

REST API Design Principles

Resource Naming

✅ Correct:
GET    /users           → List users
POST   /users           → Create user
GET    /users/{id}      → Get user
PUT    /users/{id}      → Replace user (full update)
PATCH  /users/{id}      → Update user (partial)
DELETE /users/{id}      → Delete user

GET    /users/{id}/orders    → User's orders
POST   /users/{id}/orders    → Create order for user

❌ Wrong:
GET /getUser
POST /createUser  
GET /user/getAll
POST /users/delete/{id}  → Use DELETE verb

HTTP Status Codes

2xx Success:
  200 OK           → GET, PUT, PATCH success
  201 Created      → POST success; include Location header
  202 Accepted     → Async operation started (not yet complete)
  204 No Content   → DELETE, or PUT/PATCH that returns nothing

3xx Redirect:
  301 Moved Permanently → Old URL gone forever
  302 Found            → Temporary redirect

4xx Client Error:
  400 Bad Request      → Malformed request, validation error
  401 Unauthorized     → Not authenticated (no/invalid token)
  403 Forbidden        → Authenticated but not authorized
  404 Not Found        → Resource doesn't exist
  409 Conflict         → Duplicate, optimistic locking failure
  410 Gone             → Resource permanently deleted
  422 Unprocessable    → Validation failed (semantic error)
  429 Too Many Requests → Rate limit exceeded

5xx Server Error:
  500 Internal Server Error → Bug / unhandled error
  502 Bad Gateway          → Upstream service failed
  503 Service Unavailable  → Overloaded or maintenance
  504 Gateway Timeout      → Upstream timeout

Consistent Error Response

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "code": "INVALID_FORMAT",
        "message": "Must be a valid email address"
      },
      {
        "field": "age",
        "code": "OUT_OF_RANGE",
        "message": "Must be between 18 and 120"
      }
    ],
    "request_id": "req_8f7d6e5c4b3a",
    "docs_url": "https://api.example.com/docs/errors#VALIDATION_FAILED"
  }
}

Pagination

Offset pagination:
  GET /users?limit=20&offset=0
  GET /users?limit=20&offset=20
  
  ✅ Simple, random access
  ❌ Performance degrades at high offset
  ❌ Inconsistent results when data changes during pagination

Cursor pagination (preferred for large datasets):
  GET /users?limit=20
  Response: { "data": [...], "cursor": "eyJpZCI6MTAwfQ==" }
  GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
  
  ✅ Consistent results (no skipping/duplicates)
  ✅ O(log n) performance with index
  ❌ No random access ("go to page 5")

Page-based (user-friendly):
  GET /users?page=1&per_page=20
  Response: { "data": [...], "pagination": { "page": 1, "per_page": 20, "total": 150, "total_pages": 8 } }

Standard pagination envelope:
{
  "data": [...],
  "pagination": {
    "cursor": "next_cursor_value",  // or "next_page"
    "has_more": true,
    "total": 1500                   // optional, expensive to compute
  }
}

Versioning Strategies

1. URL versioning (most common)
   /api/v1/users
   /api/v2/users
   ✅ Explicit, cacheable
   ❌ URL pollution

2. Header versioning
   Accept: application/vnd.myapi.v2+json
   API-Version: 2024-01
   ✅ Clean URLs
   ❌ Less visible, harder to test in browser

3. Query parameter
   GET /users?version=2
   ✅ Easy to test
   ❌ Easy to forget, breaks caching

Recommendation: URL versioning + Sunset header for deprecation

API-Deprecation: Fri, 01 Jan 2026 00:00:00 GMT
Sunset: Fri, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2>; rel="successor-version"

Authentication Patterns

API Key (simple services, M2M):
  Authorization: Bearer sk_live_abc123
  X-API-Key: sk_live_abc123
  
  Store: hash (bcrypt/SHA-256), never plaintext
  Prefix with env: sk_live_ sk_test_ sk_dev_

JWT (stateless, user sessions):
  Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
  
  Best practices:
  - Short expiry: 15-60 min for access tokens
  - Refresh token: 7-30 days, stored in httpOnly cookie
  - Use RS256 (asymmetric) for microservices
  - Include: sub, iat, exp, jti (JWT ID for revocation)
  
OAuth 2.1 (third-party access):
  - Authorization Code + PKCE (browser/mobile)
  - Client Credentials (M2M)
  - Never use Implicit flow (deprecated)

mTLS (high-security M2M):
  Both parties present certificates
  Best for: financial services, healthcare

Idempotency

Idempotency key pattern — safe retries for non-GET operations:

POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e

Server behavior:
1. Check if key seen before
2. If yes: return cached response (same status code + body)
3. If no: process request, cache response for 24 hours, respond

// Express middleware
async function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];
  if (!key || req.method === 'GET') return next();
  
  const cached = await redis.get(`idempotency:${key}`);
  if (cached) {
    const { status, body } = JSON.parse(cached);
    return res.status(status).json(body);
  }
  
  // Intercept response to cache it
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    redis.setex(`idempotency:${key}`, 86400, JSON.stringify({
      status: res.statusCode,
      body,
    }));
    return originalJson(body);
  };
  
  next();
}

OpenAPI/Swagger Documentation

# openapi.yaml
openapi: "3.1.0"
info:
  title: Users API
  version: "2.0.0"
  contact:
    email: [email protected]
  license:
    name: Apache 2.0

servers:
  - url: https://api.example.com/v2
    description: Production
  - url: https://sandbox.example.com/v2
    description: Sandbox

paths:
  /users:
    get:
      summary: List users
      operationId: listUsers
      tags: [Users]
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
          description: Pagination cursor from previous response
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Paginated user list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserList'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "429":
          $ref: '#/components/responses/RateLimited'
      security:
        - bearerAuth: []
    
    post:
      summary: Create user
      operationId: createUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        "201":
          description: User created
          headers:
            Location:
              schema:
                type: string
              description: URL of created user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        "422":
          $ref: '#/components/responses/ValidationError'

components:
  schemas:
    User:
      type: object
      required: [id, email, created_at]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        email:
          type: string
          format: email
        name:
          type: string
          maxLength: 100
        created_at:
          type: string
          format: date-time
          readOnly: true
    
    CreateUserRequest:
      type: object
      required: [email, name]
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          minLength: 1
          maxLength: 100
  
  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

GraphQL Schema Design

# schema.graphql — Good schema design
type Query {
  user(id: ID!): User
  users(
    first: Int
    after: String  # Cursor-based pagination (Relay spec)
    filter: UserFilter
    orderBy: UserOrderBy
  ): UserConnection!
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}

type Subscription {
  userCreated: User!
  orderStatusChanged(orderId: ID!): Order!
}

# Relay-spec connection (enables pagination)
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# Payload types for mutations (best practice)
type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  code: String!
  message: String!
}

type User {
  id: ID!
  email: String!
  name: String!
  createdAt: DateTime!
  
  # Nested connections (N+1 risk — use DataLoader!)
  orders(first: Int, after: String): OrderConnection!
  posts(first: Int): PostConnection!
}

# Input types
input CreateUserInput {
  email: String!
  name: String!
  role: UserRole = USER
}

enum UserRole {
  ADMIN
  USER
  GUEST
}

scalar DateTime
scalar UUID

N+1 Prevention with DataLoader

// Without DataLoader: 1 query for users + N queries for each user's orders
// With DataLoader: 1 query for users + 1 batched query for ALL orders

import DataLoader from 'dataloader';

// Create per-request dataloader (not global — prevents cache bleed)
function createLoaders(db: DB) {
  return {
    orders: new DataLoader<string, Order[]>(async (userIds) => {
      const orders = await db.query(
        'SELECT * FROM orders WHERE user_id = ANY($1)',
        [userIds as string[]]
      );
      // Group by userId and preserve order
      return userIds.map(id => orders.filter(o => o.userId === id));
    }),
    
    user: new DataLoader<string, User | null>(async (ids) => {
      const users = await db.query(
        'SELECT * FROM users WHERE id = ANY($1)',
        [ids as string[]]
      );
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    }),
  };
}

// In resolver
const resolvers = {
  User: {
    orders: async (user, args, context) => {
      return context.loaders.orders.load(user.id);  // Batched automatically
    }
  }
};

gRPC Design

// users.proto
syntax = "proto3";
package users.v1;

import "google/protobuf/timestamp.proto";

service UserService {
  // Unary RPC
  rpc GetUser(GetUserRequest) returns (User);
  rpc CreateUser(CreateUserRequest) returns (User);
  
  // Server streaming — good for real-time feeds
  rpc ListUsers(ListUsersRequest) returns (stream User);
  
  // Bidirectional streaming — good for chat
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message GetUserRequest {
  string user_id = 1;
}

message User {
  string id = 1;
  string email = 2;
  string name = 3;
  google.protobuf.Timestamp created_at = 4;
  UserRole role = 5;
}

enum UserRole {
  USER_ROLE_UNSPECIFIED = 0;  // Always start with 0 as unknown
  USER_ROLE_ADMIN = 1;
  USER_ROLE_USER = 2;
}

message CreateUserRequest {
  string email = 1;
  string name = 2;
}

API Design Checklist

Design:
[ ] Resources are nouns, not verbs
[ ] Consistent naming convention (snake_case vs camelCase — pick one)
[ ] Appropriate HTTP verbs used
[ ] Status codes are correct
[ ] Error responses are consistent and include error codes

Security:
[ ] Authentication required (except explicitly public endpoints)
[ ] Authorization checked (user can only see their own data)
[ ] Input validation on all fields
[ ] Rate limiting implemented
[ ] Idempotency keys for mutations

Documentation:
[ ] OpenAPI/GraphQL schema is current
[ ] Error codes are documented
[ ] Rate limits documented
[ ] Authentication flow documented
[ ] Example requests/responses provided

Reliability:
[ ] Pagination on all list endpoints
[ ] Timeouts set appropriately
[ ] Deprecated endpoints have Sunset headers
[ ] Breaking changes use new version

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills