API Design & Microservices Patterns

RESTful API Design Best Practices

5 min read

API design questions appear in nearly every backend interview. Interviewers want to see that you can design intuitive, consistent, and production-ready APIs. This lesson covers the patterns and trade-offs you need to know.

Resource Naming Conventions

REST APIs model resources as nouns, not verbs. Use plural nouns and hierarchical paths to express relationships.

GET    /users                    # List all users
POST   /users                    # Create a user
GET    /users/{id}               # Get a specific user
PUT    /users/{id}               # Replace a user
PATCH  /users/{id}               # Partially update a user
DELETE /users/{id}               # Delete a user

GET    /users/{id}/orders        # List orders for a user
POST   /users/{id}/orders        # Create an order for a user
GET    /users/{id}/orders/{oid}  # Get a specific order

Key rules:

  • Use plural nouns: /users not /user, /orders not /order
  • Use hyphens for multi-word resources: /payment-methods not /paymentMethods
  • Use hierarchy for relationships: /users/{id}/orders not /getUserOrders
  • Never use verbs in URLs: /users/{id}/activate is acceptable only as a controller action, prefer PATCH /users/{id} with { "status": "active" }

HTTP Methods and Status Codes

Each HTTP method has specific semantics. Getting status codes right signals experience.

Method Purpose Idempotent Safe
GET Read resource Yes Yes
POST Create resource No No
PUT Replace resource Yes No
PATCH Partial update No No
DELETE Remove resource Yes No

Status Codes You Must Know

Code Meaning When to Use
200 OK Success GET, PUT, PATCH with response body
201 Created Resource created POST — include Location header
204 No Content Success, no body DELETE, PUT/PATCH when no body returned
301 Moved Permanently Permanent redirect API version migration
304 Not Modified Cached response valid GET with If-None-Match / ETag
400 Bad Request Invalid input Malformed JSON, missing required fields
401 Unauthorized Not authenticated Missing or invalid token
403 Forbidden Not authorized Valid token, insufficient permissions
404 Not Found Resource missing ID does not exist
409 Conflict State conflict Duplicate creation, version mismatch
429 Too Many Requests Rate limited Include Retry-After header
500 Internal Server Error Server bug Unhandled exception
503 Service Unavailable Temporary outage Maintenance, dependency down

Interview tip: Knowing the difference between 401 (who are you?) and 403 (I know who you are, but you cannot do this) is a common interview question.

Pagination Patterns

Every list endpoint needs pagination. The two main approaches have distinct trade-offs.

Offset-Based Pagination

GET /users?page=2&limit=20
{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total_count": 354,
    "total_pages": 18
  }
}

Cursor-Based Pagination

GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTIwfQ==",
    "prev_cursor": "eyJpZCI6MTAxfQ==",
    "has_more": true
  }
}

Comparison

Aspect Offset-Based Cursor-Based
Jump to page N Yes No
Consistent with inserts/deletes No (rows shift) Yes
Performance at deep offsets Poor (OFFSET scans) Constant (WHERE id > cursor)
Total count available Yes (but expensive) Not easily
Implementation complexity Simple Moderate
Best for Admin dashboards, small datasets Feeds, real-time data, large datasets

Interview answer: "I'd use cursor-based pagination for a social media feed because users are constantly adding new posts. Offset pagination would cause items to shift between pages. The cursor ensures a stable position in the result set."

Filtering, Sorting, and Field Selection

Use query parameters to give clients control over responses.

GET /users?status=active&role=admin&sort=-created_at,name&fields=id,name,email
  • Filtering: ?status=active&role=admin — equality filters as key-value pairs
  • Sorting: ?sort=-created_at,name — prefix - for descending, comma-separated for multi-sort
  • Field selection: ?fields=id,name,email — reduce payload size, useful for mobile clients
// TypeScript: Parsing sort parameters on the server
function parseSortParam(sort: string): Array<{ field: string; order: 'asc' | 'desc' }> {
  return sort.split(',').map(field => ({
    field: field.replace(/^-/, ''),
    order: field.startsWith('-') ? 'desc' : 'asc'
  }));
}

// parseSortParam("-created_at,name")
// => [{ field: "created_at", order: "desc" }, { field: "name", order: "asc" }]

Authentication Patterns

JWT (JSON Web Tokens)

The most common API authentication pattern. Uses a short-lived access token and a long-lived refresh token.

Access Token:  15 min TTL — sent in Authorization header
Refresh Token: 7 days TTL — stored in httpOnly cookie, used to get new access tokens
// JWT structure: header.payload.signature
interface JWTPayload {
  sub: string;       // user ID
  iat: number;       // issued at (epoch)
  exp: number;       // expiration (epoch)
  roles: string[];   // authorization claims
}

// Server-side verification
import jwt from 'jsonwebtoken';

function authenticate(req: Request): JWTPayload {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) throw new UnauthorizedError('Missing token');

  try {
    return jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
  } catch (err) {
    throw new UnauthorizedError('Invalid or expired token');
  }
}

OAuth2 Flows

Flow Use Case Security
Authorization Code Web apps (server-side) Code exchanged server-side, secret never exposed
Authorization Code + PKCE Mobile/SPA Code verifier prevents interception, no client secret needed
Client Credentials Service-to-service Machine-to-machine, no user context

API Keys

Best for service-to-service communication. Pass via header, never in URL.

X-API-Key: sk_live_abc123def456

Rate Limiting

Rate limiting protects your API from abuse and ensures fair usage. The token bucket algorithm is the most commonly asked about in interviews.

Token Bucket Algorithm

import time

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity          # max tokens in bucket
        self.tokens = capacity            # current tokens
        self.refill_rate = refill_rate    # tokens added per second
        self.last_refill = time.time()

    def allow_request(self) -> bool:
        self._refill()
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

    def _refill(self):
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(
            self.capacity,
            self.tokens + elapsed * self.refill_rate
        )
        self.last_refill = now

# Example: 100 requests/minute capacity, refills at ~1.67 tokens/sec
bucket = TokenBucket(capacity=100, refill_rate=100/60)

Rate Limiting Strategies

Strategy How It Works Pros Cons
Token Bucket Bucket holds tokens, refills at fixed rate Allows bursts, smooth rate Slightly more memory
Sliding Window Log Store timestamp of each request Very accurate High memory usage
Sliding Window Counter Weighted count across current + previous window Low memory, fairly accurate Approximate

Always return rate limit headers so clients can self-throttle:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1704067200
Retry-After: 30

API Versioning

Strategy Example Pros Cons
URI Path /v1/users Simple, explicit, easy to route URL changes, hard to sunset
Header Accept: application/vnd.api.v1+json Clean URLs, flexible Hidden from browser, harder to test
Query Param /users?version=1 Easy to add Pollutes query string, optional can cause bugs

Common answer: "URI path versioning is the most practical. It's explicit, easy to route at the load balancer, and simple for clients to understand. Header versioning is cleaner in theory but adds friction for developers using browsers or curl."

Idempotency Keys

POST requests are not idempotent — retrying a payment could charge a customer twice. Idempotency keys solve this.

// Client sends a unique key with the request
// POST /payments
// Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

async function processPayment(req: Request) {
  const idempotencyKey = req.headers['idempotency-key'];
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key header required' });
  }

  // Check if we've already processed this key
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) {
    return res.status(200).json(JSON.parse(existing)); // Return cached response
  }

  // Process the payment
  const result = await chargeCustomer(req.body);

  // Cache the result (TTL: 24 hours)
  await redis.set(
    `idempotency:${idempotencyKey}`,
    JSON.stringify(result),
    'EX', 86400
  );

  return res.status(201).json(result);
}

How it works: The client generates a UUID and sends it with every POST. The server stores the response keyed by that UUID. If the same key arrives again (network retry, user double-click), the server returns the cached response without re-processing.

HATEOAS

Hypermedia As The Engine Of Application State means responses include links to related actions.

{
  "id": "order-123",
  "status": "pending",
  "total": 99.99,
  "_links": {
    "self": { "href": "/orders/order-123" },
    "cancel": { "href": "/orders/order-123/cancel", "method": "POST" },
    "payment": { "href": "/orders/order-123/payment", "method": "POST" }
  }
}

In practice, most APIs don't fully implement HATEOAS. But mentioning it in an interview shows you understand REST maturity levels (Richardson Maturity Model).

Next: We'll explore gRPC with Protocol Buffers and GraphQL — the two major alternatives to REST. :::

Quiz

Module 3 Quiz: API Design & Microservices Patterns

Take Quiz