Loading content...
Components in a distributed system communicate through APIs—Application Programming Interfaces. These APIs are contracts: they define what services offer, what clients can request, and how data should be formatted. A well-designed API enables independent development, simplifies integration, and evolves gracefully over years. A poorly designed API creates friction, confusion, and costly migrations.
API design might seem like an implementation detail, but in system design it's architectural. The APIs you define constrain how components interact, what changes are safe, and how the system evolves. Changing an API after multiple clients depend on it is expensive and risky—getting it right upfront saves significant pain.
This page covers API design principles that apply across protocols (REST, gRPC, GraphQL) and contexts (public, internal, event-driven). You'll learn to design APIs that are intuitive for developers, efficient for machines, and flexible for future requirements.
By the end of this page, you will understand core principles of API design that transcend specific technologies, compare REST, gRPC, and GraphQL for different scenarios, master resource modeling, endpoint design, and error handling, and learn versioning strategies for API evolution.
Before diving into specific technologies, let's establish principles that apply to all API designs.
APIs should be predictable. If /users returns a list of users, /orders should return a list of orders in the same format. Consistent naming, structure, and behavior reduce cognitive load for developers.
Apply consistency to:
A developer should be able to guess how your API works based on conventions. If they need to read documentation for every endpoint, the API is too complex.
Guidelines:
/orders), not verbs (/getOrders)/users/{id}/orders)Expose only what clients need. Every endpoint, field, and option you expose becomes a commitment to maintain. Start minimal and expand based on need.
Guidelines:
APIs must change over time without breaking existing clients. Design for evolution from day one.
Guidelines:
APIs should enable efficient use of resources—network bandwidth, processing time, and client memory.
Guidelines:
Show an API endpoint to a developer unfamiliar with your system. If they can't understand what it does within 3 seconds, it needs improvement. Good APIs are self-documenting.
Different API paradigms suit different contexts. Understanding their tradeoffs helps you choose appropriately.
Philosophy: Resources identified by URLs, manipulated via standard HTTP methods.
Strengths:
Weaknesses:
Best for: Public APIs, web/mobile clients, simple CRUD operations, systems requiring broad compatibility.
Philosophy: Strongly-typed RPC with Protocol Buffers for serialization.
Strengths:
Weaknesses:
Best for: Internal microservice communication, high-performance systems, polyglot environments, streaming data.
Philosophy: Clients specify exactly what data they need via a query language.
Strengths:
Weaknesses:
Best for: Complex client needs, rapidly evolving frontends, aggregating multiple data sources, mobile apps with bandwidth constraints.
| Aspect | REST | gRPC | GraphQL |
|---|---|---|---|
| Transport | HTTP/1.1 or HTTP/2 | HTTP/2 | HTTP (usually) |
| Format | JSON (typically) | Protocol Buffers (binary) | JSON |
| Schema | OpenAPI (optional) | Required (.proto files) | Required (SDL) |
| Typing | Weak/optional | Strong | Strong |
| Streaming | Limited (SSE, WebSocket) | Native bi-directional | Subscriptions |
| Browser support | Excellent | Via proxy | Excellent |
| Learning curve | Low | Medium | Medium-High |
| Typical use | Public APIs, web | Internal services | Aggregation, mobile |
Many systems use multiple paradigms: REST for public APIs, gRPC for internal service-to-service communication, and GraphQL as a BFF (Backend for Frontend) that aggregates internal services. Choose based on the specific use case.
REST remains the most common choice for public APIs. Let's explore best practices in depth.
REST APIs should be organized around resources—nouns representing entities. Operations are expressed through HTTP verbs, not URL actions.
Good (resource-oriented):
GET /orders → List orders
POST /orders → Create order
GET /orders/{id} → Get specific order
PUT /orders/{id} → Replace order
PATCH /orders/{id} → Partial update
DELETE /orders/{id} → Delete order
Bad (action-oriented):
GET /getOrders
POST /createOrder
POST /updateOrder
POST /deleteOrder
Use plural nouns for collections:
/users, /orders, /products (not /user, /order, /product)
Express relationships through hierarchy:
/users/{userId}/orders → Orders for a specific user
/orders/{orderId}/items → Items in a specific order
Limit nesting depth (2-3 levels max):
✅ /users/{id}/orders
✅ /orders/{id}/items
❌ /users/{id}/orders/{orderId}/items/{itemId}/reviews (too deep)
Use query parameters for filtering, sorting, pagination:
GET /orders?status=pending&sort=-createdAt&page=2&limit=20
123456789101112131415161718192021222324252627
# Order Resource Endpoints# ======================== # Collection endpointsGET /orders # List orders (with filters)POST /orders # Create new order # Single resource endpoints GET /orders/{orderId} # Get order detailsPUT /orders/{orderId} # Replace order (rarely used)PATCH /orders/{orderId} # Update order (status, etc.)DELETE /orders/{orderId} # Cancel/delete order # Nested resourcesGET /orders/{orderId}/items # List items in orderPOST /orders/{orderId}/items # Add item to order # Query parametersGET /orders?status=pending # Filter by statusGET /orders?customerId=123 # Filter by customerGET /orders?createdAfter=2024-01-01&createdBefore=2024-02-01GET /orders?sort=-createdAt # Sort descending by dateGET /orders?page=2&limit=50 # Pagination # Non-RESTful actions (when necessary)POST /orders/{orderId}/cancel # State transition actionPOST /orders/{orderId}/refund # Complex operationUse status codes consistently to communicate outcomes:
Success (2xx):
200 OK — Request succeeded, body contains result201 Created — Resource created, Location header points to it204 No Content — Request succeeded, no body (common for DELETE)Client Errors (4xx):
400 Bad Request — Invalid input, malformed request401 Unauthorized — Authentication required/failed403 Forbidden — Authenticated but not authorized404 Not Found — Resource doesn't exist409 Conflict — Conflicting operation (concurrent edit, duplicate key)422 Unprocessable Entity — Valid syntax but semantic errorsServer Errors (5xx):
500 Internal Server Error — Unexpected server failure502 Bad Gateway — Upstream service failure503 Service Unavailable — Temporary overload or maintenance504 Gateway Timeout — Upstream service timeoutThe structure of requests and responses significantly impacts API usability and performance.
Accept only what you need:
// Creating an order - minimal request
POST /orders
{
"customerId": "cust_123",
"items": [
{ "productId": "prod_456", "quantity": 2 }
],
"shippingAddressId": "addr_789"
}
Use IDs, not embedded objects:
customerId, not the entire customer objectproductId, not the entire product objectBe explicit about required vs optional:
Consistent envelope structure:
// Success response
{
"data": { /* actual resource */ },
"meta": { /* pagination, timing, etc. */ }
}
// Error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid order request",
"details": [
{ "field": "items[0].quantity", "message": "Must be positive" }
]
}
}
Include enough context:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// POST /orders - Request{ "customerId": "cust_abc123", "items": [ { "productId": "prod_xyz789", "quantity": 2, "notes": "Gift wrap please" } ], "shippingAddressId": "addr_def456", "billingAddressId": "addr_def456", "promoCode": "SAVE20"} // 201 Created - Response{ "data": { "id": "ord_new123", "status": "pending_payment", "customerId": "cust_abc123", "items": [ { "id": "item_001", "productId": "prod_xyz789", "productName": "Wireless Headphones", "quantity": 2, "unitPrice": 79.99, "subtotal": 159.98, "notes": "Gift wrap please" } ], "subtotal": 159.98, "discount": 32.00, "tax": 11.20, "total": 139.18, "shippingAddress": { "line1": "123 Main St", "city": "Seattle", "state": "WA", "zip": "98101" }, "createdAt": "2024-01-15T14:30:00Z", "updatedAt": "2024-01-15T14:30:00Z" }, "meta": { "requestId": "req_abc123xyz", "processingTimeMs": 145 }}After POST/PUT/PATCH, return the full resource representation. This saves the client from making a follow-up GET request and ensures they see server-computed fields (IDs, timestamps, calculated values).
Collection endpoints must handle large datasets efficiently. Pagination, filtering, and sorting are essential patterns.
1. Offset-based pagination:
GET /orders?page=3&limit=20
GET /orders?offset=40&limit=20
Pros: Simple, allows jumping to any page Cons: Inconsistent under concurrent writes (items shift), slow for large offsets
2. Cursor-based pagination:
GET /orders?limit=20&cursor=eyJpZCI6MTIzfQ==
Pros: Consistent even with concurrent writes, efficient for any position Cons: Can't jump to arbitrary page, cursor must be stable
3. Keyset pagination:
GET /orders?limit=20&createdAfter=2024-01-15T00:00:00Z&idAfter=12345
Pros: Uses natural ordering, very efficient Cons: Only works with sortable, unique keys
Include pagination metadata to enable navigation:
{
"data": [ /* items */ ],
"pagination": {
"total": 1547,
"limit": 20,
"page": 3,
"pages": 78,
"hasMore": true,
"nextCursor": "eyJpZCI6MTQwfQ==",
"prevCursor": "eyJpZCI6MTIxfQ=="
}
}
Equality filters:
GET /orders?status=pending
GET /orders?customerId=123
Range filters:
GET /orders?createdAfter=2024-01-01&createdBefore=2024-02-01
GET /orders?totalMin=100&totalMax=500
Multi-value filters:
GET /orders?status=pending,processing,shipped
| Strategy | Jump to Page | Concurrent Safe | Performance | Best For |
|---|---|---|---|---|
| Offset | ✅ Yes | ❌ No | Degrades with offset | Small datasets, admin UIs |
| Cursor | ❌ No | ✅ Yes | Consistent | Infinite scroll, feeds |
| Keyset | ❌ No | ✅ Yes | Optimal | Time-series, logs |
Single field sort:
GET /orders?sort=createdAt # Ascending (default)
GET /orders?sort=-createdAt # Descending (prefix with -)
Multi-field sort:
GET /orders?sort=-priority,createdAt # Priority desc, then created asc
Limit sortable fields:
Validate all filter and sort parameters. Allowing arbitrary field access could expose sensitive data or enable SQL injection. Whitelist allowed fields explicitly.
How an API handles errors significantly impacts developer experience. Clear, consistent, informative errors accelerate debugging.
A well-designed error response includes:
VALIDATION_ERROR, ORDER_NOT_FOUND)1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// 400 Bad Request - Validation Error{ "error": { "code": "VALIDATION_ERROR", "message": "Order validation failed", "details": [ { "field": "items[0].quantity", "code": "INVALID_VALUE", "message": "Quantity must be between 1 and 100", "received": -5 }, { "field": "shippingAddressId", "code": "REQUIRED_FIELD", "message": "Shipping address is required" } ], "requestId": "req_abc123xyz", "documentation": "https://api.example.com/docs/errors/VALIDATION_ERROR" }} // 404 Not Found{ "error": { "code": "ORDER_NOT_FOUND", "message": "Order with ID 'ord_123' does not exist", "requestId": "req_def456abc" }} // 429 Too Many Requests (Rate Limited){ "error": { "code": "RATE_LIMITED", "message": "Too many requests. Please retry after 30 seconds.", "retryAfter": 30, "requestId": "req_ghi789def" }} // 500 Internal Server Error{ "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred. Please try again or contact support.", "requestId": "req_jkl012ghi" }}Be specific about validation errors:
Use consistent error codes:
Include retry guidance:
Retry-After header and retryAfter fieldNever expose sensitive information:
APIs evolve. New features get added, old ones deprecated, and occasionally breaking changes are necessary. Versioning strategies manage this evolution while maintaining backward compatibility.
1. URL Path Versioning:
https://api.example.com/v1/orders
https://api.example.com/v2/orders
Pros: Highly visible, easy routing, clear documentation Cons: URL changes for every version, difficult to maintain multiple versions
2. Query Parameter Versioning:
https://api.example.com/orders?version=1
https://api.example.com/orders?version=2
Pros: URL stays clean Cons: Easy to forget, can be cached incorrectly
3. Header Versioning:
GET /orders
Accept: application/vnd.example.v2+json
# or
API-Version: 2
Pros: Clean URLs, proper content negotiation Cons: Not visible in URLs, harder to test in browser
| Strategy | Visibility | Caching | Routing Complexity | API Gateway Support |
|---|---|---|---|---|
| URL Path | High | Easy | Simple | Excellent |
| Query Param | Medium | Tricky | Medium | Good |
| Header | Low | Correct | Complex | Varies |
Not every change requires a new version. Understand what constitutes a breaking change:
Non-breaking (additive):
Breaking:
Deprecation policy:
Deprecation header to responses from deprecated versions410 Gone after sunsetThe best versioning strategy is needing fewer versions. Design APIs that can evolve through additive changes. Use optional fields, feature flags, and graceful degradation to minimize breaking changes.
API security is critical. Exposed APIs are attack surfaces that require protection at multiple levels.
Verify who is making the request.
API Keys:
Authorization: ApiKey xyz123OAuth 2.0 / JWT:
Authorization: Bearer eyJhbG...Mutual TLS (mTLS):
Verify what the authenticated user/service can do.
Protect against abuse and ensure fair usage:
Strategies:
Headers to include:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1705334400
Never trust client input:
An API is only as good as its documentation. Great documentation accelerates adoption and reduces support burden.
1. Reference documentation:
2. Conceptual guides:
3. Examples:
OpenAPI is the industry standard for REST API documentation:
Benefits:
Core elements:
info: API title, version, descriptionservers: Base URLs for different environmentspaths: All endpoints with operationscomponents/schemas: Reusable data modelssecurity: Authentication mechanisms123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
openapi: 3.0.3info: title: Order Management API version: 1.0.0 description: API for managing customer orders paths: /orders: get: summary: List orders parameters: - name: status in: query schema: type: string enum: [pending, processing, shipped, delivered] - name: limit in: query schema: type: integer default: 20 maximum: 100 responses: '200': description: List of orders content: application/json: schema: $ref: '#/components/schemas/OrderList' post: summary: Create order requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateOrderRequest' responses: '201': description: Order created content: application/json: schema: $ref: '#/components/schemas/Order' components: schemas: Order: type: object properties: id: type: string example: ord_abc123 status: type: string enum: [pending, processing, shipped, delivered] total: type: number format: decimal createdAt: type: string format: date-timeAPIs are the contracts that bind distributed components together. Let's consolidate the key principles:
What's next:
With components identified, architecture diagrammed, data flows traced, and APIs designed, we turn to the final crucial decision in high-level design: database selection. The next page covers how to choose the right data storage technologies for your system's needs.
You now understand how to design APIs that are intuitive, consistent, and evolvable. This skill defines how components communicate—a decision with long-lasting implications for system maintainability and developer experience.