API Design Best Practices

0/4 in this phase0/45 across the roadmap

📖 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 /users returns { 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-ID or 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:

  1. Write the API spec
  2. Review with frontend/mobile teams
  3. Generate server stubs and client SDKs
  4. Implement the server logic

This prevents the common problem of "backend built an API that doesn't match what the frontend needs."

💻 Code Example

codeTap to expand ⛶
1// ============================================
2// API Design Best Practices — Production Patterns
3// ============================================
4
5// ---------- Idempotency Key Implementation ----------
6
7const idempotencyStore = new Map(); // In production: Redis with TTL
8
9async function idempotentMiddleware(req, res, next) {
10 // Only for non-idempotent methods
11 if (['GET', 'PUT', 'DELETE'].includes(req.method)) return next();
12
13 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 }
22
23 const key = `\${req.path}:\${idempotencyKey}`;
24
25 // Check if this request was already processed
26 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 }
31
32 // Override res.json to capture the response
33 const originalJson = res.json.bind(res);
34 res.json = (body) => {
35 // Cache the response for 24 hours
36 idempotencyStore.set(key, {
37 status: res.statusCode,
38 body,
39 createdAt: Date.now(),
40 });
41 return originalJson(body);
42 };
43
44 next();
45}
46
47// ---------- Request/Response Envelope ----------
48
49// ✅ Consistent response format across ALL endpoints
50function 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}
61
62function 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 errors
69 },
70 meta: {
71 timestamp: new Date().toISOString(),
72 requestId: res.locals.requestId,
73 },
74 });
75}
76
77// ---------- Input Validation ----------
78
79function validateCreateUser(body) {
80 const errors = [];
81
82 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 }
89
90 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 }
97
98 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 }
105
106 return errors;
107}
108
109// ---------- Deprecation Headers ----------
110
111function 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}
120
121// ---------- Health Check Endpoint ----------
122
123app.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});
135
136// ---------- API Versioning with Backward Compatibility ----------
137
138// V1: { name: "John Doe" }
139// V2: { firstName: "John", lastName: "Doe" }
140
141app.get('/api/v1/users/:id', deprecatedEndpoint, async (req, res) => {
142 const user = await db.findUser(req.params.id);
143 // V1 format: combined name
144 successResponse(res, {
145 id: user.id,
146 name: `\${user.firstName} \${user.lastName}`, // Computed for backward compat
147 email: user.email,
148 });
149});
150
151app.get('/api/v2/users/:id', async (req, res) => {
152 const user = await db.findUser(req.params.id);
153 // V2 format: split name fields
154 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 V2
160 });
161});
162
163// ---------- Request ID / Correlation ID ----------
164
165function 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);
169
170 // Log every request with its ID
171 console.log(`[\${requestId}] \${req.method} \${req.path}`);
172 next();
173}
174
175function 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}
182
183// Register middleware
184app.use(requestIdMiddleware);
185app.use(idempotentMiddleware);

🏋️ Practice Exercise

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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