API Design & Microservices Patterns
RESTful API Design Best Practices
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:
/usersnot/user,/ordersnot/order - Use hyphens for multi-word resources:
/payment-methodsnot/paymentMethods - Use hierarchy for relationships:
/users/{id}/ordersnot/getUserOrders - Never use verbs in URLs:
/users/{id}/activateis acceptable only as a controller action, preferPATCH /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. :::