API design is one of the most consequential decisions in software architecture. A well-designed API is a joy to work with, evolves gracefully over years, and becomes a competitive advantage. A poorly designed API becomes a permanent tax on every team that touches it β and changing it after launch ranges from painful to impossible. We've seen companies spend millions refactoring APIs that were designed in a week during a sprint.
This guide covers practical API design decisions: when to use REST vs. GraphQL vs. gRPC, how to handle versioning, pagination, error responses, authentication, and rate limiting β all based on patterns we've implemented across hundreds of production APIs.
REST: The Default Choice (and That's Fine)
REST (Representational State Transfer) is the default API style, and for good reason: it's simple, well-understood, works with every HTTP client in every language, and has mature tooling for documentation (OpenAPI/Swagger), testing (Postman, curl), and monitoring. If you don't have a specific reason to use GraphQL or gRPC, use REST.
Good REST API design follows these principles:
Resources, not actions. URLs should represent resources (nouns), not actions (verbs). GET /orders/123 not GET /getOrder?id=123. POST /orders not POST /createOrder. The HTTP method (GET, POST, PUT, PATCH, DELETE) conveys the action.
// Well-designed REST API for an e-commerce platform
// Resources follow consistent naming (plural nouns)
GET /api/v1/products // List products (with pagination)
GET /api/v1/products/42 // Get product by ID
POST /api/v1/products // Create product
PATCH /api/v1/products/42 // Update product (partial)
DELETE /api/v1/products/42 // Delete product
// Sub-resources for relationships
GET /api/v1/products/42/reviews // List reviews for product 42
POST /api/v1/products/42/reviews // Add review to product 42
// Filtering, sorting, pagination via query params
GET /api/v1/products?category=electronics&sort=-price&page=2&limit=20
// Search is a special case β it's an action, not a resource
GET /api/v1/search?q=wireless+headphones&category=electronics
// Response format (consistent envelope)
{
"data": {
"id": 42,
"name": "Wireless Headphones",
"price": 79.99,
"currency": "USD",
"category": "electronics",
"created_at": "2026-02-15T10:30:00Z",
"updated_at": "2026-02-15T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}
// List response with pagination
{
"data": [ ... ],
"pagination": {
"page": 2,
"limit": 20,
"total": 156,
"total_pages": 8,
"next": "/api/v1/products?page=3&limit=20",
"prev": "/api/v1/products?page=1&limit=20"
}
}
GraphQL: When Your Clients Need Flexibility
GraphQL shines when: (1) you have multiple client types (web, mobile, TV, embedded) that need different data shapes from the same API, (2) your data is deeply nested/relational and REST would require many round trips to assemble, or (3) you want clients to be able to evolve independently from the backend without waiting for new REST endpoints.
GraphQL's strengths: clients request exactly the data they need (no over-fetching or under-fetching), a single endpoint handles all queries, and the schema is self-documenting. GraphQL's weaknesses: caching is harder (no HTTP-level caching), rate limiting requires custom implementation, N+1 query problems are easy to create, and file uploads require workarounds.
# GraphQL schema design best practices
type Query {
# Specific queries, not generic "get anything"
product(id: ID!): Product
products(
filter: ProductFilter
sort: ProductSort
pagination: PaginationInput
): ProductConnection!
# Search is separate from listing
searchProducts(query: String!, limit: Int = 10): [Product!]!
}
type Mutation {
createProduct(input: CreateProductInput!): CreateProductPayload!
updateProduct(id: ID!, input: UpdateProductInput!): UpdateProductPayload!
deleteProduct(id: ID!): DeleteProductPayload!
}
# Input types for mutations (separate from output types)
input CreateProductInput {
name: String!
price: Float!
currency: String! = "USD"
categoryId: ID!
description: String
}
# Payload types include errors (don't rely on GraphQL errors for business logic)
type CreateProductPayload {
product: Product
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: String!
}
# Relay-style connections for pagination
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
gRPC: When Performance is Critical
gRPC is the right choice for internal service-to-service communication where: (1) latency matters (gRPC uses HTTP/2 with binary Protocol Buffer encoding β 5-10x faster than JSON over HTTP/1.1), (2) you need streaming (server-sent events, client streaming, or bidirectional streaming), or (3) you want strong type safety across multiple languages.
// product_service.proto
syntax = "proto3";
package ecommerce.v1;
service ProductService {
// Unary RPC
rpc GetProduct(GetProductRequest) returns (Product);
rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
rpc CreateProduct(CreateProductRequest) returns (Product);
// Server streaming β real-time price updates
rpc StreamPriceUpdates(StreamPriceUpdatesRequest)
returns (stream PriceUpdate);
// Bidirectional streaming β real-time chat/negotiation
rpc NegotiatePrice(stream PriceNegotiationMessage)
returns (stream PriceNegotiationMessage);
}
message Product {
string id = 1;
string name = 2;
int64 price_cents = 3; // Store money as cents, not floats
string currency = 4;
string category_id = 5;
google.protobuf.Timestamp created_at = 6;
}
message ListProductsRequest {
int32 page_size = 1;
string page_token = 2; // Cursor-based pagination
string filter = 3; // e.g., "category_id = 'electronics'"
string order_by = 4; // e.g., "price desc"
}
message ListProductsResponse {
repeated Product products = 1;
string next_page_token = 2;
int32 total_count = 3;
}
Error Handling: The Most Overlooked API Design Decision
Error responses should be consistent, informative, and safe (no stack traces or internal details in production). A good error response tells the developer: what went wrong, why, and what to do about it.
// Standard error response format (works for REST and GraphQL)
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid data.",
"details": [
{
"field": "email",
"message": "Invalid email format.",
"code": "INVALID_FORMAT"
},
{
"field": "price",
"message": "Price must be greater than 0.",
"code": "INVALID_RANGE",
"meta": { "min": 0.01 }
}
],
"request_id": "req_abc123",
"documentation_url": "https://api.yourcompany.com/docs/errors#VALIDATION_ERROR"
}
}
// HTTP status code mapping:
// 400 Bad Request β Client sent invalid data
// 401 Unauthorized β Missing or invalid authentication
// 403 Forbidden β Authenticated but not authorized
// 404 Not Found β Resource doesn't exist
// 409 Conflict β Resource conflict (e.g., duplicate email)
// 422 Unprocessable Entity β Valid syntax but semantic errors
// 429 Too Many Requests β Rate limited
// 500 Internal Server Error β Server bug (NEVER expose details)
// 503 Service Unavailable β Temporary downtime
API Versioning: Plan for Change
APIs change. New fields are added, old fields are deprecated, behavior evolves. The question isn't whether you'll need to version your API, but how.
URL versioning (/api/v1/, /api/v2/): The simplest and most explicit approach. Easy to understand, easy to route, easy to deprecate. Downside: major version bumps require clients to update all URLs. Best for public APIs with external consumers.
Header versioning (Accept: application/vnd.api+json;version=2): Cleaner URLs but harder to discover, test, and debug. Best for internal APIs where you control all clients.
Additive changes only (no versioning): Never remove fields, never change field types, only add new fields with sensible defaults. This avoids breaking changes entirely. Works well for APIs with a small number of controlled consumers. GraphQL naturally supports this approach through schema evolution.
Regardless of versioning strategy, follow these rules: provide at least 12 months deprecation notice before removing a version, include sunset headers (Sunset: Sat, 01 Jan 2028 00:00:00 GMT), and track which API versions each client is using so you can proactively reach out before deprecation.
Choosing the Right API Style
Use REST when: Building public APIs for external developers, simple CRUD operations, you want maximum tooling support and cacheability, or your team is most comfortable with REST.
Use GraphQL when: Multiple client types need different data shapes, deeply nested/relational data, you want clients to evolve independently, or you're building a developer platform.
Use gRPC when: Internal service-to-service communication, streaming is required, performance is critical (high-throughput, low-latency), or you need strong type safety across languages.
Mix and match: Many successful architectures use REST for public APIs, gRPC for internal services, and GraphQL for frontend-to-BFF (Backend for Frontend) communication. Don't force one style for all use cases.
ZeonEdge designs and implements APIs for startups and enterprises. From initial schema design to documentation, rate limiting, and monitoring β we build APIs that last. Explore our development services.
Emily Watson
Technical Writer and Developer Advocate who simplifies complex technology for everyday readers.