API Design Best Practices
📖 Concept
Good API design is the difference between a service developers love and one they dread. This topic covers the principles and practices that make APIs intuitive, robust, and production-ready.
Key Principles
1. Design for the Consumer
- APIs should be designed from the consumer's perspective, not the database schema
- Think about what the client needs, not what the server stores
2. Be Consistent
- Use the same patterns everywhere: naming conventions, error formats, pagination
- If
/usersreturns{ data: [...], pagination: {...} }, every list endpoint should too
3. Make It Hard to Misuse
- Required fields should be clearly documented
- Invalid requests should return helpful error messages with specific field-level details
- Use enums instead of free-form strings where possible
4. Plan for Evolution
- Version from day one
- Use additive changes (adding fields) over breaking changes (removing/renaming)
- Deprecate, don't delete — mark old fields as deprecated, remove after N months
Idempotency
Idempotency means making the same request multiple times produces the same result. This is critical for reliability:
| Method | Naturally Idempotent? | How to Make Idempotent |
|---|---|---|
| GET | ✅ Yes | N/A |
| PUT | ✅ Yes | N/A |
| DELETE | ✅ Yes | N/A |
| POST | ❌ No | Idempotency Key |
| PATCH | ❌ Depends | Idempotency Key or conditional update |
Request ID / Correlation ID
Every request should have a unique identifier that flows through the entire system:
- Client sends
X-Request-IDor server generates one - ID is logged at every service hop
- Enables end-to-end request tracing in distributed systems
Contract-First Design
Define the API contract (OpenAPI/Swagger, Protobuf) before writing any code:
- Write the API spec
- Review with frontend/mobile teams
- Generate server stubs and client SDKs
- Implement the server logic
This prevents the common problem of "backend built an API that doesn't match what the frontend needs."
💻 Code Example
1// ============================================2// API Design Best Practices — Production Patterns3// ============================================45// ---------- Idempotency Key Implementation ----------67const idempotencyStore = new Map(); // In production: Redis with TTL89async function idempotentMiddleware(req, res, next) {10 // Only for non-idempotent methods11 if (['GET', 'PUT', 'DELETE'].includes(req.method)) return next();1213 const idempotencyKey = req.headers['idempotency-key'];14 if (!idempotencyKey) {15 return res.status(400).json({16 error: {17 code: 'MISSING_IDEMPOTENCY_KEY',18 message: 'POST requests require an Idempotency-Key header',19 },20 });21 }2223 const key = `\${req.path}:\${idempotencyKey}`;2425 // Check if this request was already processed26 if (idempotencyStore.has(key)) {27 const cached = idempotencyStore.get(key);28 console.log(`♻️ Returning cached response for key: \${idempotencyKey}`);29 return res.status(cached.status).json(cached.body);30 }3132 // Override res.json to capture the response33 const originalJson = res.json.bind(res);34 res.json = (body) => {35 // Cache the response for 24 hours36 idempotencyStore.set(key, {37 status: res.statusCode,38 body,39 createdAt: Date.now(),40 });41 return originalJson(body);42 };4344 next();45}4647// ---------- Request/Response Envelope ----------4849// ✅ Consistent response format across ALL endpoints50function successResponse(res, data, meta = {}) {51 return res.json({52 success: true,53 data,54 meta: {55 timestamp: new Date().toISOString(),56 requestId: res.locals.requestId,57 ...meta,58 },59 });60}6162function errorResponse(res, status, code, message, details = []) {63 return res.status(status).json({64 success: false,65 error: {66 code,67 message,68 details, // Field-level errors69 },70 meta: {71 timestamp: new Date().toISOString(),72 requestId: res.locals.requestId,73 },74 });75}7677// ---------- Input Validation ----------7879function validateCreateUser(body) {80 const errors = [];8182 if (!body.name || body.name.trim().length < 2) {83 errors.push({84 field: 'name',85 message: 'Name is required and must be at least 2 characters',86 code: 'FIELD_TOO_SHORT',87 });88 }8990 if (!body.email || !body.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {91 errors.push({92 field: 'email',93 message: 'A valid email address is required',94 code: 'INVALID_EMAIL',95 });96 }9798 if (body.role && !['admin', 'user', 'moderator'].includes(body.role)) {99 errors.push({100 field: 'role',101 message: 'Role must be one of: admin, user, moderator',102 code: 'INVALID_ENUM',103 });104 }105106 return errors;107}108109// ---------- Deprecation Headers ----------110111function deprecatedEndpoint(req, res, next) {112 res.set({113 'Deprecation': 'true',114 'Sunset': 'Sat, 01 Jun 2025 00:00:00 GMT',115 'Link': '</api/v2/users>; rel="successor-version"',116 });117 console.warn(`⚠️ Deprecated endpoint accessed: \${req.path}`);118 next();119}120121// ---------- Health Check Endpoint ----------122123app.get('/health', (req, res) => {124 res.json({125 status: 'healthy',126 version: '1.2.3',127 uptime: process.uptime(),128 checks: {129 database: 'connected',130 cache: 'connected',131 messageQueue: 'connected',132 },133 });134});135136// ---------- API Versioning with Backward Compatibility ----------137138// V1: { name: "John Doe" }139// V2: { firstName: "John", lastName: "Doe" }140141app.get('/api/v1/users/:id', deprecatedEndpoint, async (req, res) => {142 const user = await db.findUser(req.params.id);143 // V1 format: combined name144 successResponse(res, {145 id: user.id,146 name: `\${user.firstName} \${user.lastName}`, // Computed for backward compat147 email: user.email,148 });149});150151app.get('/api/v2/users/:id', async (req, res) => {152 const user = await db.findUser(req.params.id);153 // V2 format: split name fields154 successResponse(res, {155 id: user.id,156 firstName: user.firstName,157 lastName: user.lastName,158 email: user.email,159 createdAt: user.createdAt, // New field in V2160 });161});162163// ---------- Request ID / Correlation ID ----------164165function requestIdMiddleware(req, res, next) {166 const requestId = req.headers['x-request-id'] || generateUUID();167 res.locals.requestId = requestId;168 res.set('X-Request-ID', requestId);169170 // Log every request with its ID171 console.log(`[\${requestId}] \${req.method} \${req.path}`);172 next();173}174175function generateUUID() {176 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {177 const r = Math.random() * 16 | 0;178 const v = c === 'x' ? r : (r & 0x3 | 0x8);179 return v.toString(16);180 });181}182183// Register middleware184app.use(requestIdMiddleware);185app.use(idempotentMiddleware);
🏋️ Practice Exercise
API Review: Review a poorly designed API with these endpoints: POST /getUser, GET /users/delete/123, POST /api/users (returns 200 on creation, no Location header). List all issues and redesign it.
Idempotency Implementation: Design an idempotency system for a payment API. Handle these edge cases: (a) same key with different request body, (b) concurrent requests with same key, (c) key storage TTL and cleanup.
Error Response Design: Create an error response specification that handles: validation errors (multiple fields), authentication errors, authorization errors, rate limiting, server errors, and maintenance mode. Show example responses for each.
Breaking Change Management: Your API needs to change user IDs from integers to UUIDs. Design a migration plan that: (a) doesn't break existing clients, (b) gradually migrates traffic, (c) has a clear sunset timeline.
Contract-First Design: Write an OpenAPI 3.0 specification for a simple todo list API. Include request/response schemas, error responses, authentication, and at least 5 endpoints.
⚠️ Common Mistakes
Not implementing idempotency keys for POST endpoints — network retries, client-side bugs, and load balancer timeouts can all cause duplicate requests. Without idempotency keys, you'll get duplicate payments, double orders, etc.
Returning different response formats from different endpoints — inconsistent envelopes make it impossible for clients to write generic error handling or response parsing code.
Making breaking changes without versioning — removing a field, changing a field type, or restructuring responses breaks all existing clients. Always use API versioning.
Not including request IDs — without a correlation ID flowing through the system, debugging production issues becomes almost impossible. You can't trace a user's complaint back to specific server logs.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for API Design Best Practices