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.

MethodPurposeIdempotentSafe
GETRead resourceYesYes
POSTCreate resourceNoNo
PUTReplace resourceYesNo
PATCHPartial updateNoNo
DELETERemove resourceYesNo

Status Codes You Must Know

CodeMeaningWhen to Use
200 OKSuccessGET, PUT, PATCH with response body
201 CreatedResource createdPOST — include Location header
204 No ContentSuccess, no bodyDELETE, PUT/PATCH when no body returned
301 Moved PermanentlyPermanent redirectAPI version migration
304 Not ModifiedCached response validGET with If-None-Match / ETag
400 Bad RequestInvalid inputMalformed JSON, missing required fields
401 UnauthorizedNot authenticatedMissing or invalid token
403 ForbiddenNot authorizedValid token, insufficient permissions
404 Not FoundResource missingID does not exist
409 ConflictState conflictDuplicate creation, version mismatch
429 Too Many RequestsRate limitedInclude Retry-After header
500 Internal Server ErrorServer bugUnhandled exception
503 Service UnavailableTemporary outageMaintenance, 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

AspectOffset-BasedCursor-Based
Jump to page NYesNo
Consistent with inserts/deletesNo (rows shift)Yes
Performance at deep offsetsPoor (OFFSET scans)Constant (WHERE id > cursor)
Total count availableYes (but expensive)Not easily
Implementation complexitySimpleModerate
Best forAdmin dashboards, small datasetsFeeds, 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

FlowUse CaseSecurity
Authorization CodeWeb apps (server-side)Code exchanged server-side, secret never exposed
Authorization Code + PKCEMobile/SPACode verifier prevents interception, no client secret needed
Client CredentialsService-to-serviceMachine-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

StrategyHow It WorksProsCons
Token BucketBucket holds tokens, refills at fixed rateAllows bursts, smooth rateSlightly more memory
Sliding Window LogStore timestamp of each requestVery accurateHigh memory usage
Sliding Window CounterWeighted count across current + previous windowLow memory, fairly accurateApproximate

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

StrategyExampleProsCons
URI Path/v1/usersSimple, explicit, easy to routeURL changes, hard to sunset
HeaderAccept: application/vnd.api.v1+jsonClean URLs, flexibleHidden from browser, harder to test
Query Param/users?version=1Easy to addPollutes 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
FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.