How to design APIs that developers love to use
Practical API Design: REST, GraphQL, and gRPC
Choose your API style based on requirements, not hype. REST is the safe default for public APIs, gRPC wins on performance for internal microservices, and GraphQL excels when clients need flexible, efficient data retrieval.
Protocol Tradeoffs
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/1.1 | HTTP/1.1 or HTTP/2 | HTTP/2 |
| Serialization | JSON (typically) | JSON | Protocol Buffers (binary) |
| Performance | Slower; transfers excess data | Faster for clients; only requested data | Fastest; compact payloads + multiplexing |
| Best Use Case | Public APIs, simple web apps | Complex frontend data needs | Internal microservices, real-time streaming |
| Learning Curve | Low | Medium (new concepts/syntax) | Medium (.proto files) |
| Overhead | Low for simple cases | Higher for simple queries | Low after setup |
Rule of thumb:
- REST: Great for simplicity and public APIs
- GraphQL: Use sparingly, mainly for frontend-driven projects with complex data needs
- gRPC: Best for high-performance, internal, or streaming APIs
Naming Conventions
Consistent naming makes APIs predictable and discoverable:
-
Use plural nouns for collections:
/users,/projects -
Use nouns, not verbs, in URLs:
GET /usersnotGET /getUsers -
Lowercase with hyphens:
/user-profiles, not/UserProfiles -
Nested resources show hierarchy:
/users/{id}/projects -
Avoid verbs in paths; use HTTP methods for actions:
-
GET(retrieve),POST(create),PUT(replace),PATCH(partial update),DELETE
-
Example:
GET /v1/users # List users
GET /v1/users/{id} # Get user
POST /v1/users # Create user
PATCH /v1/users/{id} # Update user fields
DELETE /v1/users/{id} # Delete user
Consistent Error Handling
Return structured, informative errors with appropriate HTTP status codes:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format",
"details": [
{"field": "email", "issue": "must be valid email address"}
],
"request_id": "req_abc123"
}
}
HTTP Status Code Guidelines:
| Status | When to Use |
|--------|-------------|
| 200 | Successful GET, PUT, PATCH |
| 201 | Successful POST (resource created) |
| 204 | Successful DELETE (no content) |
| 400 | Bad request (validation errors) |
| 401 | Missing/invalid authentication |
| 403 | Authenticated but insufficient permissions |
| 404 | Resource not found |
| 429 | Rate limit exceeded |
| 500 | Server error |
Always include a request_id for debugging and logging correlation.
Versioning Strategies
Version from day one; never release an unversioned API:
URI Versioning (recommended):
/v1/users
/v2/users
Key rules:
- Always include version in URI, even for v1
- Use format
/v{number}(e.g.,/v1,/v2) - Use simple ordinal numbers; avoid
v1.2.3or semver - Return
404if version is missing - Maintain at least one version back for migration time
When to create a new version:
- ✅ Modifying existing resources in breaking ways (new mandatory field, field removal)
- ✅ Introducing new resources clients must interact with
- ✅ Creating new error types
- ❌ Adding optional fields (clients can ignore them)
- ❌ Adding new resources clients don't need to use
Support migration:
- Maintain a changelog and migration guide
- Use
Deprecationheaders to warn before removing endpoints
Pagination
Always paginate list endpoints to handle large datasets efficiently:
Cursor-based pagination (recommended for APIs):
// Request
GET /v1/users?limit=20&cursor=eyJpZCI6MTAwfQ
// Response
{
"data": [...],
"pagination": {
"cursor": "eyJpZCI6MjAwfQ",
"has_more": true,
"limit": 20
}
}
Alternative: Offset-based (simpler but less scalable):
GET /v1/users?offset=0&limit=20
Best practices:
- Set reasonable default limits (e.g., 20-50)
- Allow clients to specify
limitwithin bounds - Return pagination metadata alongside data
- Use cursor-based for unordered或不稳定 data (e.g., real-time feeds)
Authentication
Choose authentication based on your use case:
| Method | Best For | Notes |
|---|---|---|
| API Keys | Server-to-server, usage tracking | Simple but keep secret |
| OAuth 2.0 | Delegated authorization | Users grant limited access |
| JWT | Stateless authentication | Compact, self-contained tokens; often paired with OAuth 2.0 |
| mTLS | Service-to-service, high security | Both client and server verify certificates |
Authentication Pattern (JWT + OAuth 2.0):
Authorization: Bearer <token>
- Extract token from
Authorizationheader - Verify signature and expiration
- Load user from token payload
- Attach user to request context
- Return
401for invalid/missing tokens
Authorization:
- Check roles/permissions after authentication
- Use middleware/decorators for role checks
- Return
403for insufficient permissions
Documenting APIs
Document the contract first, before implementation:
| Protocol | Documentation Standard |
|---|---|
| REST | OpenAPI (Swagger) |
| gRPC |
.proto files |
| GraphQL | SDL (Schema Definition Language) |
Essential documentation elements:
- Endpoint URLs and HTTP methods
- Request/response schemas with examples
- Authentication requirements
- Error codes and meanings
- Rate limits
- Versioning and deprecation notices
Use interactive docs (e.g., Swagger UI, Redoc) for better developer experience.
Designing for Evolvability
Keep interfaces stable and versioned to avoid breaking clients:
Key principles:
- Favor simple, orthogonal operations: Each endpoint does one thing well; complex workflows belong in orchestrators
- Plan for partial failure: Design retry policies, timeouts, and graceful degradation
- Make data transfer efficient: Reduce payload size, use pagination, support field selection
- Be liberal in what you accept, conservative in what you send (Postel's Law)
- Add fields, don't remove them: Clients can ignore new optional fields
-
Deprecate before removing: Use
Deprecationheaders and announce timelines
Field selection (GraphQL-style flexibility in REST):
GET /v1/users?fields=id,name,email
Real-World Patterns
Pattern 1: Filtering, Sorting, and Pagination Combined
GET /v1/users?status=active&sort=-created_at&limit=20&cursor=xyz
Pattern 2: Bulk Operations
POST /v1/users/bulk
{
"operations": [
{"action": "create", "data": {...}},
{"action": "update", "id": "123", "data": {...}}
]
}
Pattern 3: Asynchronous Long-Running Jobs
POST /v1/reports
→ 202 Accepted
{ "job_id": "job_xyz", "status_url": "/v1/jobs/job_xyz" }
GET /v1/jobs/job_xyz
→ { "status": "completed", "result_url": "/v1/reports/abc" }
Pattern 4: Conditional Requests (ETags)
GET /v1/users/123
→ ETag: "abc123"
PUT /v1/users/123
If-Match: "abc123"
→ Prevents lost updates
Key Takeaways
- Pick based on requirements: REST for public APIs, gRPC for internal performance, GraphQL for frontend flexibility
-
Version from day one using URI versioning (
/v1/) -
Use consistent error format with
request_id, error codes, and field-level details - Always paginate list endpoints with cursor-based pagination for scalability
- Document with OpenAPI/.proto/SDL and make docs interactive
- Design for evolvability: add fields, deprecate carefully, maintain backward compatibility
Ship it with confidence by planning your API contract, versioning strategy, and error handling before writing code.
Rizwan Saleem — https://rizwansaleem.co
Top comments (0)