Back to Technology

System Design Series Part 6: API Design & REST/GraphQL

January 25, 2026 Wasil Zafar 35 min read

Master API design best practices for building scalable web services. Learn REST principles, GraphQL advantages, versioning strategies, authentication patterns, and real-world API design considerations.

Table of Contents

  1. REST API Design
  2. GraphQL
  3. Best Practices
  4. Networking Protocols
  5. Next Steps

REST API Design

Series Navigation: This is Part 6 of the 15-part System Design Series. Review Part 5: Microservices Architecture first.

REST (Representational State Transfer) is an architectural style for designing networked applications. It relies on a stateless, client-server protocol—almost always HTTP—to provide standard operations on resources.

Key Insight: Good API design is about making the common cases easy and the complex cases possible. Think like a consumer of your API.

RESTful Principles

  • Client-Server: Separation of concerns—UI from data storage
  • Stateless: Each request contains all information needed
  • Cacheable: Responses can be cached to improve performance
  • Uniform Interface: Standardized methods and URI structure
  • Layered System: Client can't tell if connected to end server

Resource Naming Conventions

# Good REST URLs - Resources as nouns, plural
GET    /api/v1/users              # List all users
GET    /api/v1/users/123          # Get specific user
POST   /api/v1/users              # Create new user
PUT    /api/v1/users/123          # Update entire user
PATCH  /api/v1/users/123          # Partial update
DELETE /api/v1/users/123          # Delete user

# Nested resources
GET    /api/v1/users/123/orders           # User's orders
GET    /api/v1/users/123/orders/456       # Specific order
POST   /api/v1/users/123/orders           # Create order for user

# Bad REST URLs - Avoid verbs, actions
GET    /api/v1/getUsers           # ? Verb in URL
POST   /api/v1/createUser         # ? Verb in URL
GET    /api/v1/user               # ? Singular noun

HTTP Methods

Each HTTP method has specific semantics and properties:

Method Purpose Idempotent Safe Request Body
GET Retrieve resource ? Yes ? Yes No
POST Create resource ? No ? No Yes
PUT Replace resource ? Yes ? No Yes
PATCH Partial update ? No* ? No Yes
DELETE Remove resource ? Yes ? No Optional
HEAD Get headers only ? Yes ? Yes No
OPTIONS Get allowed methods ? Yes ? Yes No

*PATCH can be idempotent if designed carefully

Status Codes

# HTTP Status Codes - Use them correctly!

# 2xx Success
200 OK              # General success, return data
201 Created         # Resource created (return location header)
204 No Content      # Success, no response body (DELETE)

# 3xx Redirection
301 Moved Permanently    # Resource URL changed
304 Not Modified         # Cached version is current

# 4xx Client Errors
400 Bad Request     # Invalid syntax, validation error
401 Unauthorized    # Authentication required/failed
403 Forbidden       # Authenticated but not authorized
404 Not Found       # Resource doesn't exist
405 Method Not Allowed   # HTTP method not supported
409 Conflict        # State conflict (duplicate, version)
422 Unprocessable Entity  # Valid syntax, semantic error
429 Too Many Requests    # Rate limit exceeded

# 5xx Server Errors
500 Internal Server Error  # Unexpected server error
502 Bad Gateway            # Upstream server error
503 Service Unavailable    # Maintenance or overload
504 Gateway Timeout        # Upstream timeout

Response Format

// Successful response
{
    "data": {
        "id": 123,
        "name": "John Doe",
        "email": "john@example.com",
        "created_at": "2024-01-15T10:30:00Z"
    },
    "meta": {
        "request_id": "abc123",
        "response_time_ms": 45
    }
}

// Error response
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Invalid email format",
        "details": [
            {
                "field": "email",
                "message": "Must be a valid email address"
            }
        ]
    },
    "meta": {
        "request_id": "def456"
    }
}

Pagination

# Cursor-based pagination (recommended for large datasets)
GET /api/v1/users?cursor=eyJpZCI6MTAwfQ&limit=20

# Response
{
    "data": [...],
    "pagination": {
        "next_cursor": "eyJpZCI6MTIwfQ",
        "has_more": true
    }
}

# Offset-based pagination (simpler but less efficient)
GET /api/v1/users?page=5&per_page=20

# Response
{
    "data": [...],
    "pagination": {
        "total": 458,
        "page": 5,
        "per_page": 20,
        "total_pages": 23
    }
}

GraphQL

GraphQL is a query language for APIs developed by Facebook. It lets clients request exactly the data they need, solving over-fetching and under-fetching problems common in REST.

Schema Definition

# GraphQL Schema (SDL)
type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
    followers: [User!]!
    createdAt: DateTime!
}

type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
    likes: Int!
    publishedAt: DateTime
}

type Comment {
    id: ID!
    text: String!
    author: User!
    createdAt: DateTime!
}

type Query {
    user(id: ID!): User
    users(limit: Int, offset: Int): [User!]!
    post(id: ID!): Post
    posts(authorId: ID, published: Boolean): [Post!]!
}

type Mutation {
    createUser(input: CreateUserInput!): User!
    updateUser(id: ID!, input: UpdateUserInput!): User!
    createPost(input: CreatePostInput!): Post!
    deletePost(id: ID!): Boolean!
}

input CreateUserInput {
    name: String!
    email: String!
}

input CreatePostInput {
    title: String!
    content: String!
}

Queries

# GraphQL Query - Client requests exactly what it needs
query GetUserWithPosts($userId: ID!) {
    user(id: $userId) {
        name
        email
        posts {
            id
            title
            likes
            comments {
                text
                author {
                    name
                }
            }
        }
    }
}

# Variables
{
    "userId": "123"
}

# Response - Only requested fields
{
    "data": {
        "user": {
            "name": "John Doe",
            "email": "john@example.com",
            "posts": [
                {
                    "id": "post_1",
                    "title": "My First Post",
                    "likes": 42,
                    "comments": [
                        {
                            "text": "Great post!",
                            "author": { "name": "Jane Smith" }
                        }
                    ]
                }
            ]
        }
    }
}

Mutations

# GraphQL Mutation - Create/Update data
mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
        id
        title
        content
        publishedAt
        author {
            name
        }
    }
}

# Variables
{
    "input": {
        "title": "GraphQL Best Practices",
        "content": "Here's what I learned..."
    }
}

Subscriptions (Real-time)

# GraphQL Subscription - Real-time updates
subscription OnNewComment($postId: ID!) {
    commentAdded(postId: $postId) {
        id
        text
        author {
            name
            avatar
        }
        createdAt
    }
}

# Server pushes updates when new comments arrive

REST vs GraphQL

Aspect REST GraphQL
Data Fetching Multiple endpoints, fixed responses Single endpoint, flexible queries
Over/Under-fetching Common problem Request exactly what you need
Versioning URL versioning (/v1/, /v2/) Evolve schema, deprecate fields
Caching HTTP caching built-in More complex, client-side caching
Learning Curve Familiar HTTP semantics New query language, tooling
Documentation OpenAPI/Swagger Self-documenting schema
Best For Simple CRUD, public APIs, caching-heavy Complex data relationships, mobile apps
Choosing Between Them: Use REST for simple, resource-based APIs with good caching needs. Use GraphQL when clients have diverse data needs or you're building mobile apps where bandwidth matters.

Best Practices

1. Versioning

# URL Path Versioning (most common)
GET /api/v1/users
GET /api/v2/users

# Header Versioning
GET /api/users
Accept: application/vnd.myapi.v2+json

# Query Parameter Versioning
GET /api/users?version=2

2. Error Handling

# Consistent error format
from flask import Flask, jsonify

app = Flask(__name__)

class APIError(Exception):
    def __init__(self, code, message, details=None, status_code=400):
        self.code = code
        self.message = message
        self.details = details or []
        self.status_code = status_code

@app.errorhandler(APIError)
def handle_api_error(error):
    return jsonify({
        "error": {
            "code": error.code,
            "message": error.message,
            "details": error.details
        }
    }), error.status_code

# Usage
raise APIError(
    code="VALIDATION_ERROR",
    message="Invalid input data",
    details=[{"field": "email", "message": "Invalid format"}],
    status_code=422
)

3. Request Validation

# Using Pydantic for validation
from pydantic import BaseModel, EmailStr, validator
from typing import Optional

class CreateUserRequest(BaseModel):
    name: str
    email: EmailStr
    age: Optional[int] = None
    
    @validator('name')
    def name_must_not_be_empty(cls, v):
        if not v.strip():
            raise ValueError('Name cannot be empty')
        return v.strip()
    
    @validator('age')
    def age_must_be_positive(cls, v):
        if v is not None and v < 0:
            raise ValueError('Age must be positive')
        return v

# Automatically validates and returns 422 on error

4. Filtering, Sorting, and Field Selection

# Flexible querying
GET /api/v1/products?
    category=electronics&
    price_min=100&
    price_max=500&
    sort=-created_at,name&    # Descending created_at, then name
    fields=id,name,price      # Only return these fields

# Implementation
def get_products(request):
    query = Product.query
    
    # Filtering
    if category := request.args.get('category'):
        query = query.filter(Product.category == category)
    if price_min := request.args.get('price_min'):
        query = query.filter(Product.price >= float(price_min))
    
    # Sorting
    sort_fields = request.args.get('sort', 'id').split(',')
    for field in sort_fields:
        if field.startswith('-'):
            query = query.order_by(desc(getattr(Product, field[1:])))
        else:
            query = query.order_by(getattr(Product, field))
    
    # Field selection
    fields = request.args.get('fields', '').split(',')
    
    return query.all()

Authentication

JWT (JSON Web Token)

# JWT Authentication Flow
import jwt
from datetime import datetime, timedelta

SECRET_KEY = "your-secret-key"

def create_token(user_id):
    payload = {
        "sub": user_id,
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(hours=24)
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def verify_token(token):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        return payload["sub"]
    except jwt.ExpiredSignatureError:
        raise APIError("TOKEN_EXPIRED", "Token has expired", status_code=401)
    except jwt.InvalidTokenError:
        raise APIError("INVALID_TOKEN", "Invalid token", status_code=401)

# Usage in requests
# Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Stateless Scalable

OAuth 2.0 / OpenID Connect

For third-party authentication and authorization:

# OAuth 2.0 Authorization Code Flow
# 1. Redirect user to authorization server
GET https://auth.example.com/authorize?
    response_type=code&
    client_id=YOUR_CLIENT_ID&
    redirect_uri=https://yourapp.com/callback&
    scope=openid profile email&
    state=random_state

# 2. User authenticates and grants permission

# 3. Receive authorization code at callback
GET https://yourapp.com/callback?code=AUTH_CODE&state=random_state

# 4. Exchange code for tokens
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET

# 5. Receive access token and ID token
{
    "access_token": "eyJ...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "dGhpc...",
    "id_token": "eyJ..."
}

API Keys

For server-to-server authentication:

# API Key Authentication
# Header approach (recommended)
X-API-Key: your-api-key-here

# Implementation
from functools import wraps
from flask import request

def require_api_key(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        api_key = request.headers.get('X-API-Key')
        
        if not api_key:
            raise APIError("MISSING_API_KEY", "API key required", status_code=401)
        
        # Validate key (check database, cache)
        client = validate_api_key(api_key)
        if not client:
            raise APIError("INVALID_API_KEY", "Invalid API key", status_code=401)
        
        # Attach client info for rate limiting, logging
        request.client = client
        return f(*args, **kwargs)
    
    return decorated

Networking & Communication Protocols

Key Insight: Understanding the underlying networking protocols is crucial for designing efficient APIs. HTTP sits on top of TCP/IP, and knowing when to use alternatives like WebSockets or gRPC can significantly improve system performance.

HTTP/2 vs HTTP/1.1

Feature HTTP/1.1 HTTP/2
Multiplexing Sequential requests (head-of-line blocking) Multiple streams over single connection
Header Compression None (repeated headers) HPACK compression
Server Push No Yes (proactively send resources)
Binary Protocol Text-based Binary framing

WebSockets & Real-Time Communication

WebSockets provide full-duplex communication channels over a single TCP connection, ideal for real-time applications.

WebSocket Implementation

# Server-side (Python with websockets library)
import asyncio
import websockets
import json

connected_clients = set()

async def handler(websocket, path):
    # Register client
    connected_clients.add(websocket)
    
    try:
        async for message in websocket:
            data = json.loads(message)
            
            if data["type"] == "chat_message":
                # Broadcast to all connected clients
                await broadcast({
                    "type": "new_message",
                    "user": data["user"],
                    "message": data["message"],
                    "timestamp": datetime.utcnow().isoformat()
                })
                
    finally:
        connected_clients.remove(websocket)

async def broadcast(message):
    if connected_clients:
        await asyncio.gather(
            *[client.send(json.dumps(message)) for client in connected_clients]
        )

# Start server
asyncio.run(websockets.serve(handler, "localhost", 8765))
// Client-side (JavaScript)
const socket = new WebSocket('wss://example.com/ws');

socket.onopen = () => {
    console.log('Connected');
    socket.send(JSON.stringify({
        type: 'chat_message',
        user: 'John',
        message: 'Hello!'
    }));
};

socket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
};

socket.onclose = () => console.log('Disconnected');
socket.onerror = (error) => console.error('Error:', error);
Real-Time Bidirectional

When to Use WebSockets

  • ? Chat applications
  • ? Live notifications
  • ? Real-time dashboards
  • ? Multiplayer games
  • ? Collaborative editing
  • ? Simple request-response (use REST)
  • ? Occasional updates (use polling or SSE)

gRPC & Protocol Buffers

gRPC is a high-performance RPC framework using Protocol Buffers for serialization. It's ideal for microservices communication.

Protocol Buffer Definition

# user.proto - Define service and messages
syntax = "proto3";

package user;

service UserService {
    // Unary RPC
    rpc GetUser(GetUserRequest) returns (User);
    
    // Server streaming
    rpc ListUsers(ListUsersRequest) returns (stream User);
    
    // Client streaming
    rpc CreateUsers(stream CreateUserRequest) returns (CreateUsersResponse);
    
    // Bidirectional streaming
    rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

message User {
    string id = 1;
    string name = 2;
    string email = 3;
    int32 age = 4;
}

message GetUserRequest {
    string id = 1;
}

message ListUsersRequest {
    int32 page_size = 1;
    string page_token = 2;
}

gRPC Server Implementation

# Python gRPC Server
import grpc
from concurrent import futures
import user_pb2
import user_pb2_grpc

class UserService(user_pb2_grpc.UserServiceServicer):
    def GetUser(self, request, context):
        # Fetch user from database
        user = db.get_user(request.id)
        
        if not user:
            context.set_code(grpc.StatusCode.NOT_FOUND)
            context.set_details(f"User {request.id} not found")
            return user_pb2.User()
        
        return user_pb2.User(
            id=user.id,
            name=user.name,
            email=user.email,
            age=user.age
        )
    
    def ListUsers(self, request, context):
        # Server streaming - yield multiple responses
        users = db.list_users(page_size=request.page_size)
        for user in users:
            yield user_pb2.User(
                id=user.id,
                name=user.name,
                email=user.email
            )

# Start server
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
user_pb2_grpc.add_UserServiceServicer_to_server(UserService(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()

gRPC vs REST

Aspect REST gRPC
Protocol HTTP/1.1 (usually) HTTP/2
Data Format JSON (text) Protocol Buffers (binary)
Performance Good Excellent (10x faster serialization)
Streaming Limited Built-in (all 4 types)
Browser Support Native Requires grpc-web proxy
Best For Public APIs, web clients Internal microservices, mobile apps
Performance Comparison: In benchmarks, gRPC is typically 7-10x faster than REST+JSON for serialization/deserialization, and 2-3x faster in end-to-end latency due to HTTP/2 multiplexing.

Next Steps

API Design Specification Generator

Design your REST or GraphQL API specification with endpoints, authentication, and versioning. Download as Word, Excel, or PDF.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Technology