API Design Best Practices
Our coffee shop has grown into a platform now. We have a mobile app, a web app, partner integrations and third-party developers building on our loyalty program. Everyone needs to talk to our backend. This is where API Design comes into play.
The API is the contract between your system and everyone who uses it. How well you design it will determine how well your users interact with your system.
Good API design is like good UX design. It should be intuitive enough that documentation becomes a reference, not a nightmare.
What You Will Learn
- REST principles and when to follow (or break) them
- URL structure and naming conventions
- Request and response design
- Versioning strategies that don't break clients
- Authentication and authorization patterns
- Rate limiting and error handling
- When to consider GraphQL or gRPC
REST: The Foundation
REST (Representational State Transfer) isn't a protocol. It's a set of principles for designing web APIs. Most APIs claim to be RESTful; few actually are.
Resources and URLs
In REST, everything is a resource. A resource is a noun: users, orders, products.
URLs should identify resources, not actions:
plaintextGood (nouns): GET /users/123 - Get user 123 POST /users - Create a user PUT /users/123 - Update user 123 DELETE /users/123 - Delete user 123 Bad (verbs): GET /getUser?id=123 POST /createUser POST /deleteUser?id=123
The HTTP method (GET, POST, PUT, DELETE) tells you the action. The URL tells you what you're acting on.
URL Structure Best Practices
Use plural nouns:
plaintext/users/123
Nest for relationships:
plaintextGET /users/123/orders - Orders for user 123 GET /orders/456/items - Items in order 456
But don't nest too deep:
plaintext/users/123/orders/456/items/789/reviews (not recommended) /order-items/789/reviews (flatten it)
Use hyphens, not underscores or camelCase:
plaintext/coffee-shops
Keep URLs lowercase:
plaintext/users/123/orders
HTTP Methods
| Method | Purpose | Idempotent? | Safe? |
|---|---|---|---|
| GET | Retrieve resource | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Replace resource entirely | Yes | No |
| PATCH | Partial update | No | No |
| DELETE | Remove resource | Yes | No |
Idempotent: Same request repeated gives same result. DELETE is idempotent, deleting twice = still deleted.
Safe: Doesn't modify anything. GET should never have side effects.
Request Design
Query Parameters vs Path Parameters
Path parameters identify a specific resource:
plaintextGET /users/123 - User with ID 123 GET /orders/456 - Order with ID 456
Query parameters filter, sort, or modify the response:
plaintextGET /users?status=active - Filter by status GET /users?sort=created_at&order=desc - Sort results GET /users?page=2&limit=20 - Pagination
Request Body Design
For POST and PUT, use JSON with clear field names:
json{ "email": "alice@example.com", "name": "Alice Johnson", "preferences": { "notifications": true, "theme": "dark" } }
Use camelCase for JSON fields (JavaScript convention):
json{ "firstName": "Alice", "lastName": "Johnson" }
Be explicit about required vs optional:
json{ "email": "alice@example.com", // required "name": "Alice", // required "phone": "+1234567890" // optional }
Document which fields are required. Better yet, validate and return clear errors.
Response Design
Consistent Structure
Every response should follow the same structure:
json{ "data": { "id": "user-123", "email": "alice@example.com", "name": "Alice Johnson" }, "meta": { "requestId": "req-abc123" } }
For collections:
json{ "data": [ { "id": "user-123", "name": "Alice" }, { "id": "user-456", "name": "Bob" } ], "meta": { "total": 150, "page": 1, "limit": 20 } }
HTTP Status Codes
Use status codes correctly. They tell clients what happened without parsing the body.
Success (2xx):
plaintext200 OK → GET succeeded, here's the data 201 Created → POST succeeded, resource created 204 No Content → DELETE succeeded, nothing to return
Client Error (4xx):
plaintext400 Bad Request → Malformed request, validation failed 401 Unauthorized → No auth credentials provided 403 Forbidden → Authenticated but not permitted 404 Not Found → Resource doesn't exist 409 Conflict → Resource state conflict (duplicate email) 422 Unprocessable → Validation error (email format invalid) 429 Too Many Requests → Rate limit exceeded
Server Error (5xx):
plaintext500 Internal Error → Something broke on our end 502 Bad Gateway → Upstream service failed 503 Service Unavailable → Temporarily down
Error Responses
When something goes wrong, help the developer fix it:
json{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ { "field": "email", "message": "Invalid email format" }, { "field": "age", "message": "Must be a positive number" } ] }, "meta": { "requestId": "req-abc123" } }
Include:
- Machine-readable error code (for programmatic handling)
- Human-readable message
- Field-level details when applicable
- Request ID for debugging
Don't include:
- Stack traces in production
- Internal system details
- Database error messages
Pagination
Never return unbounded lists. Always paginate.
Offset-Based Pagination
plaintextGET /users?page=2&limit=20
Response:
json{ "data": [...], "meta": { "total": 150, "page": 2, "limit": 20, "totalPages": 8 } }
Pros: Simple, supports jumping to any page Cons: Inconsistent if data changes between requests (skip/miss items)
Cursor-Based Pagination
plaintextGET /users?cursor=abc123&limit=20
Response:
json{ "data": [...], "meta": { "nextCursor": "def456", "hasMore": true } }
Pros: Consistent even when data changes, better for large datasets
Cons: Can't jump to arbitrary page
Use cursor-based for:
- Real-time feeds (social media, notifications)
- Large datasets
- Data that changes frequently
Versioning
APIs evolve. You'll need to make breaking changes eventually. How do you do it without breaking existing clients?
URL Versioning
plaintext/v1/users/123 /v2/users/123
Pros: Explicit, easy to understand, easy to route Cons: URL pollution, hard to deprecate gradually
Header Versioning
plaintextGET /users/123 Accept: application/vnd.myapi.v1+json
Pros: Clean URLs Cons: Harder to test (can't just paste in browser), easy to forget
My Recommendation
Use URL versioning for major versions (/v1/, /v2/). It's explicit and works everywhere.
Evolve within versions using additive changes:
- Adding new fields (won't break clients that ignore unknown fields)
- Adding new endpoints
- Adding optional parameters
Avoid breaking changes:
- Removing fields
- Renaming fields
- Changing field types
- Changing URL structure
When you must make breaking changes, bump the major version.
Authentication & Authorization
Authentication (Who are you?)
API Keys: Simple, good for server-to-server.
plaintextGET /users/123 Authorization: Api-Key sk_live_abc123
JWT Tokens: Self-contained, good for user sessions.
plaintextGET /users/123 Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
OAuth 2.0: For third-party access to user data.
Authorization (What can you do?)
After authentication, check permissions:
python@app.route('/users/<user_id>') def get_user(user_id): # Authentication: Who is calling? current_user = get_authenticated_user() # Authorization: Can they access this? if current_user.id != user_id and not current_user.is_admin: return {"error": "Forbidden"}, 403 return get_user_data(user_id)
Best Practices
- Use HTTPS everywhere (no exceptions)
- Never put secrets in URLs (they end up in logs)
- Use short-lived tokens (hours, not days)
- Implement token refresh for long sessions
- Log authentication failures (detect attacks)
Rate Limiting
Protect your API from abuse and ensure fair usage.
Implementation
plaintextFair use - allow Limit exceeded - 429 Too Many Requests
Response Headers
Tell clients their limit status:
plaintextX-RateLimit-Limit: 1000 - Requests allowed per window X-RateLimit-Remaining: 847 - Requests remaining X-RateLimit-Reset: 1640995200 - When the window resets (Unix timestamp)
When exceeded:
plaintextHTTP/1.1 429 Too Many Requests Retry-After: 60 { "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Try again in 60 seconds." } }
Strategies
Per-user limits: Fair usage across users
Per-endpoint limits: Protect expensive operations
Tiered limits: Free tier gets 100/hour, paid gets 10,000/hour
Beyond REST: GraphQL and gRPC
REST isn't always the best choice.
GraphQL: Client-Specified Queries
The problem with REST: To show a user profile with their recent orders and friends, you might need:
plaintextGET /users/123 GET /users/123/orders?limit=5 GET /users/123/friends?limit=10
Three round trips. Or you create a custom endpoint /users/123/profile-with-orders-and-friends.
GraphQL solution: Client specifies exactly what they want:
graphqlquery { user(id: "123") { name email orders(limit: 5) { id total } friends(limit: 10) { name avatarUrl } } }
One request, exactly the data needed.
Use GraphQL when:
- Clients have diverse data needs
- Mobile apps need to minimize requests
- You want strong typing
Avoid GraphQL when:
- Simple CRUD operations
- Caching is critical (GraphQL caching is harder)
- Team is unfamiliar
gRPC and Protocol Buffers: High-Performance APIs
The problem with REST + JSON:
JSON is text. Every request parses strings like {"name": "Alice", "age": 30}. For humans reading logs? Great. For machines making millions of calls per second? Too slow.
Protocol Buffers (Protobuf) solve this. It's a binary format — compact and fast to parse.
protobuf// user.proto - Define your data structure once syntax = "proto3"; message User { string id = 1; string name = 2; string email = 3; int32 age = 4; repeated string roles = 5; // list of strings } message GetUserRequest { string user_id = 1; }
From this .proto file, you generate code in any language:
bash# Generate Python code protoc --python_out=. user.proto # Generate Go code protoc --go_out=. user.proto # Generate Java code protoc --java_out=. user.proto
Now your Python service and Go service speak the same language.
gRPC builds on Protobuf. It's a framework for service-to-service calls:
protobufservice UserService { rpc GetUser(GetUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (stream User); // streaming! rpc CreateUser(User) returns (User); }
Why companies use gRPC:
- Google: Created it. Uses it everywhere internally.
- Netflix: Microservices communicate via gRPC.
- Uber: High-throughput internal APIs.
- Dropbox: Migrated from REST for performance.
The numbers: Protobuf messages are 3-10x smaller than JSON. Parsing is 20-100x faster. When you're making millions of calls, this matters.
Use gRPC + Protobuf when:
- Internal service-to-service communication
- High throughput requirements (>10K requests/sec)
- Need streaming (real-time updates)
- Multiple languages need to communicate
- You want strict type safety
Stick with REST when:
- Public APIs (browsers handle JSON easily)
- Simple integrations
- Team is small and unfamiliar with Protobuf
- Debugging ease matters more than performance
The Pragmatic Approach
| Use Case | Recommendation |
|---|---|
| Public API | REST |
| Mobile app with complex screens | GraphQL |
| Internal microservices | gRPC |
| Simple web app | REST |
| Real-time data streaming | gRPC or WebSockets |
Key Takeaways
URLs identify resources. Use nouns, not verbs. Let HTTP methods indicate the action.
Be consistent. Same structure for all responses. Same error format. Same naming conventions.
Version your API. Use /v1/ in URLs. Make additive changes within versions. Bump versions for breaking changes.
Fail helpfully. Good error messages save everyone time. Include error codes, messages, and field-level details.
Protect your API. Use HTTPS, authentication, authorization, and rate limiting.
Choose the right tool. REST for public APIs. GraphQL for complex client needs. gRPC for high-performance internal services.
What's Next
We've covered how APIs let clients talk to your backend. But what about serving content to users faster? If your users are spread across the globe, even the best API is limited by the speed of light. That's where CDN and Edge Computing come in. We'll learn how to bring your content closer to users.