API Design
An API is a contract between systems. A well-designed API is predictable, discoverable, and resilient to change. In distributed systems, APIs are also the seams between services; their quality determines how easily the system can evolve.
This guide compares REST, GraphQL, and gRPC; covers versioning, pagination, idempotency, and rate limiting; and provides practical patterns for designing APIs that scale and fail gracefully.
Whether you are exposing a public API or defining internal service contracts, the principles here will help you build interfaces that other engineers want to use.
REST (Representational State Transfer) models resources as URLs and uses HTTP methods to operate on them. It is stateless, cacheable, and leverages the semantics of HTTP status codes. REST is the default choice for public web APIs because it is simple, familiar, and works everywhere.
| 1 | REST resource design: |
| 2 | |
| 3 | GET /users → list users |
| 4 | GET /users/{id} → retrieve a user |
| 5 | POST /users → create a user |
| 6 | PUT /users/{id} → full update |
| 7 | PATCH /users/{id} → partial update |
| 8 | DELETE /users/{id} → delete a user |
| 9 | |
| 10 | GET /users/{id}/orders → list user's orders |
| 11 | POST /users/{id}/orders → create an order for user |
| 12 | |
| 13 | Status codes: |
| 14 | 200 OK, 201 Created, 204 No Content |
| 15 | 400 Bad Request, 401 Unauthorized, 403 Forbidden |
| 16 | 404 Not Found, 409 Conflict, 429 Too Many Requests |
| 17 | 500 Internal Server Error, 502/503/504 Gateway errors |
| Principle | Meaning |
|---|---|
| Resources | URLs name things, not actions |
| Statelessness | Each request contains all context |
| Uniform interface | Standard methods and media types |
| Cacheability | Responses declare cacheability |
best practice
GraphQL is a query language for APIs. Clients request exactly the fields they need, and the server resolves the query by fetching data from underlying sources. GraphQL is powerful for mobile and composite UIs where over-fetching and under-fetching are common problems.
| 1 | GraphQL query: |
| 2 | |
| 3 | query { |
| 4 | user(id: "123") { |
| 5 | name |
| 6 | |
| 7 | orders(first: 5) { |
| 8 | id |
| 9 | total |
| 10 | items { |
| 11 | name |
| 12 | price |
| 13 | } |
| 14 | } |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | GraphQL mutation: |
| 19 | |
| 20 | mutation { |
| 21 | createOrder(input: { userId: "123", items: [...] }) { |
| 22 | order { |
| 23 | id |
| 24 | total |
| 25 | } |
| 26 | errors |
| 27 | } |
| 28 | } |
| Aspect | REST | GraphQL |
|---|---|---|
| Data shape | Server-defined endpoints | Client-defined queries |
| Over-fetching | Common | Avoided |
| Caching | HTTP caching works well | Requires custom strategies |
| Tooling | OpenAPI, Postman | Schema introspection, Playground |
| Complexity risk | Endpoint explosion | Query complexity attacks |
warning
gRPC is a high-performance RPC framework that uses Protocol Buffers for serialization and HTTP/2 for transport. It supports unary calls, server streaming, client streaming, and bidirectional streaming. gRPC is ideal for internal service-to-service communication where both ends are controlled.
| 1 | Protocol Buffers service definition: |
| 2 | |
| 3 | service OrderService { |
| 4 | rpc GetOrder(GetOrderRequest) returns (Order); |
| 5 | rpc CreateOrder(CreateOrderRequest) returns (Order); |
| 6 | rpc StreamOrders(StreamOrdersRequest) returns (stream Order); |
| 7 | } |
| 8 | |
| 9 | message GetOrderRequest { |
| 10 | string order_id = 1; |
| 11 | } |
| 12 | |
| 13 | message Order { |
| 14 | string order_id = 1; |
| 15 | string user_id = 2; |
| 16 | repeated OrderItem items = 3; |
| 17 | double total = 4; |
| 18 | } |
info
APIs evolve. Versioning lets you introduce breaking changes without immediately breaking existing clients. The most common strategies are URL versioning, header versioning, and content negotiation.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/users | Simple, visible | Clutters URLs |
| Header | API-Version: v1 | Clean URLs | Less discoverable |
| Content type | Accept: application/vnd.api.v1+json | HTTP-native | Verbose |
best practice
An operation is idempotent if performing it multiple times has the same effect as performing it once. Idempotency is essential for distributed systems where network failures, retries, and duplicate requests are common. GET, PUT, and DELETE should be idempotent by design. POST for creation should use idempotency keys.
| 1 | Idempotency key flow: |
| 2 | |
| 3 | Client ──POST /orders──▶ Server |
| 4 | Idempotency-Key: abc-123 |
| 5 | |
| 6 | Server: |
| 7 | 1. Check store for key abc-123 |
| 8 | 2. If found: return stored response |
| 9 | 3. If not found: execute operation, |
| 10 | store response with key, return response |
| 11 | |
| 12 | Retry: |
| 13 | Client ──POST /orders──▶ Server |
| 14 | Idempotency-Key: abc-123 |
| 15 | |
| 16 | Server returns stored response; no duplicate created. |
warning
Rate limiting protects APIs from abuse and ensures fair resource allocation. It restricts how many requests a client can make in a time window. Common algorithms include token bucket, leaky bucket, fixed window, and sliding window.
| Algorithm | Behavior | Best For |
|---|---|---|
| Token bucket | Tokens added at fixed rate; each request consumes one | Bursty traffic, general APIs |
| Leaky bucket | Requests enter queue and exit at fixed rate | Smoothing traffic |
| Fixed window | Count requests per time window | Simple implementations |
| Sliding window | Count requests over rolling interval | Avoids window-edge bursts |
info
Pagination prevents APIs from returning unbounded result sets. The three common strategies are offset, cursor, and keyset pagination. Each has different performance and consistency tradeoffs.
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Offset | ?page=2&limit=20 | Simple, supports random access | Slow at deep pages, inconsistent under writes |
| Cursor | ?after=opaque_cursor | Consistent, efficient | Cannot jump to arbitrary page |
| Keyset | ?last_id=123&limit=20 | Fast, stable | Tied to sort key |
best practice
APIs fail. How they fail determines whether clients can recover gracefully. Use standard HTTP status codes, include machine-readable error codes in the body, and avoid leaking internal details.
| 1 | { |
| 2 | "error": { |
| 3 | "code": "INSUFFICIENT_INVENTORY", |
| 4 | "message": "Requested quantity exceeds available stock.", |
| 5 | "target": "items[0].quantity", |
| 6 | "details": [ |
| 7 | { |
| 8 | "code": "MAX_AVAILABLE", |
| 9 | "message": "Only 3 units are available.", |
| 10 | "target": "items[0].sku" |
| 11 | } |
| 12 | ] |
| 13 | } |
| 14 | } |
warning